feat: Security
This commit is contained in:
@@ -27,7 +27,8 @@
|
||||
},
|
||||
"users": {
|
||||
"prepareStateFailed": "Failed to prepare local user state.",
|
||||
"noCurrentUser": "No current user"
|
||||
"noCurrentUser": "No current user",
|
||||
"sessionExpired": "Your session expired. Please sign in again."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,8 @@
|
||||
},
|
||||
"users": {
|
||||
"prepareStateFailed": "Failed to prepare local user state.",
|
||||
"noCurrentUser": "No current user"
|
||||
"noCurrentUser": "No current user",
|
||||
"sessionExpired": "Your session expired. Please sign in again."
|
||||
}
|
||||
},
|
||||
"call": {
|
||||
|
||||
@@ -4,7 +4,8 @@ import {
|
||||
isDevMode
|
||||
} from '@angular/core';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||
import { authTokenInterceptor } from './domains/authentication/infrastructure/auth-token.interceptor';
|
||||
import { provideTranslateService } from '@ngx-translate/core';
|
||||
import { provideStore } from '@ngrx/store';
|
||||
import { provideEffects } from '@ngrx/effects';
|
||||
@@ -32,7 +33,7 @@ export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
provideRouter(routes),
|
||||
provideHttpClient(),
|
||||
provideHttpClient(withInterceptors([authTokenInterceptor])),
|
||||
provideTranslateService({
|
||||
fallbackLang: DEFAULT_APP_LOCALE,
|
||||
lang: DEFAULT_APP_LOCALE
|
||||
|
||||
@@ -294,9 +294,10 @@ export interface ElectronApi {
|
||||
relaunchApp: () => Promise<boolean>;
|
||||
onDeepLinkReceived: (listener: (url: string) => void) => () => void;
|
||||
readClipboardFiles: () => Promise<ClipboardFilePayload[]>;
|
||||
readFile: (filePath: string) => Promise<string>;
|
||||
readFileChunk: (filePath: string, start: number, end: number) => Promise<string>;
|
||||
getFileSize: (filePath: string) => Promise<number>;
|
||||
readFile: (filePath: string) => Promise<string | null>;
|
||||
readFileChunk: (filePath: string, start: number, end: number) => Promise<string | null>;
|
||||
getFileSize: (filePath: string) => Promise<number | null>;
|
||||
grantPluginReadRoot?: (rootPath: string) => Promise<boolean>;
|
||||
writeFile: (filePath: string, data: string) => Promise<boolean>;
|
||||
appendFile: (filePath: string, data: string) => Promise<boolean>;
|
||||
saveFileAs: (defaultFileName: string, data: string) => Promise<{ saved: boolean; cancelled: boolean }>;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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(/\/+$/, '');
|
||||
}
|
||||
}
|
||||
@@ -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(() => {});
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './application/services/authentication.service';
|
||||
export * from './application/services/auth-token-store.service';
|
||||
export * from './domain/models/authentication.model';
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
}));
|
||||
};
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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('');
|
||||
|
||||
expect(JSON.stringify(tree)).toContain('data:image/webp;base64,abc');
|
||||
});
|
||||
|
||||
it('clears dangerous non-image data urls', async () => {
|
||||
const tree = await parseMarkdown('</script>)');
|
||||
|
||||
expect(JSON.stringify(tree)).not.toContain('data:text/html');
|
||||
});
|
||||
});
|
||||
@@ -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 = '';
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -266,7 +266,6 @@ export class ServerDirectoryService {
|
||||
updateServer(
|
||||
serverId: string,
|
||||
updates: Partial<ServerInfo> & {
|
||||
currentOwnerId: string;
|
||||
actingRole?: string;
|
||||
password?: string | null;
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -184,7 +184,6 @@ export class ServerDirectoryApiService {
|
||||
updateServer(
|
||||
serverId: string,
|
||||
updates: Partial<ServerInfo> & {
|
||||
currentOwnerId: string;
|
||||
actingRole?: string;
|
||||
password?: string | null;
|
||||
},
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-semibold text-foreground">{{ 'dashboard.openInvite' | translate }}</p>
|
||||
<p class="truncate text-xs text-muted-foreground">{{ invite }}</p>
|
||||
<p class="truncate text-xs text-muted-foreground">{{ invite.inviteId }}</p>
|
||||
</div>
|
||||
<ng-icon
|
||||
name="lucideArrowRight"
|
||||
|
||||
@@ -154,7 +154,7 @@ describe('DashboardComponent', () => {
|
||||
|
||||
component.onSearchChange('https://app.test/invite/Code_42');
|
||||
|
||||
expect(component.inviteResult()).toBe('Code_42');
|
||||
expect(component.inviteResult()).toEqual({ inviteId: 'Code_42' });
|
||||
});
|
||||
|
||||
it('opens a joined server in place and routes others to the servers page', () => {
|
||||
@@ -174,7 +174,18 @@ describe('DashboardComponent', () => {
|
||||
component.onSearchChange('abc123');
|
||||
component.openInvite();
|
||||
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/invite', 'abc123']);
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/invite', 'abc123'], { queryParams: undefined });
|
||||
});
|
||||
|
||||
it('forwards the signal server when opening a full invite URL', () => {
|
||||
const { component, router } = createHarness();
|
||||
|
||||
component.onSearchChange('https://web.toju.app/invite/Code_42?server=https%3A%2F%2Fsignal.toju.app');
|
||||
component.openInvite();
|
||||
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/invite', 'Code_42'], {
|
||||
queryParams: { server: 'https://signal.toju.app' }
|
||||
});
|
||||
});
|
||||
|
||||
it('suggests people you might know independent of the query, excluding self', () => {
|
||||
|
||||
@@ -260,9 +260,13 @@ export class DashboardComponent implements OnInit {
|
||||
openInvite(): void {
|
||||
const invite = this.inviteResult();
|
||||
|
||||
if (invite) {
|
||||
this.router.navigate(['/invite', invite]);
|
||||
if (!invite) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.router.navigate(['/invite', invite.inviteId], {
|
||||
queryParams: invite.sourceUrl ? { server: invite.sourceUrl } : undefined
|
||||
});
|
||||
}
|
||||
|
||||
serverInitial(server: ServerInfo): string {
|
||||
|
||||
@@ -16,16 +16,37 @@ describe('parseInviteQuery', () => {
|
||||
expect(parseInviteQuery('hello world')).toBeNull();
|
||||
});
|
||||
|
||||
it('treats a bare url-safe code as an invite', () => {
|
||||
expect(parseInviteQuery('abc123')).toBe('abc123');
|
||||
expect(parseInviteQuery('Team-Code_9')).toBe('Team-Code_9');
|
||||
it('treats a bare url-safe code as an invite without server context', () => {
|
||||
expect(parseInviteQuery('abc123')).toEqual({ inviteId: 'abc123' });
|
||||
expect(parseInviteQuery('Team-Code_9')).toEqual({ inviteId: 'Team-Code_9' });
|
||||
});
|
||||
|
||||
it('extracts the id from an invite path', () => {
|
||||
expect(parseInviteQuery('/invite/xyz789')).toBe('xyz789');
|
||||
expect(parseInviteQuery('/invite/xyz789')).toEqual({ inviteId: 'xyz789' });
|
||||
});
|
||||
|
||||
it('extracts the id from a full invite URL', () => {
|
||||
expect(parseInviteQuery('https://app.test/invite/Code_42?ref=1')).toBe('Code_42');
|
||||
it('extracts the id and server from a browser invite URL', () => {
|
||||
expect(parseInviteQuery('https://web.toju.app/invite/Code_42?server=https%3A%2F%2Fsignal.toju.app')).toEqual({
|
||||
inviteId: 'Code_42',
|
||||
sourceUrl: 'https://signal.toju.app'
|
||||
});
|
||||
});
|
||||
|
||||
it('derives the signal server from a signal-origin invite URL', () => {
|
||||
expect(parseInviteQuery('https://localhost:3001/invite/abc123')).toEqual({
|
||||
inviteId: 'abc123',
|
||||
sourceUrl: 'https://localhost:3001'
|
||||
});
|
||||
});
|
||||
|
||||
it('does not treat a web-app invite URL without server param as complete', () => {
|
||||
expect(parseInviteQuery('https://app.test/invite/Code_42?ref=1')).toEqual({ inviteId: 'Code_42' });
|
||||
});
|
||||
|
||||
it('parses toju protocol invite links', () => {
|
||||
expect(parseInviteQuery('toju://invite/DeepLink_1?server=https%3A%2F%2Fsignal.toju.app')).toEqual({
|
||||
inviteId: 'DeepLink_1',
|
||||
sourceUrl: 'https://signal.toju.app'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,29 +1,96 @@
|
||||
export interface ParsedInviteQuery {
|
||||
inviteId: string;
|
||||
sourceUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a dashboard search query into an invite identifier when it looks like an
|
||||
* invite code or an invite URL. Returns `null` when the query is not invite-like.
|
||||
* Parses a dashboard search query into invite context when it looks like an invite
|
||||
* code or URL. Returns `null` when the query is not invite-like.
|
||||
*
|
||||
* Accepted shapes:
|
||||
* - A bare code: `abc123`, `Team-Code_9` (6+ url-safe chars, no whitespace)
|
||||
* - A path containing `/invite/<id>`
|
||||
* - A full URL whose path contains `/invite/<id>`
|
||||
* - A path or URL containing `/invite/<id>`
|
||||
* - Browser invite URLs with `?server=<signal-origin>`
|
||||
* - Signal-server invite URLs where the origin is the signal server
|
||||
* - `toju://invite/<id>?server=<signal-origin>`
|
||||
*/
|
||||
export function parseInviteQuery(rawQuery: string): string | null {
|
||||
export function parseInviteQuery(rawQuery: string): ParsedInviteQuery | null {
|
||||
const query = rawQuery.trim();
|
||||
|
||||
if (query.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (query.startsWith('toju:')) {
|
||||
const protocolInvite = parseTojuInviteUrl(query);
|
||||
|
||||
if (protocolInvite) {
|
||||
return protocolInvite;
|
||||
}
|
||||
}
|
||||
|
||||
const invitePathMatch = /\/invite\/([A-Za-z0-9_-]+)/.exec(query);
|
||||
|
||||
if (invitePathMatch) {
|
||||
return invitePathMatch[1];
|
||||
return {
|
||||
inviteId: invitePathMatch[1],
|
||||
sourceUrl: deriveSourceUrlFromInviteReference(query)
|
||||
};
|
||||
}
|
||||
|
||||
// A bare invite code: url-safe characters only, no whitespace, reasonably long.
|
||||
if (/^[A-Za-z0-9_-]{6,}$/.test(query)) {
|
||||
return query;
|
||||
return { inviteId: query };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseTojuInviteUrl(url: string): ParsedInviteQuery | null {
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
const pathSegments = [parsedUrl.hostname, ...parsedUrl.pathname.split('/').filter(Boolean)]
|
||||
.map((segment) => decodeURIComponent(segment));
|
||||
|
||||
if (pathSegments[0] !== 'invite' || !pathSegments[1]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
inviteId: pathSegments[1],
|
||||
sourceUrl: parsedUrl.searchParams.get('server')?.trim() || undefined
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function deriveSourceUrlFromInviteReference(reference: string): string | undefined {
|
||||
try {
|
||||
const normalizedReference = reference.includes('://')
|
||||
? reference
|
||||
: `https://placeholder.test${reference.startsWith('/') ? reference : `/${reference}`}`;
|
||||
const parsed = new URL(normalizedReference);
|
||||
const serverParam = parsed.searchParams.get('server')?.trim();
|
||||
|
||||
if (serverParam) {
|
||||
return serverParam;
|
||||
}
|
||||
|
||||
if (parsed.hostname === 'placeholder.test') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (isLikelySignalServerOrigin(parsed)) {
|
||||
return `${parsed.protocol}//${parsed.host}`;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function isLikelySignalServerOrigin(url: URL): boolean {
|
||||
return url.hostname.startsWith('signal.') || url.port === '3001';
|
||||
}
|
||||
|
||||
|
||||
@@ -284,14 +284,13 @@ export class TitleBarComponent {
|
||||
const invite = await firstValueFrom(this.serverDirectory.createInvite(
|
||||
room.id,
|
||||
{
|
||||
requesterUserId: user.id,
|
||||
requesterDisplayName: user.displayName,
|
||||
requesterRole: user.role
|
||||
},
|
||||
this.toSourceSelector(room)
|
||||
));
|
||||
|
||||
await this.copyInviteLink(invite.inviteUrl);
|
||||
await this.copyInviteLink(invite.browserUrl);
|
||||
this.inviteStatus.set(this.appI18n.instant('shell.titleBar.inviteCopied'));
|
||||
} catch (error: unknown) {
|
||||
const inviteError = error as { error?: { error?: string } };
|
||||
|
||||
@@ -23,6 +23,8 @@ export interface MessageRow {
|
||||
linkMetadata?: string | null;
|
||||
kind?: string | null;
|
||||
systemEvent?: string | null;
|
||||
revision?: number | null;
|
||||
headHash?: string | null;
|
||||
}
|
||||
|
||||
export interface UserRow {
|
||||
@@ -102,7 +104,9 @@ export function messageToRow(message: Message): MessageRow {
|
||||
replyToId: message.replyToId ?? null,
|
||||
linkMetadata: encodeJson(message.linkMetadata),
|
||||
kind: message.kind ?? null,
|
||||
systemEvent: message.systemEvent ?? null
|
||||
systemEvent: message.systemEvent ?? null,
|
||||
revision: message.revision ?? 0,
|
||||
headHash: message.headHash ?? null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -116,6 +120,8 @@ export function rowToMessage(row: MessageRow, reactions: Reaction[] = []): Messa
|
||||
content: row.content,
|
||||
timestamp: row.timestamp,
|
||||
editedAt: row.editedAt ?? undefined,
|
||||
revision: row.revision ?? 0,
|
||||
headHash: row.headHash ?? undefined,
|
||||
isDeleted: row.isDeleted === 1,
|
||||
replyToId: row.replyToId ?? undefined,
|
||||
linkMetadata: decodeJson(row.linkMetadata),
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
export const MOBILE_SQLITE_DATABASE_NAME = 'metoyou';
|
||||
|
||||
/** Bump when adding DDL statements; stored in meta table. */
|
||||
export const MOBILE_SQLITE_SCHEMA_VERSION = 2;
|
||||
export const MOBILE_SQLITE_SCHEMA_VERSION = 3;
|
||||
|
||||
const META_SCHEMA_VERSION_KEY = 'mobile_sqlite_schema_version';
|
||||
|
||||
@@ -23,7 +23,9 @@ export function buildMobileSqliteSchemaStatements(): string[] {
|
||||
replyToId TEXT,
|
||||
linkMetadata TEXT,
|
||||
kind TEXT,
|
||||
systemEvent TEXT
|
||||
systemEvent TEXT,
|
||||
revision INTEGER NOT NULL DEFAULT 0,
|
||||
headHash TEXT
|
||||
)`,
|
||||
'CREATE INDEX IF NOT EXISTS idx_messages_room_id ON messages(roomId)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_messages_timestamp ON messages(timestamp)',
|
||||
@@ -136,6 +138,11 @@ const SCHEMA_V2_MESSAGE_COLUMNS = [
|
||||
'ALTER TABLE messages ADD COLUMN systemEvent TEXT'
|
||||
];
|
||||
|
||||
const SCHEMA_V3_MESSAGE_COLUMNS = [
|
||||
'ALTER TABLE messages ADD COLUMN revision INTEGER NOT NULL DEFAULT 0',
|
||||
'ALTER TABLE messages ADD COLUMN headHash TEXT'
|
||||
];
|
||||
|
||||
/** Returns DDL statements that still need to run for the stored schema version. */
|
||||
export function resolveMobileSqliteMigrationStatements(storedVersion: number): string[] {
|
||||
if (storedVersion >= MOBILE_SQLITE_SCHEMA_VERSION) {
|
||||
@@ -153,5 +160,10 @@ export function resolveMobileSqliteMigrationStatements(storedVersion: number): s
|
||||
statements.push(`INSERT OR REPLACE INTO meta (key, value) VALUES ('${META_SCHEMA_VERSION_KEY}', '2')`);
|
||||
}
|
||||
|
||||
if (storedVersion < 3) {
|
||||
statements.push(...SCHEMA_V3_MESSAGE_COLUMNS);
|
||||
statements.push(`INSERT OR REPLACE INTO meta (key, value) VALUES ('${META_SCHEMA_VERSION_KEY}', '3')`);
|
||||
}
|
||||
|
||||
return statements;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** IndexedDB schema version - bump when adding/changing object stores. */
|
||||
export const BROWSER_DATABASE_VERSION = 3;
|
||||
export const BROWSER_DATABASE_VERSION = 4;
|
||||
|
||||
const STORE_MESSAGES = 'messages';
|
||||
const STORE_USERS = 'users';
|
||||
@@ -9,6 +9,7 @@ const STORE_BANS = 'bans';
|
||||
const STORE_META = 'meta';
|
||||
const STORE_ATTACHMENTS = 'attachments';
|
||||
const STORE_CUSTOM_EMOJIS = 'customEmojis';
|
||||
const STORE_MESSAGE_REVISIONS = 'messageRevisions';
|
||||
|
||||
export function ensureObjectStoreDuringUpgrade(
|
||||
database: IDBDatabase,
|
||||
@@ -64,4 +65,8 @@ export function applyBrowserDatabaseSchema(
|
||||
|
||||
ensureStoreIndex(customEmojisStore, 'updatedAt', 'updatedAt');
|
||||
ensureStoreIndex(customEmojisStore, 'creatorUserId', 'creatorUserId');
|
||||
|
||||
const revisionsStore = ensureObjectStoreDuringUpgrade(database, upgradeTransaction, STORE_MESSAGE_REVISIONS, { keyPath: 'id' });
|
||||
|
||||
ensureStoreIndex(revisionsStore, 'messageId', 'messageId');
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
User,
|
||||
Room,
|
||||
Reaction,
|
||||
BanEntry
|
||||
BanEntry,
|
||||
type MessageRevision
|
||||
} from '../../shared-kernel';
|
||||
import type { ChatAttachmentMeta, CustomEmoji } from '../../shared-kernel';
|
||||
import { getStoredCurrentUserId } from '../../core/storage/current-user-storage';
|
||||
@@ -25,6 +26,7 @@ const STORE_BANS = 'bans';
|
||||
const STORE_META = 'meta';
|
||||
const STORE_ATTACHMENTS = 'attachments';
|
||||
const STORE_CUSTOM_EMOJIS = 'customEmojis';
|
||||
const STORE_MESSAGE_REVISIONS = 'messageRevisions';
|
||||
/** All object store names, used when clearing the entire database. */
|
||||
const ALL_STORE_NAMES: string[] = [
|
||||
STORE_MESSAGES,
|
||||
@@ -34,6 +36,7 @@ const ALL_STORE_NAMES: string[] = [
|
||||
STORE_BANS,
|
||||
STORE_ATTACHMENTS,
|
||||
STORE_CUSTOM_EMOJIS,
|
||||
STORE_MESSAGE_REVISIONS,
|
||||
STORE_META
|
||||
];
|
||||
|
||||
@@ -67,6 +70,13 @@ export class BrowserDatabaseService {
|
||||
await this.put(STORE_MESSAGES, message);
|
||||
}
|
||||
|
||||
async saveMessageRevision(revision: MessageRevision): Promise<void> {
|
||||
await this.put(STORE_MESSAGE_REVISIONS, {
|
||||
...revision,
|
||||
id: `${revision.messageId}:${revision.revision}`
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the latest messages for a room, sorted oldest-first for display.
|
||||
* @param roomId - Target room.
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
type Room,
|
||||
type User
|
||||
} from '../../shared-kernel';
|
||||
import type { ChatAttachmentMeta, CustomEmoji } from '../../shared-kernel';
|
||||
import type { ChatAttachmentMeta, CustomEmoji, MessageRevision } from '../../shared-kernel';
|
||||
import { getStoredCurrentUserId } from '../../core/storage/current-user-storage';
|
||||
import {
|
||||
attachmentToValues,
|
||||
@@ -42,6 +42,15 @@ export class CapacitorDatabaseService {
|
||||
await this.connection.initialize();
|
||||
}
|
||||
|
||||
async saveMessageRevision(revision: MessageRevision): Promise<void> {
|
||||
const store = await this.connection.getStore();
|
||||
|
||||
await store.run(
|
||||
'INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)',
|
||||
[`message-revision:${revision.messageId}:${revision.revision}`, JSON.stringify(revision)]
|
||||
);
|
||||
}
|
||||
|
||||
async saveMessage(message: Message): Promise<void> {
|
||||
const store = await this.connection.getStore();
|
||||
const row = messageToRow(message);
|
||||
@@ -49,8 +58,9 @@ export class CapacitorDatabaseService {
|
||||
await store.run(
|
||||
`INSERT OR REPLACE INTO messages (
|
||||
id, roomId, ownerUserId, channelId, senderId, senderName, content,
|
||||
timestamp, editedAt, isDeleted, replyToId, linkMetadata, kind, systemEvent
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
timestamp, editedAt, isDeleted, replyToId, linkMetadata, kind, systemEvent,
|
||||
revision, headHash
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
row.id,
|
||||
row.roomId,
|
||||
@@ -65,7 +75,9 @@ export class CapacitorDatabaseService {
|
||||
row.replyToId ?? null,
|
||||
row.linkMetadata ?? null,
|
||||
row.kind ?? null,
|
||||
row.systemEvent ?? null
|
||||
row.systemEvent ?? null,
|
||||
row.revision ?? 0,
|
||||
row.headHash ?? null
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ import {
|
||||
User,
|
||||
Room,
|
||||
Reaction,
|
||||
BanEntry
|
||||
BanEntry,
|
||||
type MessageRevision
|
||||
} from '../../shared-kernel';
|
||||
import type { ChatAttachmentMeta, CustomEmoji } from '../../shared-kernel';
|
||||
import { PlatformService } from '../../core/platform';
|
||||
@@ -99,6 +100,11 @@ export class DatabaseService {
|
||||
/** Persist a single chat message. */
|
||||
saveMessage(message: Message) { return this.withReady(() => this.backend.saveMessage(message)); }
|
||||
|
||||
/** Persist an append-only message revision audit entry. */
|
||||
saveMessageRevision(revision: MessageRevision) {
|
||||
return this.withReady(() => this.backend.saveMessageRevision(revision));
|
||||
}
|
||||
|
||||
/** Retrieve the latest messages for a room or channel with optional pagination.
|
||||
*
|
||||
* When `beforeTimestamp` is provided, only messages strictly older than that
|
||||
|
||||
@@ -4,7 +4,8 @@ import {
|
||||
User,
|
||||
Room,
|
||||
Reaction,
|
||||
BanEntry
|
||||
BanEntry,
|
||||
type MessageRevision
|
||||
} from '../../shared-kernel';
|
||||
import type { CustomEmoji } from '../../shared-kernel';
|
||||
import type { ElectronApi } from '../../core/platform/electron/electron-api.models';
|
||||
@@ -38,6 +39,16 @@ export class ElectronDatabaseService {
|
||||
return this.api.command({ type: 'save-message', payload: { message } });
|
||||
}
|
||||
|
||||
saveMessageRevision(revision: MessageRevision): Promise<void> {
|
||||
return this.api.command({
|
||||
type: 'save-meta',
|
||||
payload: {
|
||||
key: `message-revision:${revision.messageId}:${revision.revision}`,
|
||||
value: JSON.stringify(revision)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the latest messages for a room, sorted oldest-first for display.
|
||||
*
|
||||
|
||||
@@ -73,7 +73,7 @@ function createContext(): PeerConnectionManagerContext {
|
||||
} as unknown as PeerConnectionManagerContext['logger'],
|
||||
callbacks: {
|
||||
getIceServers: vi.fn(() => []),
|
||||
getIdentifyCredentials: vi.fn(() => ({ oderId: 'local-user', displayName: 'Local User' })),
|
||||
getIdentifyCredentials: vi.fn(() => ({ oderId: 'local-user', token: 'session-token', displayName: 'Local User' })),
|
||||
getLocalMediaStream: vi.fn(() => null),
|
||||
getLocalPeerId: vi.fn(() => 'local-peer'),
|
||||
getVoiceStateSnapshot: vi.fn(() => ({
|
||||
|
||||
@@ -133,7 +133,7 @@ function createContext(localOderId: string): PeerConnectionManagerContext {
|
||||
} as unknown as PeerConnectionManagerContext['logger'],
|
||||
callbacks: {
|
||||
getIceServers: vi.fn(() => []),
|
||||
getIdentifyCredentials: vi.fn(() => ({ oderId: localOderId, displayName: localOderId })),
|
||||
getIdentifyCredentials: vi.fn(() => ({ oderId: localOderId, token: 'session-token', displayName: localOderId })),
|
||||
getLocalMediaStream: vi.fn(() => null),
|
||||
getLocalPeerId: vi.fn(() => localOderId),
|
||||
getVoiceStateSnapshot: vi.fn(() => ({
|
||||
|
||||
@@ -40,6 +40,7 @@ import { ServerSignalingCoordinator } from './signaling/server-signaling-coordin
|
||||
import { SignalingManager } from './signaling/signaling.manager';
|
||||
import { SignalingTransportHandler } from './signaling/signaling-transport-handler';
|
||||
import { WebRtcStateController } from './state/webrtc-state-controller';
|
||||
import { AuthTokenStoreService } from '../../domains/authentication';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
@@ -49,6 +50,7 @@ export class WebRTCService implements OnDestroy {
|
||||
private readonly debugging = inject(DebuggingService);
|
||||
private readonly screenShareSourcePicker = inject(ScreenShareSourcePickerService);
|
||||
private readonly iceServerSettings = inject(IceServerSettingsService);
|
||||
private readonly authTokenStore = inject(AuthTokenStoreService);
|
||||
|
||||
private readonly logger = new WebRTCLogger(() => this.debugging.enabled());
|
||||
private readonly state = new WebRtcStateController();
|
||||
@@ -144,7 +146,22 @@ export class WebRTCService implements OnDestroy {
|
||||
this.signalingTransportHandler = new SignalingTransportHandler({
|
||||
signalingCoordinator: this.signalingCoordinator,
|
||||
logger: this.logger,
|
||||
getLocalPeerId: () => this.state.getLocalPeerId()
|
||||
getLocalPeerId: () => this.state.getLocalPeerId(),
|
||||
resolveSessionToken: (signalUrl) => {
|
||||
if (signalUrl) {
|
||||
return this.authTokenStore.getToken(signalUrl.replace(/^ws/, 'http'));
|
||||
}
|
||||
|
||||
for (const { signalUrl: connectedUrl } of this.signalingCoordinator.getConnectedSignalingManagers()) {
|
||||
const token = this.authTokenStore.getToken(connectedUrl.replace(/^ws/, 'http'));
|
||||
|
||||
if (token) {
|
||||
return token;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
// Now wire up cross-references (all managers are instantiated)
|
||||
|
||||
@@ -85,6 +85,8 @@ export const SIGNALING_TYPE_SERVER_USERS = 'server_users';
|
||||
export const SIGNALING_TYPE_USER_JOINED = 'user_joined';
|
||||
export const SIGNALING_TYPE_USER_LEFT = 'user_left';
|
||||
export const SIGNALING_TYPE_ACCESS_DENIED = 'access_denied';
|
||||
export const SIGNALING_TYPE_AUTH_REQUIRED = 'auth_required';
|
||||
export const SIGNALING_TYPE_AUTH_ERROR = 'auth_error';
|
||||
export const SIGNALING_TYPE_KEEPALIVE = 'keepalive';
|
||||
export const SIGNALING_TYPE_KEEPALIVE_ACK = 'keepalive_ack';
|
||||
|
||||
|
||||
@@ -34,6 +34,8 @@ export interface PeerData {
|
||||
export interface IdentifyCredentials {
|
||||
/** The user's unique order / peer identifier. */
|
||||
oderId: string;
|
||||
/** Session token proving identity to the signaling server. */
|
||||
token: string;
|
||||
/** The user's display name shown to other peers. */
|
||||
displayName: string;
|
||||
/** Optional profile description advertised via signaling identity. */
|
||||
|
||||
@@ -13,6 +13,7 @@ interface SignalingTransportHandlerDependencies<TMessage> {
|
||||
signalingCoordinator: ServerSignalingCoordinator<TMessage>;
|
||||
logger: WebRTCLogger;
|
||||
getLocalPeerId(): string;
|
||||
resolveSessionToken(signalUrl?: string): string | null;
|
||||
}
|
||||
|
||||
export class SignalingTransportHandler<TMessage> {
|
||||
@@ -193,9 +194,16 @@ export class SignalingTransportHandler<TMessage> {
|
||||
const normalizedHomeSignalServerUrl = typeof profile?.homeSignalServerUrl === 'string'
|
||||
? (profile.homeSignalServerUrl.trim().replace(/\/+$/, '') || undefined)
|
||||
: undefined;
|
||||
const token = this.dependencies.resolveSessionToken(signalUrl);
|
||||
|
||||
if (!token) {
|
||||
this.dependencies.logger.warn('Skipping identify because no session token is available', { signalUrl, oderId });
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastIdentifyCredentials = {
|
||||
oderId,
|
||||
token,
|
||||
displayName: normalizedDisplayName,
|
||||
description: normalizedDescription,
|
||||
profileUpdatedAt: normalizedProfileUpdatedAt,
|
||||
@@ -205,6 +213,7 @@ export class SignalingTransportHandler<TMessage> {
|
||||
if (signalUrl) {
|
||||
this.sendRawMessageToSignalUrl(signalUrl, {
|
||||
type: SIGNALING_TYPE_IDENTIFY,
|
||||
token,
|
||||
oderId,
|
||||
displayName: normalizedDisplayName,
|
||||
description: normalizedDescription,
|
||||
@@ -225,6 +234,7 @@ export class SignalingTransportHandler<TMessage> {
|
||||
for (const { signalUrl: managerSignalUrl, manager } of connectedManagers) {
|
||||
manager.sendRawMessage({
|
||||
type: SIGNALING_TYPE_IDENTIFY,
|
||||
token,
|
||||
oderId,
|
||||
displayName: normalizedDisplayName,
|
||||
description: normalizedDescription,
|
||||
|
||||
@@ -110,6 +110,7 @@ describe('SignalingManager reconnection', () => {
|
||||
homeSignalServerUrl?: string;
|
||||
} = {
|
||||
oderId: 'peer-a',
|
||||
token: 'session-token',
|
||||
displayName: 'Peer A',
|
||||
description: 'hello',
|
||||
profileUpdatedAt: 42,
|
||||
@@ -221,6 +222,7 @@ describe('SignalingManager reconnection', () => {
|
||||
|
||||
expect(identifyMessage).toMatchObject({
|
||||
type: 'identify',
|
||||
token: 'session-token',
|
||||
oderId: 'peer-a',
|
||||
displayName: 'Peer A',
|
||||
description: 'hello',
|
||||
@@ -274,7 +276,7 @@ describe('SignalingManager reconnection', () => {
|
||||
const errorSpy = vi.spyOn(logger, 'error');
|
||||
const manager = new SignalingManager(
|
||||
logger,
|
||||
() => ({ oderId: 'peer-a', displayName: 'Peer A' }),
|
||||
() => ({ oderId: 'peer-a', token: 'session-token', displayName: 'Peer A' }),
|
||||
() => ({ serverId: 'server-1', userId: 'peer-a' }),
|
||||
() => new Set(['server-1'])
|
||||
);
|
||||
|
||||
@@ -373,6 +373,7 @@ export class SignalingManager {
|
||||
if (credentials) {
|
||||
this.sendRawMessage({
|
||||
type: SIGNALING_TYPE_IDENTIFY,
|
||||
token: credentials.token,
|
||||
oderId: credentials.oderId,
|
||||
displayName: credentials.displayName,
|
||||
description: credentials.description,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Message, Reaction } from './message.models';
|
||||
import type { MessageRevision } from './message-revision.models';
|
||||
import type { UserRole, RoomMember } from './user.models';
|
||||
import type {
|
||||
Room,
|
||||
@@ -30,6 +31,8 @@ export interface ChatInventoryItem {
|
||||
ts: number;
|
||||
rc: number;
|
||||
ac?: number;
|
||||
revision?: number;
|
||||
headHash?: string;
|
||||
}
|
||||
|
||||
// Every field that appears on any event is kept optional here so that
|
||||
@@ -124,6 +127,11 @@ export interface MessageDeletedEvent extends ChatEventBase {
|
||||
messageId: string;
|
||||
}
|
||||
|
||||
export interface MessageRevisionEvent extends ChatEventBase {
|
||||
type: 'message-revision';
|
||||
revision: MessageRevision;
|
||||
}
|
||||
|
||||
export interface ReactionAddedEvent extends ChatEventBase {
|
||||
type: 'reaction' | 'reaction-added';
|
||||
messageId: string;
|
||||
@@ -463,6 +471,7 @@ export type ChatEvent =
|
||||
| ChatMessageEvent
|
||||
| MessageEditedEvent
|
||||
| MessageDeletedEvent
|
||||
| MessageRevisionEvent
|
||||
| ReactionAddedEvent
|
||||
| ReactionRemovedEvent
|
||||
| FileAnnounceChatEvent
|
||||
|
||||
@@ -2,6 +2,7 @@ export * from './user.models';
|
||||
export * from './room.models';
|
||||
export * from './access-control.models';
|
||||
export * from './message.models';
|
||||
export * from './message-revision.models';
|
||||
export * from './moderation.models';
|
||||
export * from './voice-state.models';
|
||||
export * from './game-activity.models';
|
||||
|
||||
27
toju-app/src/app/shared-kernel/message-revision.models.ts
Normal file
27
toju-app/src/app/shared-kernel/message-revision.models.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export type MessageRevisionType =
|
||||
| 'create'
|
||||
| 'author-edit'
|
||||
| 'author-delete'
|
||||
| 'moderate-edit'
|
||||
| 'moderate-delete'
|
||||
| 'plugin-edit'
|
||||
| 'plugin-delete';
|
||||
|
||||
export interface MessageRevision {
|
||||
messageId: string;
|
||||
revision: number;
|
||||
prevRevisionHash: string;
|
||||
headHash: string;
|
||||
type: MessageRevisionType;
|
||||
actorId: string;
|
||||
senderId: string;
|
||||
roomId: string;
|
||||
channelId?: string;
|
||||
senderName?: string;
|
||||
content?: string;
|
||||
editedAt: number;
|
||||
isDeleted: boolean;
|
||||
replyToId?: string;
|
||||
pluginId?: string;
|
||||
signature?: string;
|
||||
}
|
||||
@@ -20,6 +20,8 @@ export interface Message {
|
||||
kind?: 'user' | 'system';
|
||||
systemEvent?: 'call-started';
|
||||
editedAt?: number;
|
||||
revision?: number;
|
||||
headHash?: string;
|
||||
reactions: Reaction[];
|
||||
isDeleted: boolean;
|
||||
replyToId?: string;
|
||||
|
||||
@@ -150,6 +150,7 @@ export interface TojuPluginManifest {
|
||||
bugs?: string;
|
||||
bundle?: {
|
||||
entrypoint?: string;
|
||||
integrity?: string;
|
||||
url: string;
|
||||
};
|
||||
capabilities?: PluginCapabilityId[];
|
||||
|
||||
@@ -28,6 +28,9 @@ function createContext(overrides: Record<string, unknown> = {}) {
|
||||
},
|
||||
attachments: {},
|
||||
debugging: {},
|
||||
messageRevisions: {
|
||||
verifyRevision: vi.fn(async () => true)
|
||||
},
|
||||
currentUser: null,
|
||||
currentRoom: null,
|
||||
savedRooms: [],
|
||||
|
||||
@@ -20,7 +20,9 @@ import { Action } from '@ngrx/store';
|
||||
import {
|
||||
DELETED_MESSAGE_CONTENT,
|
||||
type ChatEvent,
|
||||
type ChatInventoryItem,
|
||||
type Message,
|
||||
type MessageRevision,
|
||||
type Room,
|
||||
type User
|
||||
} from '../../shared-kernel';
|
||||
@@ -40,8 +42,10 @@ import {
|
||||
buildLocalInventoryMap,
|
||||
findMissingIds,
|
||||
hydrateMessage,
|
||||
mergeIncomingMessage
|
||||
mergeIncomingMessage,
|
||||
mergeIncomingRevision
|
||||
} from './messages.helpers';
|
||||
import { MessageRevisionService } from '../../domains/chat/application/services/message-revision.service';
|
||||
|
||||
type AnnouncedAttachment = Pick<AttachmentMeta, 'id' | 'filename' | 'size' | 'mime' | 'isImage' | 'uploaderPeerId'>;
|
||||
type AttachmentMetaMap = Record<string, AttachmentMeta[]>;
|
||||
@@ -61,7 +65,7 @@ type IncomingMessageType =
|
||||
|
||||
interface IncomingMessageEvent extends Omit<ChatEvent, 'type'> {
|
||||
type: IncomingMessageType;
|
||||
items?: InventoryItem[];
|
||||
items?: ChatInventoryItem[];
|
||||
ids?: string[];
|
||||
messages?: Message[];
|
||||
attachments?: AttachmentMetaMap;
|
||||
@@ -94,6 +98,7 @@ export interface IncomingMessageContext {
|
||||
webrtc: RealtimeSessionFacade;
|
||||
attachments: AttachmentFacade;
|
||||
debugging: DebuggingService;
|
||||
messageRevisions: MessageRevisionService;
|
||||
currentUser: User | null;
|
||||
currentRoom: Room | null;
|
||||
savedRooms?: Room[];
|
||||
@@ -383,6 +388,54 @@ function handleChatMessage(
|
||||
return of(MessagesActions.receiveMessage({ message: msg }));
|
||||
}
|
||||
|
||||
function handleMessageRevision(
|
||||
event: IncomingMessageEvent,
|
||||
ctx: IncomingMessageContext
|
||||
): Observable<Action> {
|
||||
const revision = (event as { revision?: MessageRevision }).revision;
|
||||
|
||||
if (!revision) {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
return from(
|
||||
(async () => {
|
||||
const isValid = await ctx.messageRevisions.verifyRevision(revision);
|
||||
|
||||
if (!isValid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { message, changed } = await mergeIncomingRevision(revision, ctx.db);
|
||||
|
||||
if (!changed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (message.isDeleted) {
|
||||
await ctx.attachments.deleteForMessage(message.id);
|
||||
return MessagesActions.deleteMessageSuccess({ messageId: message.id });
|
||||
}
|
||||
|
||||
if (revision.type === 'create') {
|
||||
return MessagesActions.receiveMessage({ message });
|
||||
}
|
||||
|
||||
if (revision.type.endsWith('edit')) {
|
||||
return MessagesActions.editMessageSuccess({
|
||||
messageId: message.id,
|
||||
content: message.content,
|
||||
editedAt: message.editedAt ?? revision.editedAt
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
})()
|
||||
).pipe(
|
||||
mergeMap((action) => action ? of(action) : EMPTY)
|
||||
);
|
||||
}
|
||||
|
||||
/** Applies a remote message edit to the local DB and store. */
|
||||
function handleMessageEdited(
|
||||
event: IncomingMessageEvent,
|
||||
@@ -664,6 +717,7 @@ const HANDLER_MAP: Readonly<Record<string, MessageHandler>> = {
|
||||
'chat-message': handleChatMessage,
|
||||
'message-edited': handleMessageEdited,
|
||||
'message-deleted': handleMessageDeleted,
|
||||
'message-revision': handleMessageRevision,
|
||||
|
||||
// Reactions
|
||||
'reaction-added': handleReactionAdded,
|
||||
|
||||
@@ -55,6 +55,8 @@ import { hydrateMessages } from './messages.helpers';
|
||||
import { canEditMessage } from '../../domains/chat/domain/rules/message.rules';
|
||||
import { resolveRoomPermission } from '../../domains/access-control';
|
||||
import { dispatchIncomingMessage, IncomingMessageContext } from './messages-incoming.handlers';
|
||||
import { MessageRevisionService } from '../../domains/chat/application/services/message-revision.service';
|
||||
import { materializeMessageFromRevision } from '../../domains/chat/domain/rules/message-revision.builder.rules';
|
||||
|
||||
const INITIAL_ROOM_MESSAGE_LIMIT = 30;
|
||||
/** Cap on simultaneous browser-cache prefetches for apps with many saved rooms. */
|
||||
@@ -73,6 +75,7 @@ export class MessagesEffects {
|
||||
private readonly linkMetadata = inject(LinkMetadataService);
|
||||
private readonly platform = inject(PlatformService);
|
||||
private readonly i18n = inject(AppI18nService);
|
||||
private readonly messageRevisions = inject(MessageRevisionService);
|
||||
|
||||
/** Loads messages for a room from the local database, hydrating reactions. */
|
||||
loadMessages$ = createEffect(() =>
|
||||
@@ -235,7 +238,7 @@ export class MessagesEffects {
|
||||
return of(MessagesActions.sendMessageFailure({ error: this.i18n.instant('chat.effects.notConnectedToRoom') }));
|
||||
}
|
||||
|
||||
const message: Message = {
|
||||
const draftMessage: Message = {
|
||||
id: uuidv4(),
|
||||
roomId: currentRoom.id,
|
||||
channelId: channelId || 'general',
|
||||
@@ -245,27 +248,44 @@ export class MessagesEffects {
|
||||
timestamp: this.timeSync.now(),
|
||||
reactions: [],
|
||||
isDeleted: false,
|
||||
replyToId
|
||||
replyToId,
|
||||
revision: 0
|
||||
};
|
||||
|
||||
this.attachments.rememberMessageRoom(message.id, message.roomId);
|
||||
return from((async () => {
|
||||
const revision = await this.messageRevisions.createSignedRevision({
|
||||
message: draftMessage,
|
||||
type: 'create',
|
||||
actorId: currentUser.id,
|
||||
editedAt: draftMessage.timestamp
|
||||
});
|
||||
const message = materializeMessageFromRevision(null, revision);
|
||||
|
||||
this.trackBackgroundOperation(
|
||||
this.db.saveMessage(message),
|
||||
'Failed to persist outgoing chat message',
|
||||
{
|
||||
channelId: message.channelId,
|
||||
contentLength: message.content.length,
|
||||
messageId: message.id,
|
||||
roomId: message.roomId
|
||||
}
|
||||
);
|
||||
this.attachments.rememberMessageRoom(message.id, message.roomId);
|
||||
|
||||
this.customEmoji.pushEmojisInContent(content);
|
||||
this.webrtc.broadcastMessage({ type: 'chat-message',
|
||||
message });
|
||||
this.trackBackgroundOperation(
|
||||
this.db.saveMessage(message),
|
||||
'Failed to persist outgoing chat message',
|
||||
{
|
||||
channelId: message.channelId,
|
||||
contentLength: message.content.length,
|
||||
messageId: message.id,
|
||||
roomId: message.roomId
|
||||
}
|
||||
);
|
||||
|
||||
return of(MessagesActions.sendMessageSuccess({ message }));
|
||||
this.trackBackgroundOperation(
|
||||
this.messageRevisions.persistRevision(revision),
|
||||
'Failed to persist outgoing message revision',
|
||||
{ messageId: message.id, revision: revision.revision }
|
||||
);
|
||||
|
||||
this.customEmoji.pushEmojisInContent(content);
|
||||
this.webrtc.broadcastMessage({ type: 'chat-message', message });
|
||||
this.messageRevisions.broadcastRevision(revision);
|
||||
|
||||
return MessagesActions.sendMessageSuccess({ message });
|
||||
})());
|
||||
}),
|
||||
catchError((error) =>
|
||||
of(MessagesActions.sendMessageFailure({ error: error.message }))
|
||||
@@ -295,26 +315,38 @@ export class MessagesEffects {
|
||||
|
||||
const editedAt = this.timeSync.now();
|
||||
|
||||
this.trackBackgroundOperation(
|
||||
this.db.updateMessage(messageId, { content,
|
||||
editedAt }),
|
||||
'Failed to persist edited chat message',
|
||||
{
|
||||
contentLength: content.length,
|
||||
editedAt,
|
||||
messageId
|
||||
}
|
||||
);
|
||||
return from((async () => {
|
||||
const revision = await this.messageRevisions.createSignedRevision({
|
||||
message: existing,
|
||||
type: 'author-edit',
|
||||
actorId: currentUser.id,
|
||||
content,
|
||||
editedAt
|
||||
});
|
||||
const updatedMessage = materializeMessageFromRevision(existing, revision);
|
||||
|
||||
this.customEmoji.pushEmojisInContent(content);
|
||||
this.webrtc.broadcastMessage({ type: 'message-edited',
|
||||
messageId,
|
||||
content,
|
||||
editedAt });
|
||||
this.trackBackgroundOperation(
|
||||
this.db.saveMessage(updatedMessage),
|
||||
'Failed to persist edited chat message',
|
||||
{
|
||||
contentLength: content.length,
|
||||
editedAt,
|
||||
messageId
|
||||
}
|
||||
);
|
||||
|
||||
return of(MessagesActions.editMessageSuccess({ messageId,
|
||||
content,
|
||||
editedAt }));
|
||||
this.trackBackgroundOperation(
|
||||
this.messageRevisions.persistRevision(revision),
|
||||
'Failed to persist edited message revision',
|
||||
{ messageId, revision: revision.revision }
|
||||
);
|
||||
|
||||
this.customEmoji.pushEmojisInContent(content);
|
||||
this.webrtc.broadcastMessage({ type: 'message-edited', messageId, content, editedAt });
|
||||
this.messageRevisions.broadcastRevision(revision);
|
||||
|
||||
return MessagesActions.editMessageSuccess({ messageId, content, editedAt });
|
||||
})());
|
||||
}),
|
||||
catchError((error) =>
|
||||
of(MessagesActions.editMessageFailure({ error: error.message }))
|
||||
@@ -346,30 +378,39 @@ export class MessagesEffects {
|
||||
|
||||
const deletedAt = this.timeSync.now();
|
||||
|
||||
this.trackBackgroundOperation(
|
||||
this.db.updateMessage(messageId, {
|
||||
content: DELETED_MESSAGE_CONTENT,
|
||||
return from((async () => {
|
||||
const revision = await this.messageRevisions.createSignedRevision({
|
||||
message: existing,
|
||||
type: 'author-delete',
|
||||
actorId: currentUser.id,
|
||||
editedAt: deletedAt,
|
||||
isDeleted: true
|
||||
}),
|
||||
'Failed to persist message deletion',
|
||||
{
|
||||
deletedAt,
|
||||
messageId
|
||||
}
|
||||
);
|
||||
});
|
||||
const deletedMessage = materializeMessageFromRevision(existing, revision);
|
||||
|
||||
this.trackBackgroundOperation(
|
||||
this.attachments.deleteForMessage(messageId),
|
||||
'Failed to delete message attachments',
|
||||
{ messageId }
|
||||
);
|
||||
this.trackBackgroundOperation(
|
||||
this.db.saveMessage(deletedMessage),
|
||||
'Failed to persist message deletion',
|
||||
{ deletedAt, messageId }
|
||||
);
|
||||
|
||||
this.webrtc.broadcastMessage({ type: 'message-deleted',
|
||||
messageId,
|
||||
deletedAt });
|
||||
this.trackBackgroundOperation(
|
||||
this.messageRevisions.persistRevision(revision),
|
||||
'Failed to persist deleted message revision',
|
||||
{ messageId, revision: revision.revision }
|
||||
);
|
||||
|
||||
return of(MessagesActions.deleteMessageSuccess({ messageId }));
|
||||
this.trackBackgroundOperation(
|
||||
this.attachments.deleteForMessage(messageId),
|
||||
'Failed to delete message attachments',
|
||||
{ messageId }
|
||||
);
|
||||
|
||||
this.webrtc.broadcastMessage({ type: 'message-deleted', messageId, deletedAt });
|
||||
this.messageRevisions.broadcastRevision(revision);
|
||||
|
||||
return MessagesActions.deleteMessageSuccess({ messageId });
|
||||
})());
|
||||
}),
|
||||
catchError((error) =>
|
||||
of(MessagesActions.deleteMessageFailure({ error: error.message }))
|
||||
@@ -399,37 +440,54 @@ export class MessagesEffects {
|
||||
return of(MessagesActions.deleteMessageFailure({ error: this.i18n.instant('chat.effects.permissionDenied') }));
|
||||
}
|
||||
|
||||
const deletedAt = this.timeSync.now();
|
||||
return from(this.db.getMessageById(messageId)).pipe(
|
||||
mergeMap((existing) => {
|
||||
if (!existing) {
|
||||
return of(MessagesActions.deleteMessageFailure({ error: this.i18n.instant('chat.effects.messageNotFound') }));
|
||||
}
|
||||
|
||||
this.trackBackgroundOperation(
|
||||
this.db.updateMessage(messageId, {
|
||||
content: DELETED_MESSAGE_CONTENT,
|
||||
editedAt: deletedAt,
|
||||
isDeleted: true
|
||||
}),
|
||||
'Failed to persist admin message deletion',
|
||||
{
|
||||
deletedBy: currentUser.id,
|
||||
deletedAt,
|
||||
messageId
|
||||
}
|
||||
const deletedAt = this.timeSync.now();
|
||||
|
||||
return from((async () => {
|
||||
const revision = await this.messageRevisions.createSignedRevision({
|
||||
message: existing,
|
||||
type: 'moderate-delete',
|
||||
actorId: currentUser.id,
|
||||
editedAt: deletedAt,
|
||||
isDeleted: true
|
||||
});
|
||||
const deletedMessage = materializeMessageFromRevision(existing, revision);
|
||||
|
||||
this.trackBackgroundOperation(
|
||||
this.db.saveMessage(deletedMessage),
|
||||
'Failed to persist admin message deletion',
|
||||
{ deletedBy: currentUser.id, deletedAt, messageId }
|
||||
);
|
||||
|
||||
this.trackBackgroundOperation(
|
||||
this.messageRevisions.persistRevision(revision),
|
||||
'Failed to persist moderated delete revision',
|
||||
{ messageId, revision: revision.revision }
|
||||
);
|
||||
|
||||
this.trackBackgroundOperation(
|
||||
this.attachments.deleteForMessage(messageId),
|
||||
'Failed to delete admin-deleted message attachments',
|
||||
{ deletedBy: currentUser.id, messageId }
|
||||
);
|
||||
|
||||
this.webrtc.broadcastMessage({
|
||||
type: 'message-deleted',
|
||||
messageId,
|
||||
deletedBy: currentUser.id,
|
||||
deletedAt
|
||||
});
|
||||
this.messageRevisions.broadcastRevision(revision);
|
||||
|
||||
return MessagesActions.deleteMessageSuccess({ messageId });
|
||||
})());
|
||||
})
|
||||
);
|
||||
|
||||
this.trackBackgroundOperation(
|
||||
this.attachments.deleteForMessage(messageId),
|
||||
'Failed to delete admin-deleted message attachments',
|
||||
{
|
||||
deletedBy: currentUser.id,
|
||||
messageId
|
||||
}
|
||||
);
|
||||
|
||||
this.webrtc.broadcastMessage({ type: 'message-deleted',
|
||||
messageId,
|
||||
deletedBy: currentUser.id,
|
||||
deletedAt });
|
||||
|
||||
return of(MessagesActions.deleteMessageSuccess({ messageId }));
|
||||
}),
|
||||
catchError((error) =>
|
||||
of(MessagesActions.deleteMessageFailure({ error: error.message }))
|
||||
@@ -606,12 +664,13 @@ export class MessagesEffects {
|
||||
webrtc: this.webrtc,
|
||||
attachments: this.attachments,
|
||||
debugging: this.debugging,
|
||||
messageRevisions: this.messageRevisions,
|
||||
currentUser: currentUser ?? null,
|
||||
currentRoom,
|
||||
savedRooms
|
||||
};
|
||||
|
||||
return dispatchIncomingMessage(event, ctx).pipe(
|
||||
return dispatchIncomingMessage(event as Parameters<typeof dispatchIncomingMessage>[0], ctx).pipe(
|
||||
catchError((error) => {
|
||||
const eventRecord = event as unknown as Record<string, unknown>;
|
||||
const messageRecord = (eventRecord['message'] && typeof eventRecord['message'] === 'object' && !Array.isArray(eventRecord['message']))
|
||||
@@ -658,6 +717,7 @@ export class MessagesEffects {
|
||||
webrtc: this.webrtc,
|
||||
attachments: this.attachments,
|
||||
debugging: this.debugging,
|
||||
messageRevisions: this.messageRevisions,
|
||||
currentUser: currentUser ?? null,
|
||||
currentRoom,
|
||||
savedRooms
|
||||
|
||||
@@ -2,10 +2,22 @@
|
||||
* Message store helpers - delegates pure domain logic to `domains/chat/domain/`
|
||||
* and provides DB-dependent hydration/merge operations at the application level.
|
||||
*/
|
||||
import { Message } from '../../shared-kernel';
|
||||
import {
|
||||
Message,
|
||||
type MessageRevision
|
||||
} from '../../shared-kernel';
|
||||
import { DatabaseService } from '../../infrastructure/persistence';
|
||||
import { getMessageTimestamp, normaliseDeletedMessage } from '../../domains/chat/domain/rules/message.rules';
|
||||
import type { InventoryItem } from '../../domains/chat/domain/rules/message-sync.rules';
|
||||
import {
|
||||
computeMessageHeadHashFromMessage,
|
||||
getMessageRevision,
|
||||
shouldApplyIncomingRevision
|
||||
} from '../../domains/chat/domain/rules/message-integrity.rules';
|
||||
import {
|
||||
materializeMessageFromRevision,
|
||||
revisionBeatsMessage
|
||||
} from '../../domains/chat/domain/rules/message-revision.builder.rules';
|
||||
|
||||
// Re-export domain logic so existing callers keep working
|
||||
export {
|
||||
@@ -58,26 +70,28 @@ export async function buildInventoryItem(
|
||||
_db: DatabaseService,
|
||||
attachmentCountOverride?: number
|
||||
): Promise<InventoryItem> {
|
||||
const revision = getMessageRevision(msg);
|
||||
const headHash = msg.headHash ?? await computeMessageHeadHashFromMessage(msg, revision);
|
||||
|
||||
if (msg.isDeleted) {
|
||||
return {
|
||||
id: msg.id,
|
||||
ts: getMessageTimestamp(msg),
|
||||
rc: 0,
|
||||
ac: 0
|
||||
ac: 0,
|
||||
revision,
|
||||
headHash
|
||||
};
|
||||
}
|
||||
|
||||
const item: InventoryItem = {
|
||||
return {
|
||||
id: msg.id,
|
||||
ts: getMessageTimestamp(msg),
|
||||
rc: msg.reactions?.length ?? 0
|
||||
rc: msg.reactions?.length ?? 0,
|
||||
ac: attachmentCountOverride ?? 0,
|
||||
revision,
|
||||
headHash
|
||||
};
|
||||
|
||||
if (attachmentCountOverride !== undefined) {
|
||||
item.ac = attachmentCountOverride;
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
/** Builds a local map of `{timestamp, reactionCount, attachmentCount}` keyed by message ID.
|
||||
@@ -90,25 +104,17 @@ export async function buildLocalInventoryMap(
|
||||
messages: Message[],
|
||||
_db: DatabaseService,
|
||||
attachmentCountOverrides?: ReadonlyMap<string, number>
|
||||
): Promise<Map<string, { ts: number; rc: number; ac: number }>> {
|
||||
const map = new Map<string, { ts: number; rc: number; ac: number }>();
|
||||
): Promise<Map<string, InventoryItem>> {
|
||||
const map = new Map<string, InventoryItem>();
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.isDeleted) {
|
||||
map.set(msg.id, {
|
||||
ts: getMessageTimestamp(msg),
|
||||
rc: 0,
|
||||
ac: 0
|
||||
});
|
||||
const item = await buildInventoryItem(
|
||||
msg,
|
||||
_db,
|
||||
attachmentCountOverrides?.get(msg.id)
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
map.set(msg.id, {
|
||||
ts: getMessageTimestamp(msg),
|
||||
rc: msg.reactions?.length ?? 0,
|
||||
ac: attachmentCountOverrides?.get(msg.id) ?? 0
|
||||
});
|
||||
map.set(msg.id, item);
|
||||
}
|
||||
|
||||
return map;
|
||||
@@ -125,11 +131,22 @@ export interface MergeResult {
|
||||
* Handles message upsert and reaction deduplication, then returns
|
||||
* the fully hydrated message alongside a `changed` flag.
|
||||
*/
|
||||
export async function mergeIncomingMessage(
|
||||
incoming: Message,
|
||||
db: DatabaseService
|
||||
): Promise<MergeResult> {
|
||||
const existing = await db.getMessageById(incoming.id);
|
||||
function shouldApplyIncomingMessage(incoming: Message, existing: Message | null): boolean {
|
||||
const incomingRevision = getMessageRevision(incoming);
|
||||
const existingRevision = getMessageRevision(existing ?? undefined);
|
||||
|
||||
if (incoming.headHash) {
|
||||
const existingHeadHash = existing?.headHash
|
||||
?? '';
|
||||
|
||||
return shouldApplyIncomingRevision(
|
||||
incomingRevision,
|
||||
existingRevision,
|
||||
incoming.headHash,
|
||||
existingHeadHash
|
||||
);
|
||||
}
|
||||
|
||||
const existingTs = existing ? getMessageTimestamp(existing) : -1;
|
||||
const incomingTs = getMessageTimestamp(incoming);
|
||||
const isDeletedStateNewer =
|
||||
@@ -137,10 +154,70 @@ export async function mergeIncomingMessage(
|
||||
incomingTs === existingTs &&
|
||||
incoming.isDeleted &&
|
||||
!existing.isDeleted;
|
||||
const isNewer = !existing || incomingTs > existingTs || isDeletedStateNewer;
|
||||
|
||||
return !existing || incomingTs > existingTs || isDeletedStateNewer;
|
||||
}
|
||||
|
||||
export async function mergeIncomingRevision(
|
||||
revision: MessageRevision,
|
||||
db: DatabaseService
|
||||
): Promise<MergeResult> {
|
||||
const existing = await db.getMessageById(revision.messageId);
|
||||
|
||||
if (!revisionBeatsMessage(revision, existing)) {
|
||||
if (!existing) {
|
||||
return {
|
||||
message: materializeMessageFromRevision(null, revision),
|
||||
changed: false
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
message: normaliseDeletedMessage(existing),
|
||||
changed: false
|
||||
};
|
||||
}
|
||||
|
||||
const message = materializeMessageFromRevision(existing, revision);
|
||||
|
||||
await db.saveMessage(message);
|
||||
await db.saveMessageRevision(revision);
|
||||
|
||||
if (message.isDeleted) {
|
||||
return {
|
||||
message: normaliseDeletedMessage(message),
|
||||
changed: true
|
||||
};
|
||||
}
|
||||
|
||||
const reactions = await db.getReactionsForMessage(message.id);
|
||||
|
||||
return {
|
||||
message: {
|
||||
...message,
|
||||
reactions
|
||||
},
|
||||
changed: true
|
||||
};
|
||||
}
|
||||
|
||||
export async function mergeIncomingMessage(
|
||||
incoming: Message,
|
||||
db: DatabaseService
|
||||
): Promise<MergeResult> {
|
||||
const existing = await db.getMessageById(incoming.id);
|
||||
const isNewer = shouldApplyIncomingMessage(incoming, existing);
|
||||
|
||||
if (isNewer) {
|
||||
await db.saveMessage(incoming);
|
||||
const persisted = incoming.headHash
|
||||
? incoming
|
||||
: {
|
||||
...incoming,
|
||||
revision: getMessageRevision(incoming),
|
||||
headHash: await computeMessageHeadHashFromMessage(incoming, getMessageRevision(incoming))
|
||||
};
|
||||
|
||||
await db.saveMessage(persisted);
|
||||
}
|
||||
|
||||
// Persist incoming reactions (deduped by the DB layer)
|
||||
|
||||
@@ -119,7 +119,6 @@ export class RoomSettingsEffects {
|
||||
|
||||
if (canManageRoom) {
|
||||
this.serverDirectory.updateServer(room.id, {
|
||||
currentOwnerId: currentUser.id,
|
||||
actingRole: currentUserRole ?? undefined,
|
||||
name: updatedSettings.name,
|
||||
description: updatedSettings.description,
|
||||
@@ -175,7 +174,6 @@ export class RoomSettingsEffects {
|
||||
});
|
||||
|
||||
this.serverDirectory.updateServer(currentRoom.id, {
|
||||
currentOwnerId: currentUser.id,
|
||||
actingRole: role ?? undefined,
|
||||
channels
|
||||
}, {
|
||||
@@ -286,7 +284,6 @@ export class RoomSettingsEffects {
|
||||
});
|
||||
|
||||
this.serverDirectory.updateServer(room.id, {
|
||||
currentOwnerId: currentUser.id,
|
||||
roles: nextRoom.roles,
|
||||
roleAssignments: nextRoom.roleAssignments,
|
||||
channelPermissions: nextRoom.channelPermissions,
|
||||
@@ -355,7 +352,6 @@ export class RoomSettingsEffects {
|
||||
});
|
||||
|
||||
this.serverDirectory.updateServer(room.id, {
|
||||
currentOwnerId: currentUser.id,
|
||||
actingRole: isOwner ? 'host' : undefined,
|
||||
icon,
|
||||
iconUpdatedAt
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
getPersistedCurrentUserId
|
||||
} from './rooms.helpers';
|
||||
import type { RoomPresenceSignalingMessage } from './rooms.helpers';
|
||||
import { SESSION_EXPIRED_ERROR_CODE } from '../../domains/authentication/domain/logic/auth-session.rules';
|
||||
|
||||
const SERVER_ICON_SYNC_REQUEST_DELAYS_MS = [
|
||||
1_500,
|
||||
@@ -319,6 +320,10 @@ export class RoomStateSyncEffects {
|
||||
);
|
||||
}
|
||||
|
||||
case 'auth_required':
|
||||
case 'auth_error':
|
||||
return of(UsersActions.loadCurrentUserFailure({ error: SESSION_EXPIRED_ERROR_CODE }));
|
||||
|
||||
default:
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
@@ -753,7 +753,6 @@ export class RoomsEffects {
|
||||
});
|
||||
|
||||
this.serverDirectory.updateServer(roomId, {
|
||||
currentOwnerId: currentUser.id,
|
||||
actingRole: 'host',
|
||||
ownerId: nextHostId,
|
||||
ownerPublicKey: nextHostOderId
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import {
|
||||
Actions,
|
||||
createEffect,
|
||||
@@ -20,7 +21,8 @@ import {
|
||||
catchError,
|
||||
withLatestFrom,
|
||||
tap,
|
||||
switchMap
|
||||
switchMap,
|
||||
filter
|
||||
} from 'rxjs/operators';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { MessagesActions } from '../messages/messages.actions';
|
||||
@@ -47,9 +49,11 @@ import {
|
||||
Room,
|
||||
User
|
||||
} from '../../shared-kernel';
|
||||
import { setStoredCurrentUserId } from '../../core/storage/current-user-storage';
|
||||
import { clearStoredCurrentUserId, setStoredCurrentUserId } from '../../core/storage/current-user-storage';
|
||||
import { findRoomMember, removeRoomMember } from '../rooms/room-members.helpers';
|
||||
import { AppI18nService } from '../../core/i18n';
|
||||
import { AuthTokenStoreService } from '../../domains/authentication/application/services/auth-token-store.service';
|
||||
import { hasValidPersistedSession, SESSION_EXPIRED_ERROR_CODE } from '../../domains/authentication/domain/logic/auth-session.rules';
|
||||
|
||||
type IncomingModerationExtraAction =
|
||||
| ReturnType<typeof RoomsActions.forgetRoom>
|
||||
@@ -68,6 +72,8 @@ export class UsersEffects {
|
||||
private serverDirectory = inject(ServerDirectoryFacade);
|
||||
private webrtc = inject(RealtimeSessionFacade);
|
||||
private readonly i18n = inject(AppI18nService);
|
||||
private readonly authTokenStore = inject(AuthTokenStoreService);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
/** Prepares persisted state for a successful login before exposing the user in-memory. */
|
||||
authenticateUser$ = createEffect(() =>
|
||||
@@ -106,6 +112,14 @@ export class UsersEffects {
|
||||
|
||||
const sanitizedUser = this.clearStartupVoiceConnection(user);
|
||||
|
||||
if (!this.hasPersistedSessionToken(sanitizedUser)) {
|
||||
clearStoredCurrentUserId();
|
||||
|
||||
return of(UsersActions.loadCurrentUserFailure({
|
||||
error: SESSION_EXPIRED_ERROR_CODE
|
||||
}));
|
||||
}
|
||||
|
||||
if (sanitizedUser === user) {
|
||||
return of(UsersActions.loadCurrentUserSuccess({ user }));
|
||||
}
|
||||
@@ -205,8 +219,6 @@ export class UsersEffects {
|
||||
return this.serverDirectory.kickServerMember(
|
||||
room.id,
|
||||
{
|
||||
actorUserId: currentUser.id,
|
||||
actorRole: currentUser.role,
|
||||
targetUserId: userId
|
||||
},
|
||||
this.toSourceSelector(room)
|
||||
@@ -287,8 +299,6 @@ export class UsersEffects {
|
||||
return this.serverDirectory.banServerMember(
|
||||
room.id,
|
||||
{
|
||||
actorUserId: currentUser.id,
|
||||
actorRole: currentUser.role,
|
||||
targetUserId: userId,
|
||||
banId: ban.oderId,
|
||||
displayName: ban.displayName,
|
||||
@@ -358,8 +368,6 @@ export class UsersEffects {
|
||||
return this.serverDirectory.unbanServerMember(
|
||||
room.id,
|
||||
{
|
||||
actorUserId: currentUser.id,
|
||||
actorRole: currentUser.role,
|
||||
banId: oderId
|
||||
},
|
||||
this.toSourceSelector(room)
|
||||
@@ -477,6 +485,24 @@ export class UsersEffects {
|
||||
{ dispatch: false }
|
||||
);
|
||||
|
||||
/** Send users back to login when their persisted session token is missing or rejected. */
|
||||
redirectOnSessionExpired$ = createEffect(
|
||||
() =>
|
||||
this.actions$.pipe(
|
||||
ofType(UsersActions.loadCurrentUserFailure),
|
||||
filter(({ error }) => error === SESSION_EXPIRED_ERROR_CODE),
|
||||
tap(() => {
|
||||
clearStoredCurrentUserId();
|
||||
void this.router.navigate(['/login'], {
|
||||
queryParams: {
|
||||
returnUrl: this.router.url
|
||||
}
|
||||
});
|
||||
})
|
||||
),
|
||||
{ dispatch: false }
|
||||
);
|
||||
|
||||
/** Keep signaling identity aligned with the current profile to avoid stale fallback names. */
|
||||
syncSignalingIdentity$ = createEffect(
|
||||
() =>
|
||||
@@ -511,6 +537,15 @@ export class UsersEffects {
|
||||
return savedRooms.find((room) => room.id === roomId) ?? null;
|
||||
}
|
||||
|
||||
private hasPersistedSessionToken(user: User): boolean {
|
||||
return hasValidPersistedSession(
|
||||
user,
|
||||
this.serverDirectory.activeServer()?.url,
|
||||
(serverUrl) => this.authTokenStore.getToken(serverUrl),
|
||||
() => this.authTokenStore.hasAnyValidToken()
|
||||
);
|
||||
}
|
||||
|
||||
private resolveDisplayName(user: Pick<User, 'displayName' | 'username'>): string {
|
||||
const displayName = user.displayName?.trim();
|
||||
|
||||
|
||||
@@ -3,7 +3,11 @@ import { defineConfig } from 'vitest/config';
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
include: ['src/**/*.spec.ts', '../electron/**/*.spec.ts'],
|
||||
include: [
|
||||
'src/**/*.spec.ts',
|
||||
'../electron/**/*.spec.ts',
|
||||
'../server/src/**/*.spec.ts'
|
||||
],
|
||||
tsconfig: './tsconfig.spec.json',
|
||||
setupFiles: ['src/test-setup.ts']
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user