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

@@ -340,7 +340,12 @@ export class AttachmentTransferService {
!messageId || !fileId ||
typeof index !== 'number' ||
typeof total !== 'number' ||
typeof data !== 'string'
typeof data !== 'string' ||
!Number.isInteger(index) ||
!Number.isInteger(total) ||
total <= 0 ||
index < 0 ||
index >= total
) {
return;
}
@@ -351,6 +356,14 @@ export class AttachmentTransferService {
if (!attachment)
return;
if ((attachment.receivedBytes ?? 0) > attachment.size) {
return;
}
if (!this.shouldReceiveToDisk(attachment) && attachment.size > MAX_AUTO_SAVE_SIZE_BYTES) {
return;
}
if (this.shouldReceiveToDisk(attachment)) {
this.enqueueDiskFileChunk(attachment, {
data,

View File

@@ -2,6 +2,7 @@ import { Injectable, inject } from '@angular/core';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import type { Attachment } from '../../domain/models/attachment.model';
import {
isAllowedAttachmentStoredPath,
resolveAttachmentStorageBucket,
resolveAttachmentStoredFilename,
sanitizeAttachmentRoomName
@@ -74,7 +75,9 @@ export class AttachmentStorageService {
return null;
}
return this.findExistingPath([`${appDataPath}/server/${sanitizeAttachmentRoomName(roomName)}/image/${filename}`]);
const safeFilename = resolveAttachmentStoredFilename('legacy', filename);
return this.findExistingPath([`${appDataPath}/server/${sanitizeAttachmentRoomName(roomName)}/image/${safeFilename}`]);
}
async readFile(filePath: string): Promise<string | null> {
@@ -234,13 +237,14 @@ export class AttachmentStorageService {
private async findExistingPath(candidates: (string | null | undefined)[]): Promise<string | null> {
const electronApi = this.electronBridge.getApi();
const appDataPath = await this.resolveAppDataPath();
if (!electronApi) {
if (!electronApi || !appDataPath) {
return null;
}
for (const candidatePath of candidates) {
if (!candidatePath) {
if (!candidatePath || !isAllowedAttachmentStoredPath(candidatePath, appDataPath)) {
continue;
}

View File

@@ -0,0 +1,26 @@
import {
describe,
it,
expect
} from 'vitest';
import { isAllowedAttachmentStoredPath, resolveAttachmentStoredFilename } from './attachment-storage.util';
describe('attachment-storage.util', () => {
it('allows attachment paths under server and direct-messages roots', () => {
const appDataPath = '/home/user/.config/metoyou';
expect(isAllowedAttachmentStoredPath(`${appDataPath}/server/room-1/image/file.png`, appDataPath)).toBe(true);
expect(isAllowedAttachmentStoredPath(`${appDataPath}/direct-messages/dm-1/files/file.bin`, appDataPath)).toBe(true);
});
it('rejects paths outside attachment roots', () => {
const appDataPath = '/home/user/.config/metoyou';
expect(isAllowedAttachmentStoredPath('/etc/passwd', appDataPath)).toBe(false);
expect(isAllowedAttachmentStoredPath(`${appDataPath}/plugins/evil.js`, appDataPath)).toBe(false);
});
it('sanitizes legacy filenames to basename-only storage names', () => {
expect(resolveAttachmentStoredFilename('legacy', '../../escape.txt')).toBe('legacy.txt');
});
});

View File

@@ -26,6 +26,21 @@ export function resolveAttachmentStoredFilename(attachmentId: string, filename:
: `${sanitizedAttachmentId}${sanitizedExtension}`;
}
export function isAllowedAttachmentStoredPath(candidatePath: string, appDataPath: string): boolean {
const normalizedCandidate = candidatePath.trim().replace(/\\/g, '/');
const normalizedRoot = appDataPath.trim().replace(/\\/g, '/')
.replace(/\/+$/, '');
if (!normalizedCandidate.startsWith(`${normalizedRoot}/`)) {
return false;
}
const relativePath = normalizedCandidate.slice(normalizedRoot.length + 1);
return relativePath.startsWith('server/')
|| relativePath.startsWith('direct-messages/');
}
export function resolveAttachmentStorageBucket(mime: string): 'video' | 'audio' | 'image' | 'files' {
if (mime.startsWith('video/')) {
return 'video';

View File

@@ -0,0 +1,46 @@
import {
describe,
it,
expect,
beforeEach
} from 'vitest';
import { AuthTokenStoreService } from './auth-token-store.service';
describe('AuthTokenStoreService', () => {
let service: AuthTokenStoreService;
const storage = new Map<string, string>();
beforeEach(() => {
storage.clear();
vi.stubGlobal('localStorage', {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => { storage.set(key, value); },
removeItem: (key: string) => { storage.delete(key); },
clear: () => { storage.clear(); }
});
service = new AuthTokenStoreService();
});
it('reports whether any non-expired token remains', () => {
expect(service.hasAnyValidToken()).toBe(false);
service.setToken('http://localhost:3001', 'token-abc', Date.now() + 60_000);
expect(service.hasAnyValidToken()).toBe(true);
});
it('stores and resolves tokens by signaling server url', () => {
service.setToken('http://localhost:3001', 'token-abc', Date.now() + 60_000);
expect(service.getToken('http://localhost:3001')).toBe('token-abc');
expect(service.findTokenForApiUrl('http://localhost:3001/api/servers')).toBe('token-abc');
});
it('clears expired tokens on read', () => {
service.setToken('http://localhost:3001', 'expired-token', Date.now() - 1);
expect(service.getToken('http://localhost:3001')).toBeNull();
});
});

View File

@@ -0,0 +1,91 @@
import { Injectable } from '@angular/core';
interface StoredAuthToken {
token: string;
expiresAt: number;
}
const STORAGE_KEY = 'metoyou.authTokens';
@Injectable({ providedIn: 'root' })
export class AuthTokenStoreService {
setToken(serverUrl: string, token: string, expiresAt: number): void {
const normalizedUrl = this.normalizeServerUrl(serverUrl);
const store = this.readStore();
store[normalizedUrl] = { token, expiresAt };
this.writeStore(store);
}
getToken(serverUrl: string): string | null {
const normalizedUrl = this.normalizeServerUrl(serverUrl);
const entry = this.readStore()[normalizedUrl];
if (!entry) {
return null;
}
if (entry.expiresAt <= Date.now()) {
this.clearToken(serverUrl);
return null;
}
return entry.token;
}
clearToken(serverUrl: string): void {
const normalizedUrl = this.normalizeServerUrl(serverUrl);
const store = this.readStore();
const nextStore = Object.fromEntries(
Object.entries(store).filter(([key]) => key !== normalizedUrl)
) as Record<string, StoredAuthToken>;
this.writeStore(nextStore);
}
hasAnyValidToken(): boolean {
const now = Date.now();
return Object.values(this.readStore()).some((entry) => entry.expiresAt > now);
}
findTokenForApiUrl(apiUrl: string): string | null {
const normalizedApiUrl = apiUrl.trim().replace(/\/+$/, '');
for (const [serverUrl, entry] of Object.entries(this.readStore())) {
if (entry.expiresAt <= Date.now()) {
continue;
}
if (normalizedApiUrl === serverUrl || normalizedApiUrl.startsWith(`${serverUrl}/`)) {
return entry.token;
}
}
return null;
}
private readStore(): Record<string, StoredAuthToken> {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) {
return {};
}
const parsed = JSON.parse(raw) as Record<string, StoredAuthToken>;
return parsed && typeof parsed === 'object' ? parsed : {};
} catch {
return {};
}
}
private writeStore(store: Record<string, StoredAuthToken>): void {
localStorage.setItem(STORAGE_KEY, JSON.stringify(store));
}
private normalizeServerUrl(serverUrl: string): string {
return serverUrl.trim().replace(/\/+$/, '');
}
}

View File

@@ -1,9 +1,11 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Observable, tap } from 'rxjs';
import { type ServerEndpoint, ServerDirectoryFacade } from '../../../server-directory';
import type { LoginResponse } from '../../domain/models/authentication.model';
import { AuthTokenStoreService } from './auth-token-store.service';
import { MessageSigningService } from './message-signing.service';
/**
* Handles user authentication (login and registration) against a
@@ -17,6 +19,8 @@ import type { LoginResponse } from '../../domain/models/authentication.model';
export class AuthenticationService {
private readonly http = inject(HttpClient);
private readonly serverDirectory = inject(ServerDirectoryFacade);
private readonly authTokenStore = inject(AuthTokenStoreService);
private readonly messageSigning = inject(MessageSigningService);
/**
* Resolve the API base URL for the given server.
@@ -25,6 +29,14 @@ export class AuthenticationService {
* currently active endpoint is used.
* @returns Fully-qualified API base URL (e.g. `http://host:3001/api`).
*/
private resolveServerUrl(serverId?: string): string {
return this.endpointFor(serverId).replace(/\/api$/, '');
}
private persistSessionToken(serverId: string | undefined, response: LoginResponse): void {
this.authTokenStore.setToken(this.resolveServerUrl(serverId), response.token, response.expiresAt);
}
private endpointFor(serverId?: string): string {
let endpoint: ServerEndpoint | undefined;
@@ -63,7 +75,12 @@ export class AuthenticationService {
username: params.username,
password: params.password,
displayName: params.displayName
});
}).pipe(
tap((response) => {
this.persistSessionToken(params.serverId, response);
void this.messageSigning.registerSigningPublicKeyIfNeeded().catch(() => {});
})
);
}
/**
@@ -85,6 +102,11 @@ export class AuthenticationService {
return this.http.post<LoginResponse>(url, {
username: params.username,
password: params.password
});
}).pipe(
tap((response) => {
this.persistSessionToken(params.serverId, response);
void this.messageSigning.registerSigningPublicKeyIfNeeded().catch(() => {});
})
);
}
}

View File

@@ -0,0 +1,172 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import type { MessageRevision } from '../../../../shared-kernel';
import { ServerDirectoryFacade } from '../../../server-directory';
const STORAGE_KEY = 'metoyou.messageSigningKeyPair';
interface StoredSigningKeyPair {
publicKeyJwk: JsonWebKey;
privateKeyJwk: JsonWebKey;
}
@Injectable({ providedIn: 'root' })
export class MessageSigningService {
private readonly http = inject(HttpClient);
private readonly serverDirectory = inject(ServerDirectoryFacade);
private keyPairPromise: Promise<CryptoKeyPair> | null = null;
async ensureSigningKeyPair(): Promise<CryptoKeyPair> {
if (!this.keyPairPromise) {
this.keyPairPromise = this.loadOrCreateKeyPair();
}
return await this.keyPairPromise;
}
async getPublicKeyJwk(): Promise<JsonWebKey> {
const keyPair = await this.ensureSigningKeyPair();
return await crypto.subtle.exportKey('jwk', keyPair.publicKey);
}
async registerSigningPublicKeyIfNeeded(): Promise<void> {
const activeServer = this.serverDirectory.activeServer();
if (!activeServer) {
return;
}
const publicKeyJwk = await this.getPublicKeyJwk();
const apiBase = `${activeServer.url}/api`;
await firstValueFrom(this.http.put(`${apiBase}/users/me/signing-key`, { publicKeyJwk }));
}
async signRevision(revision: MessageRevision): Promise<string> {
const keyPair = await this.ensureSigningKeyPair();
const payload = this.canonicalRevisionPayload(revision);
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(payload));
const signature = await crypto.subtle.sign('Ed25519', keyPair.privateKey, digest);
return this.bufferToBase64Url(signature);
}
async fetchSigningPublicKey(userId: string): Promise<JsonWebKey | null> {
const activeServer = this.serverDirectory.activeServer();
if (!activeServer) {
return null;
}
try {
const response = await firstValueFrom(
this.http.get<{ publicKeyJwk: JsonWebKey }>(`${activeServer.url}/api/users/${encodeURIComponent(userId)}/signing-public-key`)
);
return response.publicKeyJwk;
} catch {
return null;
}
}
async verifyRevisionSignature(
revision: MessageRevision,
publicKeyJwk: JsonWebKey
): Promise<boolean> {
if (!revision.signature) {
return false;
}
try {
const publicKey = await crypto.subtle.importKey(
'jwk',
publicKeyJwk,
{ name: 'Ed25519' },
false,
['verify']
);
const payload = this.canonicalRevisionPayload(revision);
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(payload));
const signature = this.base64UrlToBuffer(revision.signature);
return await crypto.subtle.verify('Ed25519', publicKey, signature, digest);
} catch {
return false;
}
}
private async loadOrCreateKeyPair(): Promise<CryptoKeyPair> {
const stored = this.readStoredKeyPair();
if (stored) {
const [publicKey, privateKey] = await Promise.all([
crypto.subtle.importKey('jwk', stored.publicKeyJwk, { name: 'Ed25519' }, true, ['verify']),
crypto.subtle.importKey('jwk', stored.privateKeyJwk, { name: 'Ed25519' }, false, ['sign'])
]);
return { publicKey, privateKey };
}
const generated = await crypto.subtle.generateKey(
{ name: 'Ed25519' },
true,
['sign', 'verify']
);
const [publicKeyJwk, privateKeyJwk] = await Promise.all([
crypto.subtle.exportKey('jwk', generated.publicKey),
crypto.subtle.exportKey('jwk', generated.privateKey)
]);
this.writeStoredKeyPair({ publicKeyJwk, privateKeyJwk });
return generated;
}
private canonicalRevisionPayload(revision: MessageRevision): string {
const { signature: _signature, ...unsigned } = revision;
return JSON.stringify(unsigned, Object.keys(unsigned).sort());
}
private readStoredKeyPair(): StoredSigningKeyPair | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) {
return null;
}
return JSON.parse(raw) as StoredSigningKeyPair;
} catch {
return null;
}
}
private writeStoredKeyPair(pair: StoredSigningKeyPair): void {
localStorage.setItem(STORAGE_KEY, JSON.stringify(pair));
}
private bufferToBase64Url(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
return btoa(String.fromCharCode(...bytes))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
}
private base64UrlToBuffer(value: string): ArrayBuffer {
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4);
const binary = atob(padded);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return bytes.buffer;
}
}

View File

@@ -0,0 +1,53 @@
import {
describe,
it,
expect
} from 'vitest';
import type { User } from '../../../../shared-kernel';
import {
SESSION_EXPIRED_ERROR_CODE,
collectSessionTokenLookupUrls,
hasValidPersistedSession
} from './auth-session.rules';
describe('auth-session.rules', () => {
const user = {
homeSignalServerUrl: 'https://signal.example.com'
} as Pick<User, 'homeSignalServerUrl'>;
it('collects home and active server urls without duplicates', () => {
expect(collectSessionTokenLookupUrls(user, 'https://signal.example.com')).toEqual([
'https://signal.example.com'
]);
expect(collectSessionTokenLookupUrls(user, 'http://localhost:3001')).toEqual([
'http://localhost:3001',
'https://signal.example.com'
]);
});
it('requires a valid token for a known server url', () => {
const getToken = (url: string) => (url === 'https://signal.example.com' ? 'token-1' : null);
expect(hasValidPersistedSession(user, 'https://signal.example.com', getToken)).toBe(true);
expect(hasValidPersistedSession(user, 'http://localhost:3001', getToken)).toBe(true);
});
it('rejects persisted users without any valid session token', () => {
expect(hasValidPersistedSession(user, 'https://signal.example.com', () => null)).toBe(false);
});
it('falls back to any stored token when preferred urls are missing', () => {
expect(
hasValidPersistedSession(
{} as Pick<User, 'homeSignalServerUrl'>,
null,
() => null,
() => true
)
).toBe(true);
});
it('exports a stable session-expired error code', () => {
expect(SESSION_EXPIRED_ERROR_CODE).toBe('SESSION_EXPIRED');
});
});

View File

@@ -0,0 +1,46 @@
import type { User } from '../../../../shared-kernel';
export const SESSION_EXPIRED_ERROR_CODE = 'SESSION_EXPIRED';
function normalizeServerUrl(serverUrl: string): string {
return serverUrl.trim().replace(/\/+$/, '');
}
export function collectSessionTokenLookupUrls(
user: Pick<User, 'homeSignalServerUrl'>,
activeServerUrl?: string | null
): string[] {
const urls = new Set<string>();
if (activeServerUrl?.trim()) {
urls.add(normalizeServerUrl(activeServerUrl));
}
if (user.homeSignalServerUrl?.trim()) {
urls.add(normalizeServerUrl(user.homeSignalServerUrl));
}
return [...urls];
}
export function hasValidSessionTokenForUrls(
urls: readonly string[],
getToken: (serverUrl: string) => string | null
): boolean {
return urls.some((url) => !!getToken(url));
}
export function hasValidPersistedSession(
user: Pick<User, 'homeSignalServerUrl'>,
activeServerUrl: string | null | undefined,
getToken: (serverUrl: string) => string | null,
hasAnyValidToken?: () => boolean
): boolean {
const preferredUrls = collectSessionTokenLookupUrls(user, activeServerUrl);
if (preferredUrls.length > 0 && hasValidSessionTokenForUrls(preferredUrls, getToken)) {
return true;
}
return hasAnyValidToken?.() ?? false;
}

View File

@@ -8,4 +8,8 @@ export interface LoginResponse {
username: string;
/** Human-readable display name. */
displayName: string;
/** Opaque session token for authenticated API and WebSocket identify calls. */
token: string;
/** Unix timestamp (ms) when the session token expires. */
expiresAt: number;
}

View File

@@ -1,2 +1,3 @@
export * from './application/services/authentication.service';
export * from './application/services/auth-token-store.service';
export * from './domain/models/authentication.model';

View File

@@ -0,0 +1,22 @@
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { AuthTokenStoreService } from '../application/services/auth-token-store.service';
export const authTokenInterceptor: HttpInterceptorFn = (req, next) => {
if (!req.url.includes('/api/')) {
return next(req);
}
const authTokenStore = inject(AuthTokenStoreService);
const token = authTokenStore.findTokenForApiUrl(req.url);
if (!token || req.headers.has('Authorization')) {
return next(req);
}
return next(req.clone({
setHeaders: {
Authorization: `Bearer ${token}`
}
}));
};

View File

@@ -14,6 +14,8 @@ chat/
├── domain/
│ └── rules/
│ ├── message.rules.ts canEditMessage, normaliseDeletedMessage, getMessageTimestamp
│ ├── message-integrity.rules.ts headHash, inventory refresh, revision merge predicates
│ ├── message-revision.builder.rules.ts buildMessageRevision, materializeMessageFromRevision
│ ├── message-sync.rules.ts Inventory-based sync: chunkArray, findMissingIds, limits
│ └── auto-scroll.rules.ts resolveAutoScrollBehavior (instant on channel switch, smooth for live msgs) + isStuckToBottom predicate
@@ -95,6 +97,10 @@ sequenceDiagram
User->>DC: broadcastMessage(delete-message)
```
## Message integrity
Outgoing creates/edits/deletes also emit signed `message-revision` events and persist revision audit rows locally. Sync inventories include `revision` and `headHash`; merge prefers a verified higher revision over legacy timestamp comparison. See `agents-docs/features/message-integrity.md` and `MessageRevisionService`.
## Text channel scoping
`ChatMessagesComponent` renders only the active text channel selected in `store/rooms`. Legacy messages without an explicit `channelId` are treated as `general` for backward compatibility, while new sends and typing events attach the active `channelId` so one text channel does not leak state into the rest of the server. Voice channels live in the same server-owned channel list, but they do not participate in chat-message routing.

View File

@@ -0,0 +1,104 @@
import { Injectable, inject } from '@angular/core';
import {
type Message,
type MessageRevision,
type MessageRevisionType
} from '../../../../shared-kernel';
import { RealtimeSessionFacade } from '../../../../core/realtime';
import { DatabaseService } from '../../../../infrastructure/persistence';
import { MessageSigningService } from '../../../authentication/application/services/message-signing.service';
import { buildMessageRevision } from '../../domain/rules/message-revision.builder.rules';
import { getMessageRevision } from '../../domain/rules/message-integrity.rules';
import { attachRevisionSignatureIfPossible, shouldAcceptRevisionWithoutRegisteredKey } from '../../domain/rules/message-revision-signing.rules';
@Injectable({ providedIn: 'root' })
export class MessageRevisionService {
private readonly db = inject(DatabaseService);
private readonly signing = inject(MessageSigningService);
private readonly realtime = inject(RealtimeSessionFacade);
async createSignedRevision(input: {
message: Message;
type: MessageRevisionType;
actorId: string;
content?: string;
editedAt: number;
isDeleted?: boolean;
pluginId?: string;
sign?: boolean;
}): Promise<MessageRevision> {
const prevRevisionHash = input.type === 'create'
? ''
: (input.message.headHash ?? '');
let revision = await buildMessageRevision({
message: input.message,
type: input.type,
actorId: input.actorId,
content: input.content,
editedAt: input.editedAt,
isDeleted: input.isDeleted,
pluginId: input.pluginId,
prevRevisionHash
});
if (input.sign !== false) {
revision = await attachRevisionSignatureIfPossible(
revision,
(value) => this.signing.signRevision(value)
);
}
return revision;
}
async persistRevision(revision: MessageRevision): Promise<void> {
await this.db.saveMessageRevision(revision);
}
broadcastRevision(revision: MessageRevision): void {
this.realtime.broadcastMessage({
type: 'message-revision',
revision
});
}
async persistAndBroadcast(revision: MessageRevision): Promise<void> {
await this.persistRevision(revision);
this.broadcastRevision(revision);
}
async buildCreateRevision(message: Message, actorId: string): Promise<MessageRevision> {
return await this.createSignedRevision({
message: {
...message,
revision: 0
},
type: 'create',
actorId,
editedAt: message.timestamp
});
}
resolveNextRevision(message: Message): number {
return getMessageRevision(message) + 1;
}
async verifyRevision(revision: MessageRevision): Promise<boolean> {
if (!revision.signature) {
return true;
}
const publicKeyJwk = await this.signing.fetchSigningPublicKey(revision.actorId);
if (shouldAcceptRevisionWithoutRegisteredKey(revision, publicKeyJwk)) {
return true;
}
if (!publicKeyJwk) {
return false;
}
return await this.signing.verifyRevisionSignature(revision, publicKeyJwk);
}
}

View File

@@ -0,0 +1,94 @@
import {
describe,
it,
expect
} from 'vitest';
import type { Message } from '../../../../shared-kernel';
import {
buildMessageHeadState,
computeMessageHeadHash,
getMessageRevision,
inventoryNeedsRefresh,
resolveMessageRevision
} from './message-integrity.rules';
function createMessage(overrides: Partial<Message> = {}): Message {
return {
id: 'message-1',
roomId: 'room-1',
senderId: 'user-1',
senderName: 'User 1',
content: 'hello',
timestamp: 1_000,
reactions: [],
isDeleted: false,
...overrides
};
}
describe('message-integrity.rules', () => {
it('defaults missing revision to zero', () => {
expect(getMessageRevision(createMessage())).toBe(0);
expect(getMessageRevision(createMessage({ revision: 3 }))).toBe(3);
});
it('produces stable head hashes for the same canonical state', async () => {
const message = createMessage({ revision: 0 });
const state = buildMessageHeadState(message, 0);
const first = await computeMessageHeadHash(state);
const second = await computeMessageHeadHash(state);
expect(first).toBe(second);
expect(first).toMatch(/^[a-f0-9]{64}$/);
});
it('changes head hash when content or revision changes', async () => {
const original = await computeMessageHeadHash(buildMessageHeadState(createMessage({ revision: 0 }), 0));
const edited = await computeMessageHeadHash(buildMessageHeadState(createMessage({
content: 'edited',
editedAt: 2_000,
revision: 1
}), 1));
expect(edited).not.toBe(original);
});
it('requests refresh when revision is newer', () => {
expect(inventoryNeedsRefresh(
{ ts: 10, rc: 0, ac: 0, revision: 1, headHash: 'aaa' },
{ ts: 10, rc: 0, ac: 0, revision: 0, headHash: 'aaa' }
)).toBe(true);
});
it('requests refresh when revision matches but head hash differs', () => {
expect(inventoryNeedsRefresh(
{ ts: 10, rc: 0, ac: 0, revision: 1, headHash: 'tampered' },
{ ts: 10, rc: 0, ac: 0, revision: 1, headHash: 'canonical' }
)).toBe(true);
});
it('does not request refresh when revision and head hash match', () => {
expect(inventoryNeedsRefresh(
{ ts: 10, rc: 0, ac: 0, revision: 2, headHash: 'same' },
{ ts: 10, rc: 0, ac: 0, revision: 2, headHash: 'same' }
)).toBe(false);
});
it('falls back to legacy timestamp comparison when remote inventory lacks integrity fields', () => {
expect(inventoryNeedsRefresh(
{ ts: 20, rc: 0, ac: 0 },
{ ts: 10, rc: 0, ac: 0, revision: 0, headHash: 'hash' }
)).toBe(true);
expect(inventoryNeedsRefresh(
{ ts: 10, rc: 1, ac: 0 },
{ ts: 10, rc: 0, ac: 0, revision: 0, headHash: 'hash' }
)).toBe(true);
});
it('resolves next revision for create, edit, and delete flows', () => {
expect(resolveMessageRevision(undefined, 'create')).toBe(0);
expect(resolveMessageRevision(createMessage({ revision: 0 }), 'author-edit')).toBe(1);
expect(resolveMessageRevision(createMessage({ revision: 4 }), 'moderate-delete')).toBe(5);
});
});

View File

@@ -0,0 +1,136 @@
import type { Message } from '../../../../shared-kernel';
import type { MessageRevisionType } from '../../../../shared-kernel/message-revision.models';
import { getMessageTimestamp } from './message.rules';
export interface MessageHeadState {
messageId: string;
revision: number;
senderId: string;
content: string;
isDeleted: boolean;
editedAt: number;
channelId: string;
replyToId: string;
}
export interface InventoryIntegritySnapshot {
ts: number;
rc: number;
ac: number;
revision: number;
headHash: string;
}
export type RemoteInventoryItem = {
id: string;
ts: number;
rc?: number;
ac?: number;
revision?: number;
headHash?: string;
};
export type MessageRevisionAction = MessageRevisionType;
export function getMessageRevision(message: Pick<Message, 'revision'> | null | undefined): number {
return typeof message?.revision === 'number' && message.revision >= 0
? message.revision
: 0;
}
export function resolveMessageRevision(
existing: Pick<Message, 'revision'> | null | undefined,
_action: MessageRevisionAction
): number {
return getMessageRevision(existing ?? undefined) + (existing ? 1 : 0);
}
export function buildMessageHeadState(message: Message, revision = getMessageRevision(message)): MessageHeadState {
return {
messageId: message.id,
revision,
senderId: message.senderId,
content: message.isDeleted ? '' : message.content,
isDeleted: message.isDeleted,
editedAt: getMessageTimestamp(message),
channelId: message.channelId ?? 'general',
replyToId: message.replyToId ?? ''
};
}
export async function computeMessageHeadHash(state: MessageHeadState): Promise<string> {
const canonical = JSON.stringify(state, Object.keys(state).sort());
if (typeof globalThis.crypto?.subtle?.digest !== 'function') {
throw new Error('Web Crypto digest is unavailable for message integrity hashing');
}
const digest = await globalThis.crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(canonical)
);
return Array.from(new Uint8Array(digest))
.map((byte) => byte.toString(16).padStart(2, '0'))
.join('');
}
export async function computeMessageHeadHashFromMessage(
message: Message,
revision = getMessageRevision(message)
): Promise<string> {
return await computeMessageHeadHash(buildMessageHeadState(message, revision));
}
function hasIntegrityFields(item: RemoteInventoryItem): item is RemoteInventoryItem & {
revision: number;
headHash: string;
} {
return typeof item.revision === 'number'
&& typeof item.headHash === 'string'
&& item.headHash.length > 0;
}
export function inventoryNeedsRefresh(
remote: RemoteInventoryItem,
local: InventoryIntegritySnapshot
): boolean {
if (!hasIntegrityFields(remote)) {
return remote.ts > local.ts
|| (remote.rc !== undefined && remote.rc !== local.rc)
|| (remote.ac !== undefined && remote.ac !== local.ac);
}
if (remote.revision > local.revision) {
return true;
}
if (remote.revision < local.revision) {
return false;
}
if (remote.headHash !== local.headHash) {
return true;
}
return remote.ts > local.ts
|| (remote.rc !== undefined && remote.rc !== local.rc)
|| (remote.ac !== undefined && remote.ac !== local.ac);
}
export function shouldApplyIncomingRevision(
incomingRevision: number,
existingRevision: number,
incomingHeadHash: string,
existingHeadHash: string
): boolean {
if (incomingRevision > existingRevision) {
return true;
}
if (incomingRevision < existingRevision) {
return false;
}
return incomingHeadHash !== existingHeadHash;
}

View File

@@ -0,0 +1,48 @@
import {
describe,
it,
expect,
vi
} from 'vitest';
import type { MessageRevision } from '../../../../shared-kernel';
import {
attachRevisionSignatureIfPossible,
shouldAcceptRevisionWithoutRegisteredKey
} from './message-revision-signing.rules';
describe('message-revision-signing.rules', () => {
const revision: MessageRevision = {
messageId: 'msg-1',
revision: 0,
prevRevisionHash: '',
headHash: 'hash',
type: 'create',
actorId: 'user-1',
senderId: 'user-1',
roomId: 'room-1',
channelId: 'general',
senderName: 'User',
content: 'hello',
editedAt: 1,
isDeleted: false
};
it('keeps revisions unsigned when signing fails', async () => {
const signed = await attachRevisionSignatureIfPossible(
revision,
vi.fn(async () => {
throw new Error('Ed25519 unavailable');
})
);
expect(signed.signature).toBeUndefined();
});
it('accepts signed revisions while the sender key is still registering', () => {
expect(shouldAcceptRevisionWithoutRegisteredKey({
...revision,
signature: 'signature'
}, null)).toBe(true);
expect(shouldAcceptRevisionWithoutRegisteredKey(revision, null)).toBe(false);
});
});

View File

@@ -0,0 +1,24 @@
import type { MessageRevision } from '../../../../shared-kernel';
export async function attachRevisionSignatureIfPossible(
revision: MessageRevision,
signRevision: (value: MessageRevision) => Promise<string>
): Promise<MessageRevision> {
try {
const signature = await signRevision(revision);
return {
...revision,
signature
};
} catch {
return revision;
}
}
export function shouldAcceptRevisionWithoutRegisteredKey(
revision: MessageRevision,
publicKeyJwk: JsonWebKey | null
): boolean {
return !!revision.signature && !publicKeyJwk;
}

View File

@@ -0,0 +1,94 @@
import {
describe,
it,
expect
} from 'vitest';
import type { Message } from '../../../../shared-kernel';
import {
buildMessageRevision,
materializeMessageFromRevision,
revisionBeatsMessage
} from './message-revision.builder.rules';
function createMessage(overrides: Partial<Message> = {}): Message {
return {
id: 'message-1',
roomId: 'room-1',
senderId: 'user-1',
senderName: 'User 1',
content: 'hello',
timestamp: 1_000,
reactions: [],
isDeleted: false,
...overrides
};
}
describe('message-revision.builder.rules', () => {
it('builds create revisions at revision zero', async () => {
const message = createMessage();
const revision = await buildMessageRevision({
message,
type: 'create',
actorId: 'user-1',
editedAt: 1_000
});
expect(revision.revision).toBe(0);
expect(revision.headHash).toMatch(/^[a-f0-9]{64}$/);
expect(revision.type).toBe('create');
});
it('increments revision for author edits', async () => {
const message = createMessage({ revision: 0, headHash: 'abc' });
const revision = await buildMessageRevision({
message,
type: 'author-edit',
actorId: 'user-1',
content: 'edited',
editedAt: 2_000,
prevRevisionHash: 'abc'
});
expect(revision.revision).toBe(1);
expect(revision.prevRevisionHash).toBe('abc');
expect(revision.content).toBe('edited');
});
it('materializes message state from a revision', async () => {
const revision = await buildMessageRevision({
message: createMessage(),
type: 'author-edit',
actorId: 'user-1',
content: 'edited',
editedAt: 2_000
});
const materialized = materializeMessageFromRevision(createMessage(), revision);
expect(materialized.revision).toBe(1);
expect(materialized.content).toBe('edited');
expect(materialized.headHash).toBe(revision.headHash);
});
it('detects when a revision should replace local state', async () => {
const local = materializeMessageFromRevision(
createMessage({ revision: 0, headHash: 'old' }),
await buildMessageRevision({
message: createMessage(),
type: 'create',
actorId: 'user-1',
editedAt: 1_000
})
);
const incoming = await buildMessageRevision({
message: createMessage({ revision: 0, headHash: local.headHash }),
type: 'author-edit',
actorId: 'user-1',
content: 'edited',
editedAt: 2_000,
prevRevisionHash: local.headHash ?? ''
});
expect(revisionBeatsMessage(incoming, local)).toBe(true);
});
});

View File

@@ -0,0 +1,108 @@
import {
DELETED_MESSAGE_CONTENT,
type Message,
type MessageRevision,
type MessageRevisionType
} from '../../../../shared-kernel';
import {
buildMessageHeadState,
computeMessageHeadHash,
getMessageRevision,
resolveMessageRevision
} from './message-integrity.rules';
import { getMessageTimestamp } from './message.rules';
export interface BuildMessageRevisionInput {
message: Message;
type: MessageRevisionType;
actorId: string;
content?: string;
editedAt: number;
isDeleted?: boolean;
pluginId?: string;
prevRevisionHash?: string;
signature?: string;
}
export async function buildMessageRevision(input: BuildMessageRevisionInput): Promise<MessageRevision> {
const revision = input.type === 'create'
? 0
: resolveMessageRevision(input.message, input.type);
const isDeleted = input.isDeleted ?? input.type.includes('delete');
const content = isDeleted
? DELETED_MESSAGE_CONTENT
: (input.content ?? input.message.content);
const materializedMessage: Message = {
...input.message,
content,
editedAt: input.editedAt,
isDeleted,
revision
};
const headHash = await computeMessageHeadHash(buildMessageHeadState(materializedMessage, revision));
return {
messageId: input.message.id,
revision,
prevRevisionHash: input.prevRevisionHash ?? '',
headHash,
type: input.type,
actorId: input.actorId,
senderId: input.message.senderId,
roomId: input.message.roomId,
channelId: input.message.channelId,
senderName: input.message.senderName,
content,
editedAt: input.editedAt,
isDeleted,
replyToId: input.message.replyToId,
pluginId: input.pluginId,
signature: input.signature
};
}
export function materializeMessageFromRevision(
existing: Message | null,
revision: MessageRevision
): Message {
const base = existing ?? {
id: revision.messageId,
roomId: revision.roomId,
channelId: revision.channelId,
senderId: revision.senderId,
senderName: revision.senderName ?? 'Unknown',
content: revision.content ?? '',
timestamp: revision.editedAt,
reactions: [],
isDeleted: revision.isDeleted,
replyToId: revision.replyToId
};
return {
...base,
roomId: revision.roomId,
channelId: revision.channelId ?? base.channelId,
senderId: revision.senderId,
senderName: revision.senderName ?? base.senderName,
content: revision.isDeleted ? DELETED_MESSAGE_CONTENT : (revision.content ?? base.content),
editedAt: revision.editedAt,
revision: revision.revision,
headHash: revision.headHash,
isDeleted: revision.isDeleted,
replyToId: revision.replyToId ?? base.replyToId
};
}
export function revisionTimestamp(revision: MessageRevision): number {
return revision.editedAt;
}
export function revisionBeatsMessage(revision: MessageRevision, message: Message | null): boolean {
const existingRevision = getMessageRevision(message ?? undefined);
if (revision.revision !== existingRevision) {
return revision.revision > existingRevision;
}
return revision.headHash !== message?.headHash;
}

View File

@@ -0,0 +1,23 @@
import {
describe,
it,
expect
} from 'vitest';
import { findMissingIds } from './message-sync.rules';
describe('message-sync.rules', () => {
it('requests ids with newer revision or mismatched head hash', () => {
const localMap = new Map([
['m1', { ts: 10, rc: 0, ac: 0, revision: 1, headHash: 'aaa' }],
['m2', { ts: 10, rc: 0, ac: 0, revision: 2, headHash: 'bbb' }]
]);
const missing = findMissingIds([
{ id: 'm1', ts: 10, rc: 0, ac: 0, revision: 2, headHash: 'ccc' },
{ id: 'm2', ts: 10, rc: 0, ac: 0, revision: 2, headHash: 'bbb' },
{ id: 'm3', ts: 1, rc: 0, ac: 0, revision: 0, headHash: 'ddd' }
], localMap);
expect(missing).toEqual(['m1', 'm3']);
});
});

View File

@@ -1,3 +1,9 @@
import {
inventoryNeedsRefresh,
type InventoryIntegritySnapshot,
type RemoteInventoryItem
} from './message-integrity.rules';
/** Maximum number of messages to include in sync inventories.
*
* The inventory protocol now ships every message in the room (id, ts, rc, ac)
@@ -27,7 +33,9 @@ export interface InventoryItem {
id: string;
ts: number;
rc: number;
ac?: number;
ac: number;
revision: number;
headHash: string;
}
/** Splits an array into chunks of the given size. */
@@ -43,15 +51,15 @@ export function chunkArray<T>(items: T[], size: number): T[][] {
/** Identifies missing or stale message IDs by comparing remote items against a local map. */
export function findMissingIds(
remoteItems: readonly { id: string; ts: number; rc?: number; ac?: number }[],
localMap: ReadonlyMap<string, { ts: number; rc: number; ac: number }>
remoteItems: readonly RemoteInventoryItem[],
localMap: ReadonlyMap<string, InventoryIntegritySnapshot | InventoryItem>
): string[] {
const missing: string[] = [];
for (const item of remoteItems) {
const local = localMap.get(item.id);
if (!local || item.ts > local.ts || (item.rc !== undefined && item.rc !== local.rc) || (item.ac !== undefined && item.ac !== local.ac)) {
if (!local || inventoryNeedsRefresh(item, local)) {
missing.push(item.id);
}
}

View File

@@ -0,0 +1,42 @@
import {
describe,
it,
expect
} from 'vitest';
import remarkParse from 'remark-parse';
import { unified } from 'unified';
import { remarkStripDangerousContent } from './remark-sanitize.rules';
async function parseMarkdown(content: string) {
const processor = unified()
.use(remarkParse)
.use(remarkStripDangerousContent());
return processor.run(processor.parse(content));
}
describe('remarkStripDangerousContent', () => {
it('removes raw html nodes from markdown', async () => {
const tree = await parseMarkdown('Hello <script>alert(1)</script> world');
expect(JSON.stringify(tree)).not.toContain('script');
});
it('clears dangerous link protocols', async () => {
const tree = await parseMarkdown('[click](javascript:alert(1))');
expect(JSON.stringify(tree)).not.toContain('javascript:');
});
it('preserves safe custom emoji image data urls', async () => {
const tree = await parseMarkdown('![custom-emoji:party-id:party](data:image/webp;base64,abc)');
expect(JSON.stringify(tree)).toContain('data:image/webp;base64,abc');
});
it('clears dangerous non-image data urls', async () => {
const tree = await parseMarkdown('![x](data:text/html,<script>alert(1)</script>)');
expect(JSON.stringify(tree)).not.toContain('data:text/html');
});
});

View File

@@ -0,0 +1,46 @@
import type { Parent, Root } from 'mdast';
import { visit } from 'unist-util-visit';
const ALLOWED_DATA_IMAGE_MIME_PATTERN = /^data:image\/(?:webp|gif|jpe?g|png)(?:;|$)/i;
function isBlockedMarkdownUrl(url: string): boolean {
const trimmed = url.trim();
if (/^(?:javascript|vbscript):/i.test(trimmed)) {
return true;
}
if (/^data:/i.test(trimmed)) {
return !ALLOWED_DATA_IMAGE_MIME_PATTERN.test(trimmed);
}
return false;
}
export function remarkStripDangerousContent(): () => (tree: Root) => void {
return () => (tree: Root) => {
const htmlNodes: { parent: Parent; index: number }[] = [];
visit(tree, 'html', (_node, index, parent) => {
if (parent && typeof index === 'number') {
htmlNodes.push({ parent, index });
}
});
for (const entry of htmlNodes.sort((left, right) => right.index - left.index)) {
entry.parent.children.splice(entry.index, 1);
}
visit(tree, 'link', (node) => {
if (typeof node.url === 'string' && isBlockedMarkdownUrl(node.url)) {
node.url = '';
}
});
visit(tree, 'image', (node) => {
if (typeof node.url === 'string' && isBlockedMarkdownUrl(node.url)) {
node.url = '';
}
});
};
}

View File

@@ -11,6 +11,7 @@ import remarkBreaks from 'remark-breaks';
import remarkGfm from 'remark-gfm';
import remarkParse from 'remark-parse';
import { unified } from 'unified';
import { remarkStripDangerousContent } from '../../../../../domain/rules/remark-sanitize.rules';
import {
isSoundcloudUrl,
isSpotifyUrl,
@@ -53,7 +54,8 @@ const KLIPY_MEDIA_URL_PATTERN = /^(?:https?:)?\/\/(?:[^/]+\.)?klipy\.com/i;
const MERMAID_LINE_BREAK_PATTERN = /\r\n?/g;
const REMARK_PROCESSOR = unified().use(remarkParse)
.use(remarkGfm)
.use(remarkBreaks);
.use(remarkBreaks)
.use(remarkStripDangerousContent());
@Component({
selector: 'app-chat-message-markdown',

View File

@@ -24,6 +24,19 @@ Plugins can inspect the current interaction context through `api.context.getCurr
Plugins can add quick actions to the server sidebar's View plugins menu with `api.ui.registerToolbarAction(id, { icon, label, run })`. The menu is rendered from the room side-panel plugin area as an overlay grid, and callbacks receive a `toolbarAction` interaction context.
## Trust model
Plugins run in the product-client renderer with the same origin and capability surface as the host app. Process isolation (separate `BrowserView` or worker sandboxes) is intentionally out of scope because the runtime loads entrypoints through dynamic `import()`.
Remote plugin fetches are constrained as follows:
- Store and host installs require **HTTPS** entrypoints and bundle URLs; `file://` fetches from the renderer are blocked.
- When a source manifest entry or cached bundle declares `bundle.integrity` (SHA-256), `PluginHostService` verifies the digest before `import()`.
- Desktop local plugins and cached bundles continue to load from Electron-controlled paths under app data (`plugins/`, `plugin-bundles/`).
- Capability grants remain user-consented; integrity checks do not replace the existing capability model.
Treat third-party plugin code as trusted only after the user installs it and grants the declared capabilities.
Plugins can register `/` slash commands with `api.commands.register(id, { name, description, icon, options, scope, run })` (capability `ui.commands`). A command's `scope` is `global` (default — available in chat servers and direct messages) or `server` (only while a chat server is the active surface). The chat composer renders a Discord-style autocomplete menu when the user types `/`: results come from `PluginUiRegistryService.slashCommandRecords` filtered by surface via `selectAvailableSlashCommands` and by query via `filterSlashCommands` (both in `domain/logic/slash-command.rules.ts`). Picking a command (click, Enter, or Tab) either runs it immediately when it declares no options, or fills `/name ` so the user can type arguments before sending. On submit, `parseSlashCommandInput` + `findSlashCommand` resolve the command, `parseSlashCommandArguments` maps positional tokens (or a single `rest` option) to `args`, and `PluginClientApiService.createSlashCommandContext` builds a `slashCommand`-source context. Slash command input is intercepted in the composer and never sent as a chat message; unmatched `/text` falls through to a normal message. `api.commands.list()` returns every registered command across plugins.
Desktop plugin preferences that belong to the local user, including capability grants, disabled plugin ids, and previously activated plugin ids, are persisted through Electron's local database meta table with renderer localStorage as the browser fallback.

View File

@@ -30,6 +30,8 @@ import {
type PluginClientApiMethodPath
} from '../../domain/logic/plugin-client-api-surface.rules';
import { PluginCapabilityError, PluginCapabilityService } from './plugin-capability.service';
import { MessageRevisionService } from '../../../chat/application/services/message-revision.service';
import { MessageSigningService } from '../../../authentication/application/services/message-signing.service';
import { PluginClientApiService } from './plugin-client-api.service';
import { PluginDesktopStateService } from './plugin-desktop-state.service';
import { PluginLoggerService } from './plugin-logger.service';
@@ -106,17 +108,31 @@ describe('PluginClientApiService', () => {
}));
});
it('sends plugin messages and broadcasts them to peers', () => {
it('sends plugin messages and broadcasts them to peers', async () => {
const api = context.service.createApi(TEST_MANIFEST);
const message = api.messages.send('hello plugin');
expect(message.content).toBe('hello plugin');
expect(message.roomId).toBe('room-1');
expect(context.store.dispatch).toHaveBeenCalledWith(MessagesActions.sendMessageSuccess({ message }));
await new Promise((resolve) => setTimeout(resolve, 0));
expect(context.store.dispatch).toHaveBeenCalledWith(
MessagesActions.sendMessageSuccess({
message: expect.objectContaining({
content: 'hello plugin',
roomId: 'room-1'
})
})
);
expect(context.voice.broadcastMessage).toHaveBeenCalledWith(expect.objectContaining({
type: 'chat-message',
message
message: expect.objectContaining({
content: 'hello plugin',
roomId: 'room-1'
})
}));
expect(context.messageRevisions.broadcastRevision).toHaveBeenCalled();
});
it('publishes typing state through the realtime facade', () => {
@@ -264,6 +280,11 @@ interface ServiceTestContext {
setLocalStream: ReturnType<typeof vi.fn>;
setOutputVolume: ReturnType<typeof vi.fn>;
};
messageRevisions: {
broadcastRevision: ReturnType<typeof vi.fn>;
createSignedRevision: ReturnType<typeof vi.fn>;
persistRevision: ReturnType<typeof vi.fn>;
};
}
function createServiceTestContext(): ServiceTestContext {
@@ -305,6 +326,28 @@ function createServiceTestContext(): ServiceTestContext {
setLocalStream: vi.fn(async () => undefined),
setOutputVolume: vi.fn()
};
const messageRevisions = {
createSignedRevision: vi.fn(async (input: Parameters<MessageRevisionService['createSignedRevision']>[0]) => ({
messageId: input.message.id,
revision: input.type === 'create' ? 0 : (input.message.revision ?? 0) + 1,
prevRevisionHash: input.type === 'create' ? '' : (input.message.headHash ?? ''),
headHash: 'test-head-hash',
type: input.type,
actorId: input.actorId,
senderId: input.message.senderId,
roomId: input.message.roomId,
channelId: input.message.channelId,
senderName: input.message.senderName,
content: input.content ?? input.message.content,
editedAt: input.editedAt,
isDeleted: input.isDeleted ?? false,
replyToId: input.message.replyToId,
pluginId: input.pluginId,
signature: input.sign === false ? undefined : 'test-signature'
})),
persistRevision: vi.fn(async () => undefined),
broadcastRevision: vi.fn()
};
const realtime = {
onSignalingMessage: new Subject<unknown>(),
sendRawMessage: vi.fn()
@@ -357,11 +400,24 @@ function createServiceTestContext(): ServiceTestContext {
{
provide: DatabaseService,
useValue: {
getMessageById: vi.fn(async () => null),
saveMessage: vi.fn(async () => undefined),
updateMessage: vi.fn(async () => undefined),
updateRoom: vi.fn(async () => undefined)
}
},
{
provide: MessageRevisionService,
useValue: messageRevisions
},
{
provide: MessageSigningService,
useValue: {
signRevision: vi.fn(async () => 'test-signature'),
fetchSigningPublicKey: vi.fn(async () => null),
verifyRevisionSignature: vi.fn(async () => true)
}
},
{
provide: PluginDesktopStateService,
useValue: {
@@ -426,7 +482,8 @@ function createServiceTestContext(): ServiceTestContext {
storage,
store,
uiRegistry,
voice
voice,
messageRevisions
};
}

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

View File

@@ -24,6 +24,10 @@ import { PluginClientApiService } from './plugin-client-api.service';
import { PluginLoggerService } from './plugin-logger.service';
import { PluginRegistryService } from './plugin-registry.service';
import { PluginUiRegistryService } from './plugin-ui-registry.service';
import {
fileUrlToPath,
grantPluginReadRoots
} from '../../domain/rules/plugin-local-file.rules';
interface ActivePluginRuntime {
context: TojuPluginActivationContext;
@@ -369,43 +373,57 @@ export class PluginHostService {
const entrypointUrl = this.resolveEntrypoint(manifest, sourcePath);
if (entrypointUrl.startsWith('file://')) {
const moduleObjectUrl = await this.createLocalModuleObjectUrl(entrypointUrl);
const moduleObjectUrl = await this.createLocalModuleObjectUrl(entrypointUrl, sourcePath);
const module = await import(/* @vite-ignore */ moduleObjectUrl) as TojuClientPluginModule;
return { module, moduleObjectUrl };
}
if (!entrypointUrl.startsWith('file://') && !entrypointUrl.startsWith('https://')) {
throw new Error('Remote plugin entrypoints must use HTTPS');
}
try {
return {
module: await import(/* @vite-ignore */ entrypointUrl) as TojuClientPluginModule
};
} catch (error) {
if (!entrypointUrl.startsWith('http://') && !entrypointUrl.startsWith('https://')) {
if (!entrypointUrl.startsWith('https://')) {
throw error;
}
const moduleObjectUrl = await this.createRemoteModuleObjectUrl(entrypointUrl);
const moduleObjectUrl = await this.createRemoteModuleObjectUrl(entrypointUrl, manifest);
const module = await import(/* @vite-ignore */ moduleObjectUrl) as TojuClientPluginModule;
return { module, moduleObjectUrl };
}
}
private async createLocalModuleObjectUrl(entrypointUrl: string): Promise<string> {
private async createLocalModuleObjectUrl(entrypointUrl: string, sourcePath?: string): Promise<string> {
const api = this.electronBridge?.getApi();
if (!api) {
throw new Error('Local plugin entrypoints require the desktop app');
}
await grantPluginReadRoots(api, sourcePath, entrypointUrl);
const base64Data = await api.readFile(fileUrlToPath(entrypointUrl));
if (!base64Data) {
throw new Error('Plugin entrypoint is not readable from app data');
}
const bytes = Uint8Array.from(atob(base64Data), (character) => character.charCodeAt(0));
const source = new TextDecoder().decode(bytes);
return URL.createObjectURL(new Blob([source], { type: 'text/javascript' }));
}
private async createRemoteModuleObjectUrl(entrypointUrl: string): Promise<string> {
private async createRemoteModuleObjectUrl(entrypointUrl: string, manifest: TojuPluginManifest): Promise<string> {
if (!entrypointUrl.startsWith('https://')) {
throw new Error('Remote plugin entrypoints must use HTTPS');
}
const response = await fetch(entrypointUrl, { headers: { Accept: 'text/javascript,*/*' } });
if (!response.ok) {
@@ -413,10 +431,26 @@ export class PluginHostService {
}
const source = await response.text();
const expectedIntegrity = manifest.bundle?.integrity?.trim();
if (expectedIntegrity) {
const actualDigest = await this.sha256Hex(source);
if (actualDigest !== expectedIntegrity.replace(/^sha256-/i, '').toLowerCase()) {
throw new Error('Plugin entrypoint integrity check failed');
}
}
return URL.createObjectURL(new Blob([`${source}\n//# sourceURL=${entrypointUrl}`], { type: 'text/javascript' }));
}
private async sha256Hex(source: string): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(source));
const bytes = new Uint8Array(digest);
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
}
private revokeModuleObjectUrl(pluginId: string): void {
const moduleObjectUrl = this.activePlugins.get(pluginId)?.moduleObjectUrl;
@@ -478,17 +512,6 @@ export class PluginHostService {
}
}
function fileUrlToPath(fileUrl: string): string {
const url = new URL(fileUrl);
const decodedPath = decodeURIComponent(url.pathname);
if (/^\/[A-Za-z]:\//.test(decodedPath)) {
return decodedPath.slice(1).replace(/\//g, '\\');
}
return decodedPath;
}
function safeDispose(disposable: TojuPluginDisposable, pluginId: string, logger: PluginLoggerService): void {
try {
disposable.dispose();

View File

@@ -10,7 +10,6 @@ import type {
} from '../../../../shared-kernel';
export interface UpsertPluginRequirementRequest {
actorUserId: string;
installUrl?: string;
manifest?: TojuPluginManifest;
reason?: string;
@@ -20,7 +19,6 @@ export interface UpsertPluginRequirementRequest {
}
export interface UpsertPluginEventDefinitionRequest {
actorUserId: string;
direction: 'clientToServer' | 'serverRelay' | 'p2pHint';
maxPayloadBytes?: number;
rateLimitJson?: string;
@@ -48,10 +46,9 @@ export class PluginRequirementService {
);
}
deleteRequirement(apiBaseUrl: string, serverId: string, pluginId: string, actorUserId: string): Observable<{ ok: boolean }> {
deleteRequirement(apiBaseUrl: string, serverId: string, pluginId: string): Observable<{ ok: boolean }> {
return this.http.delete<{ ok: boolean }>(
`${this.apiBase(apiBaseUrl)}/servers/${encodeURIComponent(serverId)}/plugins/${encodeURIComponent(pluginId)}/requirement`,
{ body: { actorUserId } }
`${this.apiBase(apiBaseUrl)}/servers/${encodeURIComponent(serverId)}/plugins/${encodeURIComponent(pluginId)}/requirement`
);
}

View File

@@ -63,6 +63,7 @@ describe('PluginStoreService', () => {
const service = createService(registerLocalManifest, unregister);
await new Promise((resolve) => setTimeout(resolve, 0));
await service.addSourceUrl('https://plugins.example.test/index.json#latest');
expect(service.sourceUrls()).toEqual([OFFICIAL_PLUGIN_SOURCE_URL, 'https://plugins.example.test/index.json']);
@@ -81,9 +82,11 @@ describe('PluginStoreService', () => {
}));
});
it('seeds the official plugin repository for new users', () => {
it('seeds the official plugin repository for new users', async () => {
const service = createService(registerLocalManifest, unregister);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(service.sourceUrls()).toEqual([OFFICIAL_PLUGIN_SOURCE_URL]);
expect(fetchMock).toHaveBeenCalledWith(
OFFICIAL_PLUGIN_SOURCE_URL,
@@ -132,12 +135,15 @@ describe('PluginStoreService', () => {
],
title: 'Local Plugins'
};
const grantPluginReadRoot = vi.fn(async () => true);
const readFile = vi.fn(async () => toBase64(JSON.stringify(localSourceManifest)));
const service = createService(registerLocalManifest, unregister, { readFile });
const service = createService(registerLocalManifest, unregister, { grantPluginReadRoot, readFile });
await new Promise((resolve) => setTimeout(resolve, 0));
await service.addSourceUrl('/home/ludde/Desktop/TestPlugin/plugin-source.json');
expect(fetchMock).not.toHaveBeenCalledWith('/home/ludde/Desktop/TestPlugin/plugin-source.json', expect.anything());
expect(grantPluginReadRoot).toHaveBeenCalledWith('/home/ludde/Desktop/TestPlugin');
expect(readFile).toHaveBeenCalledWith('/home/ludde/Desktop/TestPlugin/plugin-source.json');
expect(service.sourceUrls()).toEqual([OFFICIAL_PLUGIN_SOURCE_URL, 'file:///home/ludde/Desktop/TestPlugin/plugin-source.json']);
@@ -255,7 +261,8 @@ function createService(
electronApi: {
ensureDir?: (dirPath: string) => Promise<boolean>;
getAppDataPath?: () => Promise<string>;
readFile?: (filePath: string) => Promise<string>;
grantPluginReadRoot?: (rootPath: string) => Promise<boolean>;
readFile?: (filePath: string) => Promise<string | null>;
writeFile?: (filePath: string, data: string) => Promise<boolean>;
} | null = null
): PluginStoreService {

View File

@@ -37,7 +37,6 @@ import type {
PersistedPluginStoreState,
PluginStoreEntry,
PluginStoreInstallState,
PluginStoreActionLabel,
PluginStoreReadme,
PluginStoreSourceResult
} from '../../domain/models/plugin-store.models';
@@ -46,6 +45,10 @@ import { PluginCapabilityService } from './plugin-capability.service';
import { PluginDesktopStateService } from './plugin-desktop-state.service';
import { PluginRequirementService } from './plugin-requirement.service';
import { PluginRegistryService } from './plugin-registry.service';
import {
fileUrlToPath,
grantPluginReadRoots
} from '../../domain/rules/plugin-local-file.rules';
const STORE_SCHEMA_VERSION = 2;
const STORAGE_KEY_PLUGIN_STORE = 'metoyou_plugin_store';
@@ -138,7 +141,7 @@ export class PluginStoreService {
void this.applyInstalledPlugins(state.installedPlugins, 'client');
if (state.sourceUrls.length > 0) {
void this.refreshSources();
void this.bootstrapSourceRefresh(state.sourceUrls);
}
if (this.currentRoomId && this.currentUser && this.serverDirectory) {
@@ -169,6 +172,7 @@ export class PluginStoreService {
}
this.sourceUrlsSignal.update((sourceUrls) => [...sourceUrls, sourceUrl]);
await this.ensurePluginSourceReadRoot(sourceUrl);
this.saveState();
await this.refreshSources();
}
@@ -190,6 +194,7 @@ export class PluginStoreService {
this.loadingSignal.set(true);
try {
await this.ensurePluginSourceReadRoots(this.sourceUrls());
const sources = await Promise.all(this.sourceUrls().map((sourceUrl) => this.loadSource(sourceUrl, abortController.signal)));
if (this.refreshVersion === currentRefresh) {
@@ -292,7 +297,13 @@ export class PluginStoreService {
return;
}
this.host.registerLocalManifest(installedPlugin.manifest, installedPlugin.cachedSourcePath ?? installedPlugin.installUrl);
const sourcePath = installedPlugin.cachedSourcePath ?? installedPlugin.installUrl;
if (sourcePath?.startsWith('file://')) {
await this.ensurePluginSourceReadRoot(sourcePath);
}
this.host.registerLocalManifest(installedPlugin.manifest, sourcePath);
this.setInstalledPluginsForScope(installScope, nextInstalledPlugins);
@@ -503,6 +514,10 @@ export class PluginStoreService {
return await this.readLocalFileUrl(url);
}
if (!url.startsWith('https://')) {
throw new Error('Remote plugin store requests must use HTTPS');
}
const response = await fetch(url, { headers: { Accept: accept }, signal });
if (!response.ok) {
@@ -512,6 +527,28 @@ export class PluginStoreService {
return await response.text();
}
private async bootstrapSourceRefresh(sourceUrls: readonly string[]): Promise<void> {
await this.ensurePluginSourceReadRoots(sourceUrls);
if (this.stateMutated) {
return;
}
await this.refreshSources();
}
private async ensurePluginSourceReadRoots(sourceUrls: readonly string[]): Promise<void> {
await Promise.all(sourceUrls.map((sourceUrl) => this.ensurePluginSourceReadRoot(sourceUrl)));
}
private async ensurePluginSourceReadRoot(sourceUrl: string): Promise<void> {
if (!sourceUrl.startsWith('file://')) {
return;
}
await grantPluginReadRoots(this.electronBridge.getApi(), sourceUrl);
}
private async readLocalFileUrl(fileUrl: string): Promise<string> {
const api = this.electronBridge.getApi();
@@ -519,7 +556,13 @@ export class PluginStoreService {
throw new Error('Local plugin source paths require the desktop app');
}
await this.ensurePluginSourceReadRoot(fileUrl);
const base64Data = await api.readFile(fileUrlToPath(fileUrl));
if (!base64Data) {
throw new Error(`Local plugin source is not readable: ${fileUrlToPath(fileUrl)}`);
}
const bytes = Uint8Array.from(atob(base64Data), (character) => character.charCodeAt(0));
return new TextDecoder().decode(bytes);
@@ -874,7 +917,6 @@ export class PluginStoreService {
roomId,
installedPlugin.manifest.id,
{
actorUserId,
installUrl: installedPlugin.installUrl,
manifest: installedPlugin.manifest,
reason: installedPlugin.manifest.description,
@@ -893,7 +935,7 @@ export class PluginStoreService {
throw new Error('Open a chat server before removing server-scoped plugins');
}
await firstValueFrom(this.pluginRequirements.deleteRequirement(this.getPluginApiBaseUrl(roomId), roomId, pluginId, actorUserId));
await firstValueFrom(this.pluginRequirements.deleteRequirement(this.getPluginApiBaseUrl(roomId), roomId, pluginId));
}
private getPluginApiBaseUrl(serverId: string): string {
@@ -994,7 +1036,7 @@ export class PluginStoreService {
if (sourceUrlsChanged) {
this.sourceUrlsSignal.set(normalized.sourceUrls);
void this.refreshSources();
void this.bootstrapSourceRefresh(normalized.sourceUrls);
}
await this.applyInstalledPlugins(normalized.installedPlugins, 'client');
@@ -1400,17 +1442,6 @@ function localPathToFileUrl(filePath: string): string | undefined {
.join('/')}`;
}
function fileUrlToPath(fileUrl: string): string {
const url = new URL(fileUrl);
const decodedPath = decodeURIComponent(url.pathname);
if (/^\/[A-Za-z]:\//.test(decodedPath)) {
return decodedPath.slice(1).replace(/\//g, '\\');
}
return decodedPath;
}
function isAbsoluteLocalPath(filePath: string): boolean {
return filePath.startsWith('/') || /^[A-Za-z]:[\\/]/.test(filePath);
}

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import {
collectPluginReadRoots,
fileUrlToPath,
pluginFileParentDir
} from './plugin-local-file.rules';
describe('plugin-local-file.rules', () => {
it('resolves linux file URLs to absolute paths', () => {
expect(fileUrlToPath('file:///home/ludde/Desktop/TestPlugin/plugin-source.json'))
.toBe('/home/ludde/Desktop/TestPlugin/plugin-source.json');
});
it('collects plugin read roots from source and entrypoint URLs', () => {
expect(collectPluginReadRoots(
'file:///home/ludde/Desktop/TestPlugin/plugin-source.json',
'file:///home/ludde/Desktop/TestPlugin/dist/main.js'
)).toEqual([
'/home/ludde/Desktop/TestPlugin',
'/home/ludde/Desktop/TestPlugin/dist'
]);
});
it('treats directory file URLs as their own read roots', () => {
expect(collectPluginReadRoots('file:///home/ludde/Desktop/TestPlugin/')).toEqual([
'/home/ludde/Desktop/TestPlugin'
]);
expect(collectPluginReadRoots('file:///home/ludde/Desktop/TestPlugin')).toEqual([
'/home/ludde/Desktop/TestPlugin'
]);
});
});

View File

@@ -0,0 +1,59 @@
import type { ElectronApi } from '../../../../core/platform/electron/electron-api.models';
export function pluginFileParentDir(filePath: string): string {
const normalized = filePath.replace(/\\/g, '/').replace(/\/+$/, '');
const index = normalized.lastIndexOf('/');
return index > 0 ? normalized.slice(0, index) : normalized;
}
export function pluginReadRootForFileUrl(fileUrl: string): string {
const filePath = fileUrlToPath(fileUrl).replace(/\\/g, '/').replace(/\/+$/, '');
const basename = filePath.split('/').pop() ?? '';
if (fileUrl.endsWith('/') || !basename.includes('.')) {
return filePath;
}
return pluginFileParentDir(filePath);
}
export function collectPluginReadRoots(...fileUrls: Array<string | undefined>): string[] {
const roots = new Set<string>();
for (const fileUrl of fileUrls) {
if (!fileUrl?.startsWith('file://')) {
continue;
}
roots.add(pluginReadRootForFileUrl(fileUrl));
}
return [...roots];
}
export async function grantPluginReadRoots(
api: Pick<ElectronApi, 'grantPluginReadRoot'> | null | undefined,
...fileUrls: Array<string | undefined>
): Promise<void> {
if (!api?.grantPluginReadRoot) {
return;
}
const roots = collectPluginReadRoots(...fileUrls);
for (const root of roots) {
await api.grantPluginReadRoot(root);
}
}
export function fileUrlToPath(fileUrl: string): string {
const url = new URL(fileUrl);
const decodedPath = decodeURIComponent(url.pathname);
if (/^\/[A-Za-z]:\//.test(decodedPath)) {
return decodedPath.slice(1).replace(/\//g, '\\');
}
return decodedPath;
}

View File

@@ -266,7 +266,6 @@ export class ServerDirectoryService {
updateServer(
serverId: string,
updates: Partial<ServerInfo> & {
currentOwnerId: string;
actingRole?: string;
password?: string | null;
},

View File

@@ -95,7 +95,6 @@ export interface ServerJoinAccessResponse {
}
export interface CreateServerInviteRequest {
requesterUserId: string;
requesterDisplayName?: string;
requesterRole?: string;
}
@@ -116,8 +115,6 @@ export interface ServerInviteInfo {
}
export interface KickServerMemberRequest {
actorUserId: string;
actorRole?: string;
targetUserId: string;
}
@@ -129,8 +126,6 @@ export interface BanServerMemberRequest extends KickServerMemberRequest {
}
export interface UnbanServerMemberRequest {
actorUserId: string;
actorRole?: string;
banId?: string;
targetUserId?: string;
}

View File

@@ -184,7 +184,6 @@ export class ServerDirectoryApiService {
updateServer(
serverId: string,
updates: Partial<ServerInfo> & {
currentOwnerId: string;
actingRole?: string;
password?: string | null;
},