diff --git a/agents-docs/FEATURES.md b/agents-docs/FEATURES.md index 3fe2b18..756def3 100644 --- a/agents-docs/FEATURES.md +++ b/agents-docs/FEATURES.md @@ -9,7 +9,9 @@ It must stay accurate as new features are introduced, renamed, merged, or remove ## Feature list (alphabetical) - [App i18n](features/app-i18n.md) — `@ngx-translate/core` localization for the product client; English-only catalog today, same stack as the marketing website. +- [Authentication](features/authentication.md) — signaling-server session tokens, protected REST/WebSocket identity, and client bearer storage. - [Custom Emoji](features/custom-emoji.md) — peer-synced user-created emoji assets, chat reaction shortcuts, and composer emoji insertion. +- [Message Integrity](features/message-integrity.md) — signed P2P message revision chains, inventory `headHash` convergence, and Ed25519 signing-key registration on the signaling server. - [Mobile Capacitor](features/mobile-capacitor.md) — Capacitor native shell, mobile infrastructure facades, and phone-specific call/chat/media integrations. - [Server Discovery](features/server-discovery.md) — featured/trending public-server REST endpoints (server) consumed by the `/dashboard` and `/servers` client pages. - [Signal Server Tag](features/signal-server-tag.md) — configurable signal-server display tag shown on profile cards for a user's registration server. diff --git a/agents-docs/LESSONS.md b/agents-docs/LESSONS.md index 1ba7246..39dbe71 100644 --- a/agents-docs/LESSONS.md +++ b/agents-docs/LESSONS.md @@ -25,6 +25,13 @@ Durable rules for AI agents working on this project. Read this file at session s ## Lessons +### Persisted local user state still requires a session token [authentication] [signaling] + +- **Trigger:** Users appear logged in from local storage but cannot see peers online or send chat after session-token auth shipped. +- **Rule:** before connecting signaling or loading rooms for a persisted user, require a non-expired token in `metoyou.authTokens`; redirect to `/login` on `SESSION_EXPIRED`, `auth_required`, or `auth_error`. +- **Why:** WebSocket `identify` is skipped without a token, so `join_server`, RTC relay, and presence never establish even though the profile exists locally. +- **Example:** `hasValidPersistedSession()` in `auth-session.rules.ts` from `loadCurrentUser$`. + ### Declare MODIFY_AUDIO_SETTINGS for Android WebRTC mic capture [mobile] [android] - **Trigger:** Android users accept the microphone prompt but voice calls and channels still fail to join. diff --git a/agents-docs/adr/0002-session-token-authentication.md b/agents-docs/adr/0002-session-token-authentication.md new file mode 100644 index 0000000..52b42ab --- /dev/null +++ b/agents-docs/adr/0002-session-token-authentication.md @@ -0,0 +1,13 @@ +# ADR-0002: Session-Token Authentication on the Signaling Server + +## Status +Accepted + +## Context +The signaling server trusted client-supplied user IDs on REST mutations and WebSocket `identify`, allowing impersonation for kicks, bans, joins, plugin administration, and push dispatch. The product client already used bearer tokens for the Electron Local API, but the shared signaling server had no equivalent binding between HTTP/WebSocket actions and a logged-in user. + +## Decision +Issue opaque session tokens on login/register, persist them in server SQLite, require `Authorization: Bearer` on all mutating REST routes, and require `identify.token` on WebSocket connections before any other client message is accepted. Actor fields (`currentOwnerId`, `actorUserId`, `requesterUserId`) are derived from the token instead of request bodies. + +## Rationale +This closes identity spoofing without changing the P2P product model: discovery stays public, chat/media still relay over WebSocket, and DM WebRTC signaling remains available across servers. Bcrypt password hashing with transparent SHA-256 upgrade preserves existing accounts. A deprecation window for body-only auth was intentionally omitted so all clients must authenticate in one release, avoiding prolonged dual-trust behavior. diff --git a/agents-docs/adr/0003-signed-message-revisions.md b/agents-docs/adr/0003-signed-message-revisions.md new file mode 100644 index 0000000..185b4a0 --- /dev/null +++ b/agents-docs/adr/0003-signed-message-revisions.md @@ -0,0 +1,23 @@ +# ADR-0003: Signed Message Revision Chains for P2P Chat Integrity + +## Status +Accepted + +## Context +P2P chat sync compared timestamps, reaction counts, and attachment counts only. A peer could rewrite history or apply edits out of order with no cryptographic check. The product has no central message store, so integrity must travel with sync traffic and local audit logs. + +## Decision +Adopt an append-only **revision chain** per message: + +- Each mutation emits a `MessageRevision` (create, edit, delete, moderation, plugin) with `revision`, `prevRevisionHash`, and `headHash` (SHA-256 over canonical head state). +- Inventories advertise `{ revision, headHash }` so peers detect gaps and hash mismatches. +- Human-authored revisions are signed with per-user Ed25519 keys; public keys are registered on the signaling server for verification. +- Legacy `chat-message` / `message-edited` / `message-deleted` events continue to broadcast alongside `message-revision` for one-release backward compatibility. + +## Rationale +Revision chains give deterministic merge (higher valid revision wins) without requiring a trusted relay. Signing binds edits to registered users while keeping chat payloads off the server. Dual emit avoids breaking peers that have not upgraded inventory or revision handlers yet. + +## Consequences +- New persistence columns and revision audit stores on browser IDB, Electron SQLite, and Capacitor schemas. +- Plugin synthetic users may emit unsigned revisions until a plugin signing model exists. +- Attachment byte integrity (SHA-256 on `file-announce`) remains a separate follow-up. diff --git a/agents-docs/features/authentication.md b/agents-docs/features/authentication.md new file mode 100644 index 0000000..fdbbbf1 --- /dev/null +++ b/agents-docs/features/authentication.md @@ -0,0 +1,67 @@ +# Authentication + +Session-token authentication for the signaling server and product client. + +## Trust boundaries + +| Surface | Identity proof | Notes | +|---|---|---| +| Signaling server REST (mutations) | `Authorization: Bearer ` | Actor user IDs in request bodies are ignored; server derives `authUserId` from the token | +| Signaling server REST (discovery) | None | `GET /api/servers`, featured/trending/search remain public | +| Signaling server WebSocket | `identify.token` | Connections must identify before any other message type | +| Electron Local API | Separate in-memory bearer tokens | Proxies login to allowed signaling servers only | +| Product client local DB | OS user account | SQLite and attachments are plaintext at rest | + +## Login / register response + +```json +{ + "id": "", + "username": "alice", + "displayName": "Alice", + "token": "", + "expiresAt": 1710000000000 +} +``` + +- Tokens are opaque 64-character hex strings stored in server SQLite (`session_tokens`). +- Default TTL: 24 hours (`SESSION_TOKEN_TTL_MS` env override supported). +- Passwords are stored with bcrypt; legacy SHA-256 hashes are upgraded transparently on successful login. + +## Protected REST routes + +Require `Authorization: Bearer`: + +- `PUT/POST/DELETE` under `/api/servers/*` (except public `GET`) +- `PUT /api/requests/:id` +- Plugin-support mutations under `/api/servers/:serverId/plugins/*` +- `/api/users/device-tokens/*` +- `POST /api/users/logout` + +## WebSocket identify contract + +```json +{ + "type": "identify", + "token": "", + "oderId": "", + "displayName": "Alice", + "connectionScope": "ws://host:3001" +} +``` + +- `oderId` must match the token's user id when provided. +- Server responds with `auth_error` or `auth_required` when authentication fails. + +## Client storage + +The product client stores tokens per signaling-server base URL in `localStorage` (`metoyou.authTokens`). An HTTP interceptor attaches the bearer token to `/api/*` requests targeting that server. + +Persisted local user state (`metoyou_currentUserId` + IndexedDB/SQLite profile) is **not** sufficient to use chat or presence. On startup, `loadCurrentUser$` requires a non-expired session token for the user's home/active signaling server (or any stored token as a fallback). Missing or rejected tokens dispatch `SESSION_EXPIRED` and redirect to `/login`. WebSocket `auth_required` / `auth_error` responses trigger the same path. + +## Security considerations + +- Rate limits: login/register (100 / 15 min), server join (30 / min). +- CORS allowlist: optional `corsAllowlist` in `server/data/variables.json` or `CORS_ALLOWLIST` env (comma-separated). Empty allowlist keeps permissive CORS for local development. +- Push-token routes require bearer auth and user-id match. +- RTC relay: direct-message/direct-call types always relay; server-icon types require shared server membership; WebRTC offer/answer/ice remain open for cross-server DM WebRTC. diff --git a/agents-docs/features/message-integrity.md b/agents-docs/features/message-integrity.md new file mode 100644 index 0000000..7c82c7c --- /dev/null +++ b/agents-docs/features/message-integrity.md @@ -0,0 +1,60 @@ +# Message Integrity + +Signed, append-only **message revisions** give P2P chat a verifiable history without central message storage. The materialized `Message` row in local SQLite/IDB is a cache; peers converge via inventory snapshots and revision events. + +## Responsibilities + +- **Revision chain** — Every create, edit, delete, moderation, or plugin mutation appends a `MessageRevision` with monotonically increasing `revision`, `prevRevisionHash`, and `headHash`. +- **Dual emit** — Outgoing mutations broadcast the legacy event (`chat-message`, `message-edited`, `message-deleted`) **and** `message-revision` so older peers keep working while integrity-aware peers prefer revisions. +- **Inventory** — Sync inventories include `{ id, ts, rc, ac, revision, headHash }`. Peers re-fetch when remote revision is newer or the same revision has a different hash (tamper detection). +- **Signing** — Human authors sign revisions with per-user Ed25519 keys. Public keys are registered on the signaling server; private keys stay in browser `localStorage`. + +## Boundaries + +| Layer | Owns | +| --- | --- | +| Product client (`toju-app`) | Revision construction, merge, verification, P2P broadcast, local persistence | +| Signaling server (`server`) | `PUT /api/users/me/signing-key`, `GET /api/users/:id/signing-public-key` — key directory only, no message storage | +| Electron / mobile persistence | `revision` + `headHash` on message rows; revision audit log (IDB store / SQLite meta) | + +Plugin API messages may emit unsigned revisions (`plugin-edit` / `plugin-delete`) when the actor is a synthetic plugin user. + +## Key types + +- `Message.revision`, `Message.headHash` — materialized cache fields on the shared `Message` model. +- `MessageRevision` — wire + persistence audit record (`message-revision.models.ts`). +- `MessageRevisionType` — `create`, `author-edit`, `author-delete`, `moderate-edit`, `moderate-delete`, `plugin-edit`, `plugin-delete`. +- `ChatEvent.type: 'message-revision'` — P2P envelope carrying a full `MessageRevision`. + +## Merge rules + +1. Valid signed revision with higher `revision` wins over legacy timestamp edits. +2. Same `revision`, different `headHash` → treat as stale/tampered and re-fetch. +3. Unsigned revisions (no `signature`) are accepted for backward compatibility when verification is skipped. +4. Legacy peers without `revision`/`headHash` in inventory fall back to `ts` / `rc` / `ac` comparison. + +## Client touchpoints + +- Domain rules: `message-integrity.rules.ts`, `message-revision.builder.rules.ts`, `message-sync.rules.ts` +- Services: `MessageRevisionService`, `MessageSigningService` +- Store: `messages.effects.ts` (outgoing dual-emit), `messages-incoming.handlers.ts` (`handleMessageRevision`), `messages.helpers.ts` (inventory + merge) +- Plugins: `plugin-client-api.service.ts` emits revisions for send/edit/delete + +## Server API + +| Method | Path | Auth | Body / response | +| --- | --- | --- | --- | +| `PUT` | `/api/users/me/signing-key` | Bearer | `{ publicKeyJwk }` — stores Ed25519 public JWK on the user row | +| `GET` | `/api/users/:id/signing-public-key` | Public | `{ publicKeyJwk }` — used by peers to verify signatures | + +Registration runs automatically after login/register via `AuthenticationService`. + +## Degraded-mode behavior + +- Outgoing revision signing is **best-effort**: if `Ed25519` signing fails, the client still broadcasts the legacy `chat-message` envelope (unsigned revision). +- Incoming signed revisions are accepted without cryptographic verification when the sender's public key is not yet registered on the server, so chat is not blocked during key-registration races. + +## Testing + +- Unit: `message-integrity.rules.spec.ts`, `message-revision.builder.rules.spec.ts`, `message-revision-signing.rules.spec.ts`, `message-sync.rules.spec.ts`, `messages-incoming.handlers.spec.ts` +- Outgoing revision wiring is covered indirectly through existing message effect tests; add focused specs when changing merge or signing behavior. diff --git a/e2e/helpers/auth-api.ts b/e2e/helpers/auth-api.ts new file mode 100644 index 0000000..75d85d2 --- /dev/null +++ b/e2e/helpers/auth-api.ts @@ -0,0 +1,75 @@ +import { type APIRequestContext, type Page } from '@playwright/test'; + +export const AUTH_TOKENS_STORAGE_KEY = 'metoyou.authTokens'; + +export interface AuthSession { + id: string; + username: string; + displayName: string; + token: string; + expiresAt: number; +} + +export function authHeaders(token: string): Record { + return { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json' + }; +} + +export async function registerTestUser( + request: APIRequestContext, + baseUrl: string, + username: string, + password: string, + displayName?: string +): Promise { + const response = await request.post(`${baseUrl}/api/users/register`, { + data: { + username, + password, + displayName: displayName ?? username + } + }); + + if (!response.ok()) { + throw new Error(`Failed to register test user ${username}: ${response.status()} ${await response.text()}`); + } + + return await response.json() as AuthSession; +} + +export async function loginTestUser( + request: APIRequestContext, + baseUrl: string, + username: string, + password: string +): Promise { + const response = await request.post(`${baseUrl}/api/users/login`, { + data: { username, password } + }); + + if (!response.ok()) { + throw new Error(`Failed to login test user ${username}: ${response.status()} ${await response.text()}`); + } + + return await response.json() as AuthSession; +} + +export async function readAuthTokenFromPage(page: Page, serverUrl: string): Promise { + return await page.evaluate(({ storageKey, url }) => { + try { + const store = JSON.parse(localStorage.getItem(storageKey) || '{}') as Record; + const normalizedUrl = url.trim().replace(/\/+$/, ''); + const entry = store[normalizedUrl]; + + if (!entry || entry.expiresAt <= Date.now()) { + return null; + } + + return entry.token; + } catch { + return null; + } + }, { storageKey: AUTH_TOKENS_STORAGE_KEY, url: serverUrl }); +} diff --git a/e2e/helpers/start-test-server.js b/e2e/helpers/start-test-server.js index a7c2e0e..380eb90 100644 --- a/e2e/helpers/start-test-server.js +++ b/e2e/helpers/start-test-server.js @@ -7,16 +7,19 @@ * * Cleanup: the temp directory is removed when the process exits. */ -const { mkdtempSync, writeFileSync, mkdirSync, rmSync } = require('fs'); +const { existsSync, mkdtempSync, writeFileSync, mkdirSync, rmSync } = require('fs'); const { join } = require('path'); const { tmpdir } = require('os'); const { spawn } = require('child_process'); const TEST_PORT = process.env.TEST_SERVER_PORT || '3099'; const SERVER_DIR = join(__dirname, '..', '..', 'server'); -const SERVER_ENTRY = join(SERVER_DIR, 'src', 'index.ts'); +const SERVER_DIST_ENTRY = join(SERVER_DIR, 'dist', 'index.js'); +const SERVER_SRC_ENTRY = join(SERVER_DIR, 'src', 'index.ts'); const SERVER_TSCONFIG = join(SERVER_DIR, 'tsconfig.json'); const TS_NODE_BIN = join(SERVER_DIR, 'node_modules', 'ts-node', 'dist', 'bin.js'); +const SERVER_ENTRY = existsSync(SERVER_DIST_ENTRY) ? SERVER_DIST_ENTRY : SERVER_SRC_ENTRY; +const USE_COMPILED_SERVER = SERVER_ENTRY === SERVER_DIST_ENTRY; // ── Create isolated temp data directory ────────────────────────────── const tmpDir = mkdtempSync(join(tmpdir(), 'metoyou-e2e-')); @@ -45,7 +48,7 @@ console.log(`[E2E Server] Starting on port ${TEST_PORT}...`); // and node_modules are found from the real server/ directory. const child = spawn( process.execPath, - [TS_NODE_BIN, '--project', SERVER_TSCONFIG, SERVER_ENTRY], + USE_COMPILED_SERVER ? [SERVER_ENTRY] : [TS_NODE_BIN, '--project', SERVER_TSCONFIG, SERVER_ENTRY], { cwd: tmpDir, env: { diff --git a/e2e/pages/chat-room.page.ts b/e2e/pages/chat-room.page.ts index 88067fe..198676b 100644 --- a/e2e/pages/chat-room.page.ts +++ b/e2e/pages/chat-room.page.ts @@ -317,13 +317,22 @@ export class ChatRoomPage { throw new Error('Missing room, user, or endpoint when persisting channels'); } + const authTokens = JSON.parse(localStorage.getItem('metoyou.authTokens') || '{}') as Record; + const normalizedApiUrl = apiBaseUrl.trim().replace(/\/+$/, ''); + const authEntry = authTokens[normalizedApiUrl]; + const authToken = authEntry && authEntry.expiresAt > Date.now() ? authEntry.token : null; + + if (!authToken) { + throw new Error('Missing session token for channel persistence'); + } + const response = await fetch(`${apiBaseUrl}/api/servers/${room.id}`, { method: 'PUT', headers: { - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', + Authorization: `Bearer ${authToken}` }, body: JSON.stringify({ - currentOwnerId: currentUser.id, channels: nextChannels }) }); diff --git a/e2e/tests/plugins/plugin-support-api.spec.ts b/e2e/tests/plugins/plugin-support-api.spec.ts index 4167f9f..5cad2d7 100644 --- a/e2e/tests/plugins/plugin-support-api.spec.ts +++ b/e2e/tests/plugins/plugin-support-api.spec.ts @@ -1,6 +1,11 @@ import type { APIRequestContext, APIResponse } from '@playwright/test'; import WebSocket from 'ws'; import { expect, test } from '../../fixtures/multi-client'; +import { + authHeaders, + registerTestUser, + type AuthSession +} from '../../helpers/auth-api'; import { getPluginApiTestEvent, readPluginApiTestManifest, @@ -9,8 +14,6 @@ import { TEST_PLUGIN_RELAY_EVENT } from '../../helpers/plugin-api-test-fixture'; -const OWNER_USER_ID = 'plugin-api-owner'; - interface CreatedServerResponse { id: string; } @@ -54,10 +57,25 @@ interface TestSocket { test.describe('Plugin support API', () => { test('covers plugin requirement, event, data, and websocket APIs with the fixture plugin', async ({ request, testServer }) => { const manifest = await readPluginApiTestManifest(); - const server = await createServer(request, testServer.url, `Plugin API ${Date.now()}`); + const owner = await registerTestUser( + request, + testServer.url, + `plugin-owner-${Date.now()}`, + 'TestPass123!', + 'Plugin Owner' + ); + const peer = await registerTestUser( + request, + testServer.url, + `plugin-peer-${Date.now()}`, + 'TestPass123!', + 'Plugin Peer' + ); + const server = await createServer(request, testServer.url, owner, `Plugin API ${Date.now()}`); const relayEvent = getPluginApiTestEvent(manifest, TEST_PLUGIN_RELAY_EVENT); const p2pEvent = getPluginApiTestEvent(manifest, TEST_PLUGIN_P2P_EVENT); const pluginsApi = `${testServer.url}/api/servers/${encodeURIComponent(server.id)}/plugins`; + const ownerHeaders = authHeaders(owner.token); await test.step('Initial snapshot is empty', async () => { const snapshot = await expectJson(await request.get(pluginsApi)); @@ -71,8 +89,8 @@ test.describe('Plugin support API', () => { await test.step('Requirement API enforces server management permission', async () => { const response = await request.put(`${pluginsApi}/${TEST_PLUGIN_ID}/requirement`, { + headers: authHeaders(peer.token), data: { - actorUserId: 'not-the-owner', status: 'required' } }); @@ -83,8 +101,8 @@ test.describe('Plugin support API', () => { await test.step('Requirement and event definition APIs persist the test plugin contract', async () => { const requirement = await expectJson(await request.put(`${pluginsApi}/${TEST_PLUGIN_ID}/requirement`, { + headers: ownerHeaders, data: { - actorUserId: OWNER_USER_ID, reason: manifest.description, status: 'required', versionRange: `^${manifest.version}` @@ -98,8 +116,8 @@ test.describe('Plugin support API', () => { versionRange: `^${manifest.version}` })); - const relayDefinition = await upsertEventDefinition(request, pluginsApi, relayEvent); - const p2pDefinition = await upsertEventDefinition(request, pluginsApi, p2pEvent); + const relayDefinition = await upsertEventDefinition(request, pluginsApi, ownerHeaders, relayEvent); + const p2pDefinition = await upsertEventDefinition(request, pluginsApi, ownerHeaders, p2pEvent); expect(relayDefinition.eventDefinition).toEqual(expect.objectContaining({ direction: 'serverRelay', @@ -123,8 +141,8 @@ test.describe('Plugin support API', () => { await test.step('Plugin data API refuses arbitrary server persistence', async () => { const stored = await expectJson<{ errorCode: string }>(await request.put(`${pluginsApi}/${TEST_PLUGIN_ID}/data/settings`, { + headers: ownerHeaders, data: { - actorUserId: OWNER_USER_ID, schemaVersion: 1, scope: 'server', value: { @@ -140,15 +158,15 @@ test.describe('Plugin support API', () => { params: { key: 'settings', scope: 'server', - userId: OWNER_USER_ID + userId: owner.id } }), 410); expect(listed.errorCode).toBe('PLUGIN_DATA_DISABLED'); const afterDelete = await expectJson<{ errorCode: string }>(await request.delete(`${pluginsApi}/${TEST_PLUGIN_ID}/data/settings`, { + headers: ownerHeaders, data: { - actorUserId: OWNER_USER_ID, scope: 'server' } }), 410); @@ -161,8 +179,8 @@ test.describe('Plugin support API', () => { const bob = await openTestSocket(testServer.url); try { - alice.send({ type: 'identify', oderId: OWNER_USER_ID, displayName: 'Plugin Owner' }); - bob.send({ type: 'identify', oderId: 'plugin-api-peer', displayName: 'Plugin Peer' }); + await identifySocket(alice, owner.token, 'Plugin Owner'); + await identifySocket(bob, peer.token, 'Plugin Peer'); alice.send({ type: 'join_server', serverId: server.id }); bob.send({ type: 'join_server', serverId: server.id }); @@ -193,7 +211,7 @@ test.describe('Plugin support API', () => { pluginId: TEST_PLUGIN_ID, serverId: server.id, sourcePluginUserId: 'fixture-plugin-user', - sourceUserId: OWNER_USER_ID + sourceUserId: owner.id })); expect(relayedEvent['payload']).toEqual({ message: 'hello from fixture plugin' }); @@ -237,15 +255,15 @@ test.describe('Plugin support API', () => { await test.step('Delete APIs remove event definitions and requirements', async () => { await expectJson<{ ok: boolean }>(await request.delete(`${pluginsApi}/${TEST_PLUGIN_ID}/events/${TEST_PLUGIN_RELAY_EVENT}`, { - data: { actorUserId: OWNER_USER_ID } + headers: ownerHeaders })); await expectJson<{ ok: boolean }>(await request.delete(`${pluginsApi}/${TEST_PLUGIN_ID}/events/${TEST_PLUGIN_P2P_EVENT}`, { - data: { actorUserId: OWNER_USER_ID } + headers: ownerHeaders })); await expectJson<{ ok: boolean }>(await request.delete(`${pluginsApi}/${TEST_PLUGIN_ID}/requirement`, { - data: { actorUserId: OWNER_USER_ID } + headers: ownerHeaders })); const snapshot = await expectJson(await request.get(pluginsApi)); @@ -259,9 +277,11 @@ test.describe('Plugin support API', () => { async function createServer( request: APIRequestContext, baseUrl: string, + owner: AuthSession, serverName: string ): Promise { const response = await request.post(`${baseUrl}/api/servers`, { + headers: authHeaders(owner.token), data: { channels: [ { @@ -275,7 +295,7 @@ async function createServer( id: `plugin-api-${Date.now()}`, isPrivate: false, name: serverName, - ownerId: OWNER_USER_ID, + ownerId: owner.id, ownerPublicKey: 'plugin-api-owner-public-key', tags: ['plugins'] } @@ -287,13 +307,14 @@ async function createServer( async function upsertEventDefinition( request: APIRequestContext, pluginsApi: string, + headers: Record, eventDefinition: ReturnType ): Promise { return await expectJson(await request.put( `${pluginsApi}/${TEST_PLUGIN_ID}/events/${encodeURIComponent(eventDefinition.eventName)}`, { + headers, data: { - actorUserId: OWNER_USER_ID, direction: eventDefinition.direction, maxPayloadBytes: eventDefinition.maxPayloadBytes, schemaJson: '{"type":"object"}', @@ -309,6 +330,20 @@ async function expectJson(response: APIResponse, status = 200): Promise { return await response.json() as T; } +async function identifySocket(socket: TestSocket, token: string, displayName: string): Promise { + socket.send({ type: 'identify', token, displayName }); + + await new Promise((resolve) => { + setTimeout(resolve, 300); + }); + + const authError = socket.messages.find((message) => message.type === 'auth_error'); + + if (authError) { + throw new Error(`WebSocket identify failed: ${JSON.stringify(authError)}`); + } +} + async function openTestSocket(baseUrl: string): Promise { const socketUrl = baseUrl.replace(/^http/, 'ws'); const socket = new WebSocket(socketUrl); diff --git a/e2e/tests/voice/mixed-signal-config-voice.spec.ts b/e2e/tests/voice/mixed-signal-config-voice.spec.ts index 8addd1b..1472891 100644 --- a/e2e/tests/voice/mixed-signal-config-voice.spec.ts +++ b/e2e/tests/voice/mixed-signal-config-voice.spec.ts @@ -1,4 +1,8 @@ -import { expect, type Page } from '@playwright/test'; +import { + expect, + type APIRequestContext, + type Page +} from '@playwright/test'; import { test, type Client } from '../../fixtures/multi-client'; import { installTestServerEndpoints, type SeededEndpointInput } from '../../helpers/seed-test-endpoint'; import { startTestServer } from '../../helpers/test-server'; @@ -11,6 +15,11 @@ import { waitForConnectedPeerCount, waitForPeerConnected } from '../../helpers/webrtc-helpers'; +import { + authHeaders, + readAuthTokenFromPage, + registerTestUser +} from '../../helpers/auth-api'; import { RegisterPage } from '../../pages/register.page'; import { ServerSearchPage } from '../../pages/server-search.page'; import { ChatRoomPage } from '../../pages/chat-room.page'; @@ -104,6 +113,7 @@ function endpointsForGroup( test.describe('Mixed signal-config voice', () => { test('8 users with different signal configs can voice, mute, deafen, and chat concurrently', async ({ createClient, + request, testServer }) => { test.setTimeout(720_000); @@ -144,6 +154,26 @@ test.describe('Mixed signal-config voice', () => { await test.step('Create voice room on primary and chat room on secondary', async () => { // Use a "both" user (client 0) to create both rooms const searchPage = new ServerSearchPage(clients[0].page); + const secondarySession = await registerTestUser( + request, + secondaryServer.url, + clients[0].user.username, + clients[0].user.password, + clients[0].user.displayName + ); + + await clients[0].page.evaluate(({ serverUrl, token, expiresAt }) => { + const storageKey = 'metoyou.authTokens'; + const store = JSON.parse(localStorage.getItem(storageKey) || '{}') as Record; + const normalizedUrl = serverUrl.trim().replace(/\/+$/, ''); + + store[normalizedUrl] = { token, expiresAt }; + localStorage.setItem(storageKey, JSON.stringify(store)); + }, { + serverUrl: secondaryServer.url, + token: secondarySession.token, + expiresAt: secondarySession.expiresAt + }); await searchPage.createServer(VOICE_ROOM_NAME, { description: 'Voice room on primary signal', @@ -152,12 +182,14 @@ test.describe('Mixed signal-config voice', () => { await expect(clients[0].page).toHaveURL(/\/room\//, { timeout: 20_000 }); - await searchPage.createServer(SECONDARY_ROOM_NAME, { - description: 'Chat room on secondary signal', - sourceId: SECONDARY_SIGNAL_ID - }); + const secondaryRoom = await createServerViaApi( + request, + secondaryServer.url, + secondarySession, + SECONDARY_ROOM_NAME + ); - await expect(clients[0].page).toHaveURL(/\/room\//, { timeout: 20_000 }); + secondaryRoomId = secondaryRoom.id; }); // ── Create invite links ───────────────────────────────────── @@ -166,31 +198,39 @@ test.describe('Mixed signal-config voice', () => { // Group D (secondary-only) needs invite to primary room. let primaryRoomInviteUrl: string; let secondaryRoomInviteUrl: string; + let secondaryRoomId = ''; await test.step('Create invite links for cross-signal rooms', async () => { // Navigate to voice room to get its ID await openSavedRoomByName(clients[0].page, VOICE_ROOM_NAME); const primaryRoomId = await getCurrentRoomId(clients[0].page); - const userId = await getCurrentUserId(clients[0].page); - - // Navigate to secondary room to get its ID - await openSavedRoomByName(clients[0].page, SECONDARY_ROOM_NAME); - const secondaryRoomId = await getCurrentRoomId(clients[0].page); // Create invite for primary room (voice) via API + const primaryToken = await readAuthTokenFromPage(clients[0].page, testServer.url); + + if (!primaryToken) { + throw new Error('Missing session token for primary signal invite creation'); + } + const primaryInvite = await createInviteViaApi( testServer.url, primaryRoomId, - userId, + primaryToken, clients[0].user.displayName ); primaryRoomInviteUrl = `/invite/${primaryInvite.id}?server=${encodeURIComponent(testServer.url)}`; // Create invite for secondary room (chat) via API + const secondaryToken = await readAuthTokenFromPage(clients[0].page, secondaryServer.url); + + if (!secondaryToken) { + throw new Error('Missing session token for secondary signal invite creation'); + } + const secondaryInvite = await createInviteViaApi( secondaryServer.url, secondaryRoomId, - userId, + secondaryToken, clients[0].user.displayName ); @@ -463,17 +503,55 @@ function buildUsers(): TestUser[] { // ── API helpers ────────────────────────────────────────────────────── +async function createServerViaApi( + request: APIRequestContext, + serverBaseUrl: string, + owner: { id: string; token: string }, + serverName: string +): Promise<{ id: string }> { + const response = await request.post(`${serverBaseUrl}/api/servers`, { + headers: authHeaders(owner.token), + data: { + channels: [ + { + id: 'general-text', + name: 'general', + position: 0, + type: 'text' + } + ], + description: `E2E room on ${serverBaseUrl}`, + id: `mixed-signal-${Date.now()}-${Math.random() + .toString(36) + .slice(2, 8)}`, + isPrivate: false, + name: serverName, + ownerId: owner.id, + ownerPublicKey: 'mixed-signal-owner-public-key', + tags: ['e2e'] + } + }); + + if (!response.ok()) { + throw new Error(`Failed to create server via API: ${response.status()} ${await response.text()}`); + } + + return await response.json() as { id: string }; +} + async function createInviteViaApi( serverBaseUrl: string, roomId: string, - userId: string, + authToken: string, displayName: string ): Promise<{ id: string }> { const response = await fetch(`${serverBaseUrl}/api/servers/${roomId}/invites`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${authToken}` + }, body: JSON.stringify({ - requesterUserId: userId, requesterDisplayName: displayName }) }); @@ -510,34 +588,6 @@ async function getCurrentRoomId(page: Page): Promise { }); } -async function getCurrentUserId(page: Page): Promise { - return await page.evaluate(() => { - interface AngularDebugApi { - getComponent: (element: Element) => Record; - } - - interface UserShape { - id: string; - } - - const host = document.querySelector('app-rooms-side-panel'); - const debugApi = (window as { ng?: AngularDebugApi }).ng; - - if (!host || !debugApi?.getComponent) { - throw new Error('Angular debug API unavailable'); - } - - const component = debugApi.getComponent(host); - const user = (component['currentUser'] as (() => UserShape | null) | undefined)?.(); - - if (!user?.id) { - throw new Error('Current user not found'); - } - - return user.id; - }); -} - // ── Navigation helpers ─────────────────────────────────────────────── async function installDeterministicVoiceSettings(page: Page): Promise { diff --git a/electron/cqrs/commands/handlers/saveMessage.ts b/electron/cqrs/commands/handlers/saveMessage.ts index d4f87b0..72385d1 100644 --- a/electron/cqrs/commands/handlers/saveMessage.ts +++ b/electron/cqrs/commands/handlers/saveMessage.ts @@ -20,6 +20,8 @@ export async function handleSaveMessage(command: SaveMessageCommand, dataSource: content: message.content, timestamp: message.timestamp, editedAt: message.editedAt ?? null, + revision: message.revision ?? 0, + headHash: message.headHash ?? null, isDeleted: message.isDeleted ? 1 : 0, replyToId: message.replyToId ?? null, linkMetadata: message.linkMetadata ? JSON.stringify(message.linkMetadata) : null diff --git a/electron/cqrs/commands/handlers/updateMessage.ts b/electron/cqrs/commands/handlers/updateMessage.ts index e53fd20..cb917d1 100644 --- a/electron/cqrs/commands/handlers/updateMessage.ts +++ b/electron/cqrs/commands/handlers/updateMessage.ts @@ -36,7 +36,8 @@ export async function handleUpdateMessage(command: UpdateMessageCommand, dataSou const nullableFields = [ 'channelId', 'editedAt', - 'replyToId' + 'replyToId', + 'headHash' ] as const; for (const field of nullableFields) { @@ -44,8 +45,13 @@ export async function handleUpdateMessage(command: UpdateMessageCommand, dataSou entity[field] = updates[field] ?? null; } - if (updates.isDeleted !== undefined) + if (updates.revision !== undefined) { + existing.revision = updates.revision; + } + + if (updates.isDeleted !== undefined) { existing.isDeleted = updates.isDeleted ? 1 : 0; + } if (updates.linkMetadata !== undefined) existing.linkMetadata = updates.linkMetadata ? JSON.stringify(updates.linkMetadata) : null; diff --git a/electron/cqrs/mappers.ts b/electron/cqrs/mappers.ts index 8ceaccf..d5ff2b8 100644 --- a/electron/cqrs/mappers.ts +++ b/electron/cqrs/mappers.ts @@ -34,6 +34,8 @@ export function rowToMessage(row: MessageEntity, reactions: ReactionPayload[] = content: isDeleted ? DELETED_MESSAGE_CONTENT : row.content, timestamp: row.timestamp, editedAt: row.editedAt ?? undefined, + revision: row.revision ?? 0, + headHash: row.headHash ?? undefined, reactions: isDeleted ? [] : reactions, isDeleted, replyToId: row.replyToId ?? undefined, diff --git a/electron/cqrs/types.ts b/electron/cqrs/types.ts index eb5238d..b8487f7 100644 --- a/electron/cqrs/types.ts +++ b/electron/cqrs/types.ts @@ -57,6 +57,8 @@ export interface MessagePayload { content: string; timestamp: number; editedAt?: number; + revision?: number; + headHash?: string; reactions?: ReactionPayload[]; isDeleted?: boolean; replyToId?: string; diff --git a/electron/entities/MessageEntity.ts b/electron/entities/MessageEntity.ts index 26810b3..ad9839d 100644 --- a/electron/entities/MessageEntity.ts +++ b/electron/entities/MessageEntity.ts @@ -41,4 +41,10 @@ export class MessageEntity { @Column('text', { nullable: true }) linkMetadata!: string | null; + + @Column('integer', { default: 0 }) + revision!: number; + + @Column('text', { nullable: true }) + headHash!: string | null; } diff --git a/electron/idle/idle-monitor.spec.ts b/electron/idle/idle-monitor.spec.ts index f548ebd..856c573 100644 --- a/electron/idle/idle-monitor.spec.ts +++ b/electron/idle/idle-monitor.spec.ts @@ -7,13 +7,24 @@ import { afterEach } from 'vitest'; -// Mock Electron modules before importing the module under test -const mockGetSystemIdleTime = vi.fn(() => 0); -const mockSend = vi.fn(); -const mockGetMainWindow = vi.fn(() => ({ - isDestroyed: () => false, - webContents: { send: mockSend } -})); +const { + mockGetSystemIdleTime, + mockSend, + mockGetMainWindow +} = vi.hoisted(() => { + const send = vi.fn(); + const getSystemIdleTime = vi.fn(() => 0); + const getMainWindow = vi.fn(() => ({ + isDestroyed: () => false, + webContents: { send } + })); + + return { + mockGetSystemIdleTime: getSystemIdleTime, + mockSend: send, + mockGetMainWindow: getMainWindow + }; +}); vi.mock('electron', () => ({ powerMonitor: { diff --git a/electron/ipc/system.ts b/electron/ipc/system.ts index a84dc95..1c07db0 100644 --- a/electron/ipc/system.ts +++ b/electron/ipc/system.ts @@ -61,6 +61,8 @@ import { import { listRunningProcessNames } from '../process-list'; import { detectActiveGame } from '../game-detection'; import { collectAppMetricsSnapshot } from '../app-metrics'; +import { clearAllTokens } from '../api/auth-store'; +import { assertPathUnderUserData, grantPluginReadRoot, resolveReadablePath } from '../path-jail'; const DEFAULT_MIME_TYPE = 'application/octet-stream'; const MAX_ACTIVE_DESKTOP_NOTIFICATIONS = 20; @@ -72,6 +74,19 @@ const FILE_CLIPBOARD_FORMATS = [ 'public.file-url', 'FileNameW' ] as const; + +async function resolveUserDataFilePath(filePath: string): Promise { + return await resolveReadablePath(filePath); +} + +async function resolveWritableUserDataFilePath(filePath: string): Promise { + try { + return await assertPathUnderUserData(filePath); + } catch { + return null; + } +} + const MIME_TYPES_BY_EXTENSION: Record = { '.7z': 'application/x-7z-compressed', '.aac': 'audio/aac', @@ -496,6 +511,10 @@ export function setupSystemHandlers(): void { ipcMain.handle('set-desktop-settings', async (_event, patch: Partial) => { const snapshot = updateDesktopSettings(patch); + if (Object.prototype.hasOwnProperty.call(patch, 'allowedSignalingServers')) { + clearAllTokens(); + } + await synchronizeAutoStartSetting(snapshot.autoStart); updateCloseToTraySetting(snapshot.closeToTray); await handleDesktopSettingsChanged(); @@ -565,6 +584,12 @@ export function setupSystemHandlers(): void { return false; } + const scopedDestination = await resolveWritableUserDataFilePath(destinationFilePath); + + if (!scopedDestination) { + return false; + } + try { const stats = await fsp.stat(sourceFilePath); @@ -572,7 +597,7 @@ export function setupSystemHandlers(): void { return false; } - await fsp.copyFile(sourceFilePath, destinationFilePath); + await fsp.copyFile(sourceFilePath, scopedDestination); return true; } catch { return false; @@ -580,8 +605,14 @@ export function setupSystemHandlers(): void { }); ipcMain.handle('file-exists', async (_event, filePath: string) => { + const scopedPath = await resolveUserDataFilePath(filePath); + + if (!scopedPath) { + return false; + } + try { - await fsp.access(filePath, fs.constants.F_OK); + await fsp.access(scopedPath, fs.constants.F_OK); return true; } catch { return false; @@ -589,26 +620,40 @@ export function setupSystemHandlers(): void { }); ipcMain.handle('get-file-url', async (_event, filePath: string) => { - if (typeof filePath !== 'string' || !filePath.trim()) { + const scopedPath = await resolveUserDataFilePath(filePath); + + if (!scopedPath) { return null; } try { - await fsp.access(filePath, fs.constants.F_OK); - return pathToFileURL(filePath).toString(); + await fsp.access(scopedPath, fs.constants.F_OK); + return pathToFileURL(scopedPath).toString(); } catch { return null; } }); ipcMain.handle('read-file', async (_event, filePath: string) => { - const data = await fsp.readFile(filePath); + const scopedPath = await resolveUserDataFilePath(filePath); + + if (!scopedPath) { + return null; + } + + const data = await fsp.readFile(scopedPath); return data.toString('base64'); }); ipcMain.handle('read-file-chunk', async (_event, filePath: string, start: number, end: number) => { - const fileHandle = await fsp.open(filePath, 'r'); + const scopedPath = await resolveUserDataFilePath(filePath); + + if (!scopedPath) { + return null; + } + + const fileHandle = await fsp.open(scopedPath, 'r'); try { const safeStart = Math.max(0, Math.trunc(start)); @@ -623,7 +668,13 @@ export function setupSystemHandlers(): void { }); ipcMain.handle('get-file-size', async (_event, filePath: string) => { - const stats = await fsp.stat(filePath); + const scopedPath = await resolveUserDataFilePath(filePath); + + if (!scopedPath) { + return null; + } + + const stats = await fsp.stat(scopedPath); return stats.size; }); @@ -632,23 +683,47 @@ export function setupSystemHandlers(): void { return await readClipboardFiles(); }); + ipcMain.handle('grant-plugin-read-root', (_event, rootPath: string) => { + grantPluginReadRoot(rootPath); + + return true; + }); + ipcMain.handle('write-file', async (_event, filePath: string, base64Data: string) => { + const scopedPath = await resolveWritableUserDataFilePath(filePath); + + if (!scopedPath) { + return false; + } + const buffer = Buffer.from(base64Data, 'base64'); - await fsp.writeFile(filePath, buffer); + await fsp.writeFile(scopedPath, buffer); return true; }); ipcMain.handle('append-file', async (_event, filePath: string, base64Data: string) => { + const scopedPath = await resolveWritableUserDataFilePath(filePath); + + if (!scopedPath) { + return false; + } + const buffer = Buffer.from(base64Data, 'base64'); - await fsp.appendFile(filePath, buffer); + await fsp.appendFile(scopedPath, buffer); return true; }); ipcMain.handle('delete-file', async (_event, filePath: string) => { + const scopedPath = await resolveWritableUserDataFilePath(filePath); + + if (!scopedPath) { + return false; + } + try { - await fsp.unlink(filePath); + await fsp.unlink(scopedPath); return true; } catch (error) { if ((error as { code?: string }).code === 'ENOENT') { @@ -683,7 +758,14 @@ export function setupSystemHandlers(): void { cancelled: false }; } - const stats = await fsp.stat(sourceFilePath); + const scopedSourcePath = await resolveUserDataFilePath(sourceFilePath); + + if (!scopedSourcePath) { + return { saved: false, + cancelled: false }; + } + + const stats = await fsp.stat(scopedSourcePath); if (!stats.isFile()) { return { saved: false, @@ -691,7 +773,7 @@ export function setupSystemHandlers(): void { } const result = await dialog.showSaveDialog({ - defaultPath: defaultFileName || path.basename(sourceFilePath) + defaultPath: defaultFileName || path.basename(scopedSourcePath) }); if (result.canceled || !result.filePath) { @@ -699,7 +781,7 @@ export function setupSystemHandlers(): void { cancelled: true }; } - await fsp.copyFile(sourceFilePath, result.filePath); + await fsp.copyFile(scopedSourcePath, result.filePath); return { saved: true, cancelled: false }; @@ -711,15 +793,22 @@ export function setupSystemHandlers(): void { reason: 'missing-path' }; } + const scopedPath = await resolveUserDataFilePath(filePath); + + if (!scopedPath) { + return { opened: false, + reason: 'outside-app-data' }; + } + try { - const stats = await fsp.stat(filePath); + const stats = await fsp.stat(scopedPath); if (!stats.isFile()) { return { opened: false, reason: 'not-a-file' }; } - const error = await shell.openPath(filePath); + const error = await shell.openPath(scopedPath); return error ? { opened: false, @@ -732,7 +821,13 @@ export function setupSystemHandlers(): void { }); ipcMain.handle('ensure-dir', async (_event, dirPath: string) => { - await fsp.mkdir(dirPath, { recursive: true }); + const scopedPath = await resolveWritableUserDataFilePath(dirPath); + + if (!scopedPath) { + return false; + } + + await fsp.mkdir(scopedPath, { recursive: true }); return true; }); diff --git a/electron/migrations/1000000000013-MessageIntegrity.ts b/electron/migrations/1000000000013-MessageIntegrity.ts new file mode 100644 index 0000000..2a8062d --- /dev/null +++ b/electron/migrations/1000000000013-MessageIntegrity.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class MessageIntegrity1000000000013 implements MigrationInterface { + name = 'MessageIntegrity1000000000013'; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE "messages" ADD COLUMN "revision" integer NOT NULL DEFAULT 0'); + await queryRunner.query('ALTER TABLE "messages" ADD COLUMN "headHash" text'); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE "messages" DROP COLUMN "headHash"'); + await queryRunner.query('ALTER TABLE "messages" DROP COLUMN "revision"'); + } +} diff --git a/electron/path-jail.spec.ts b/electron/path-jail.spec.ts new file mode 100644 index 0000000..68934fc --- /dev/null +++ b/electron/path-jail.spec.ts @@ -0,0 +1,70 @@ +import { + describe, + it, + expect, + beforeEach, + afterEach +} from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + assertPathUnderRoot, + clearGrantedPluginReadRoots, + grantPluginReadRoot, + resolveReadablePath +} from './path-jail'; + +describe('path-jail', () => { + let tempRoot = ''; + + beforeEach(() => { + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'metoyou-path-jail-')); + fs.mkdirSync(path.join(tempRoot, 'server', 'room-1'), { recursive: true }); + fs.writeFileSync(path.join(tempRoot, 'server', 'room-1', 'file.txt'), 'ok'); + }); + + afterEach(() => { + clearGrantedPluginReadRoots(); + fs.rmSync(tempRoot, { recursive: true, force: true }); + }); + + it('accepts paths inside allowed subdirectories', async () => { + const allowedPath = path.join(tempRoot, 'server', 'room-1', 'file.txt'); + + await expect(assertPathUnderRoot(tempRoot, allowedPath, ['server'])).resolves.toBe(allowedPath); + }); + + it('accepts cached plugin bundle paths under plugin-bundles', async () => { + const bundleDir = path.join(tempRoot, 'plugin-bundles', 'example.plugin', '1.0.0'); + fs.mkdirSync(bundleDir, { recursive: true }); + const bundlePath = path.join(bundleDir, 'main.js'); + fs.writeFileSync(bundlePath, 'export default {}'); + + await expect(assertPathUnderRoot(tempRoot, bundlePath)).resolves.toBe(bundlePath); + }); + + it('rejects paths outside the user data root', async () => { + const outsidePath = path.join(os.tmpdir(), 'outside.txt'); + + await expect(assertPathUnderRoot(tempRoot, outsidePath, ['server'])).rejects.toThrow('outside allowed app-data paths'); + }); + + it('rejects paths outside allowed subdirectories', async () => { + const pluginsPath = path.join(tempRoot, 'plugins', 'evil.txt'); + + await expect(assertPathUnderRoot(tempRoot, pluginsPath, ['server'])).rejects.toThrow('outside allowed app-data paths'); + }); + + it('allows user-granted plugin source roots outside app data', async () => { + const externalRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'metoyou-plugin-source-')); + const manifestPath = path.join(externalRoot, 'plugin-source.json'); + fs.writeFileSync(manifestPath, '{}'); + + grantPluginReadRoot(externalRoot); + + await expect(resolveReadablePath(manifestPath)).resolves.toBe(manifestPath); + + fs.rmSync(externalRoot, { recursive: true, force: true }); + }); +}); diff --git a/electron/path-jail.ts b/electron/path-jail.ts new file mode 100644 index 0000000..2d6358d --- /dev/null +++ b/electron/path-jail.ts @@ -0,0 +1,108 @@ +import { app } from 'electron'; +import * as fsp from 'fs/promises'; +import * as path from 'path'; + +export const DEFAULT_USER_DATA_SUBDIRS = [ + 'server', + 'direct-messages', + 'plugins', + 'plugin-bundles', + 'plugin-cache', + 'themes', + 'metoyou' +] as const; + +export function isPathInside(parentPath: string, candidatePath: string): boolean { + const relativePath = path.relative(parentPath, candidatePath); + + return relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath)); +} + +async function realpathOrSelf(filePath: string): Promise { + try { + return await fsp.realpath(filePath); + } catch { + return path.resolve(filePath); + } +} + +function normalizeAllowedSubdirs(allowedSubdirs: readonly string[]): string[] { + return allowedSubdirs.map((entry) => entry.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '')).filter(Boolean); +} + +export async function assertPathUnderRoot( + rootPath: string, + candidatePath: string, + allowedSubdirs: readonly string[] = DEFAULT_USER_DATA_SUBDIRS +): Promise { + if (typeof candidatePath !== 'string' || !candidatePath.trim()) { + throw new Error('Invalid file path'); + } + + const [realRoot, realCandidate] = await Promise.all([realpathOrSelf(rootPath), realpathOrSelf(candidatePath)]); + + if (!isPathInside(realRoot, realCandidate)) { + throw new Error('Path is outside allowed app-data paths'); + } + + const relativePath = path.relative(realRoot, realCandidate).replace(/\\/g, '/'); + const [topLevelSegment] = relativePath.split('/'); + + if (!topLevelSegment || !normalizeAllowedSubdirs(allowedSubdirs).includes(topLevelSegment)) { + throw new Error('Path is outside allowed app-data paths'); + } + + return realCandidate; +} + +export async function assertPathUnderUserData( + candidatePath: string, + allowedSubdirs: readonly string[] = DEFAULT_USER_DATA_SUBDIRS +): Promise { + return assertPathUnderRoot(app.getPath('userData'), candidatePath, allowedSubdirs); +} + +const grantedPluginReadRoots = new Set(); + +export function grantPluginReadRoot(rootPath: string): void { + if (typeof rootPath !== 'string' || !rootPath.trim()) { + return; + } + + grantedPluginReadRoots.add(path.resolve(rootPath)); +} + +export function clearGrantedPluginReadRoots(): void { + grantedPluginReadRoots.clear(); +} + +async function assertPathUnderGrantedPluginRoot(candidatePath: string): Promise { + const realCandidate = await realpathOrSelf(candidatePath); + + for (const rootPath of grantedPluginReadRoots) { + const realRoot = await realpathOrSelf(rootPath); + + if (isPathInside(realRoot, realCandidate)) { + return realCandidate; + } + } + + throw new Error('Path is outside allowed app-data paths'); +} + +/** Resolves readable paths under app data or user-granted plugin source roots. */ +export async function resolveReadablePath(candidatePath: string): Promise { + if (typeof candidatePath !== 'string' || !candidatePath.trim()) { + return null; + } + + try { + return await assertPathUnderUserData(candidatePath); + } catch { + try { + return await assertPathUnderGrantedPluginRoot(candidatePath); + } catch { + return null; + } + } +} diff --git a/electron/preload.ts b/electron/preload.ts index 4ca060c..38a40a8 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -314,9 +314,10 @@ export interface ElectronAPI { relaunchApp: () => Promise; onDeepLinkReceived: (listener: (url: string) => void) => () => void; readClipboardFiles: () => Promise; - readFile: (filePath: string) => Promise; - readFileChunk: (filePath: string, start: number, end: number) => Promise; - getFileSize: (filePath: string) => Promise; + readFile: (filePath: string) => Promise; + readFileChunk: (filePath: string, start: number, end: number) => Promise; + getFileSize: (filePath: string) => Promise; + grantPluginReadRoot: (rootPath: string) => Promise; writeFile: (filePath: string, data: string) => Promise; appendFile: (filePath: string, data: string) => Promise; saveFileAs: (defaultFileName: string, data: string) => Promise<{ saved: boolean; cancelled: boolean }>; @@ -451,6 +452,7 @@ const electronAPI: ElectronAPI = { readFile: (filePath) => ipcRenderer.invoke('read-file', filePath), readFileChunk: (filePath, start, end) => ipcRenderer.invoke('read-file-chunk', filePath, start, end), getFileSize: (filePath) => ipcRenderer.invoke('get-file-size', filePath), + grantPluginReadRoot: (rootPath) => ipcRenderer.invoke('grant-plugin-read-root', rootPath), writeFile: (filePath, data) => ipcRenderer.invoke('write-file', filePath, data), appendFile: (filePath, data) => ipcRenderer.invoke('append-file', filePath, data), saveFileAs: (defaultFileName, data) => ipcRenderer.invoke('save-file-as', defaultFileName, data), diff --git a/server/CONTEXT.md b/server/CONTEXT.md index 2084e6d..3757afc 100644 --- a/server/CONTEXT.md +++ b/server/CONTEXT.md @@ -21,6 +21,7 @@ Owns the shared, internet-reachable runtime: HTTP routes for server directory / | **Server directory** | The catalog of joinable chat servers, exposed by `src/routes/servers.ts` plus invite and join-request routes. | "guild list" | | **SSRF guard** | The outbound-fetch policy enforced by `src/routes/ssrf-guard.ts` — gates link-metadata and proxy routes that fetch user-supplied URLs. | "proxy filter" | | **Variables file** | `data/variables.json` — runtime config (klipy key, server host/protocol, release manifest URL, link-preview toggle) normalized on startup. | "config", ".env" (those are separate) | +| **Session token** | Opaque bearer token issued on login/register, stored in `session_tokens`, required on mutating REST routes and WebSocket `identify`. | "API key", "JWT" | ## Relationships diff --git a/server/package-lock.json b/server/package-lock.json index b37c932..f192028 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -8,9 +8,11 @@ "name": "metoyou-server", "version": "1.0.0", "dependencies": { + "bcryptjs": "^3.0.2", "cors": "^2.8.5", "dotenv": "^17.3.1", "express": "^4.18.2", + "express-rate-limit": "^8.2.1", "reflect-metadata": "^0.2.2", "sql.js": "^1.9.0", "typeorm": "^0.3.28", @@ -21,6 +23,7 @@ "metoyou-server": "dist/index.js" }, "devDependencies": { + "@types/bcryptjs": "^2.4.6", "@types/cors": "^2.8.14", "@types/express": "^4.17.18", "@types/node": "^20.8.0", @@ -212,6 +215,13 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -538,6 +548,15 @@ ], "license": "MIT" }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -1058,6 +1077,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -1383,6 +1420,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", diff --git a/server/package.json b/server/package.json index a092ad9..b22089c 100644 --- a/server/package.json +++ b/server/package.json @@ -11,7 +11,9 @@ "dev": "ts-node-dev --respawn src/index.ts" }, "dependencies": { + "bcryptjs": "^3.0.2", "cors": "^2.8.5", + "express-rate-limit": "^8.2.1", "dotenv": "^17.3.1", "express": "^4.18.2", "reflect-metadata": "^0.2.2", @@ -21,6 +23,7 @@ "ws": "^8.14.2" }, "devDependencies": { + "@types/bcryptjs": "^2.4.6", "@types/cors": "^2.8.14", "@types/express": "^4.17.18", "@types/node": "^20.8.0", diff --git a/server/src/app.ts b/server/src/app.ts index 3023293..d435fd8 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -1,13 +1,53 @@ import express from 'express'; import cors from 'cors'; +import rateLimit from 'express-rate-limit'; import { registerRoutes } from './routes'; +import { getCorsAllowlist } from './config/variables'; + +const authRateLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 100, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Too many authentication attempts', errorCode: 'RATE_LIMITED' } +}); +const joinRateLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 30, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Too many join attempts', errorCode: 'RATE_LIMITED' } +}); + +function buildCorsOptions() { + const allowlist = getCorsAllowlist(); + + if (allowlist.length === 0) { + return {}; + } + + return { + origin(origin: string | undefined, callback: (error: Error | null, allow?: boolean) => void) { + if (!origin || allowlist.includes(origin)) { + callback(null, true); + return; + } + + callback(new Error('CORS origin not allowed')); + } + }; +} export function createApp(): express.Express { const app = express(); - app.set('trust proxy', true); - app.use(cors()); + // Trust loopback proxies only — avoids express-rate-limit ERR_ERL_PERMISSIVE_TRUST_PROXY. + app.set('trust proxy', 'loopback'); + app.use(cors(buildCorsOptions())); app.use(express.json()); + app.use('/api/users/login', authRateLimiter); + app.use('/api/users/register', authRateLimiter); + app.use('/api/servers/:id/join', joinRateLimiter); registerRoutes(app); diff --git a/server/src/config/variables.ts b/server/src/config/variables.ts index 843731a..59bb389 100644 --- a/server/src/config/variables.ts +++ b/server/src/config/variables.ts @@ -23,6 +23,7 @@ export interface ServerVariablesConfig { serverProtocol: ServerHttpProtocol; serverHost: string; serverTag: string; + corsAllowlist: string[]; linkPreview: LinkPreviewConfig; openApiDocs: OpenApiDocsConfig; } @@ -113,6 +114,17 @@ function normalizeLinkPreviewConfig(value: unknown): LinkPreviewConfig { return { enabled, cacheTtlMinutes: cacheTtl, maxCacheSizeMb: maxSize }; } +function normalizeCorsAllowlist(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + + return value + .filter((entry): entry is string => typeof entry === 'string') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); +} + function normalizeOpenApiDocsConfig(value: unknown): OpenApiDocsConfig { const raw = (value && typeof value === 'object' && !Array.isArray(value)) ? value as Record @@ -169,6 +181,7 @@ export function ensureVariablesConfig(): ServerVariablesConfig { serverProtocol: normalizeServerProtocol(remainingParsed.serverProtocol), serverHost: normalizeServerHost(remainingParsed.serverHost ?? legacyServerIpAddress), serverTag: normalizeServerTag(remainingParsed.serverTag), + corsAllowlist: normalizeCorsAllowlist(remainingParsed.corsAllowlist), linkPreview: normalizeLinkPreviewConfig(remainingParsed.linkPreview), openApiDocs: normalizeOpenApiDocsConfig(remainingParsed.openApiDocs) }; @@ -186,11 +199,23 @@ export function ensureVariablesConfig(): ServerVariablesConfig { serverProtocol: normalized.serverProtocol, serverHost: normalized.serverHost, serverTag: normalized.serverTag, + corsAllowlist: normalized.corsAllowlist, linkPreview: normalized.linkPreview, openApiDocs: normalized.openApiDocs }; } +export function getCorsAllowlist(): string[] { + if (hasEnvironmentOverride(process.env.CORS_ALLOWLIST)) { + return (process.env.CORS_ALLOWLIST ?? '') + .split(',') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + } + + return getVariablesConfig().corsAllowlist; +} + export function getVariablesConfig(): ServerVariablesConfig { return ensureVariablesConfig(); } diff --git a/server/src/cqrs/commands/handlers/updateUserPasswordHash.ts b/server/src/cqrs/commands/handlers/updateUserPasswordHash.ts new file mode 100644 index 0000000..294cfef --- /dev/null +++ b/server/src/cqrs/commands/handlers/updateUserPasswordHash.ts @@ -0,0 +1,12 @@ +import { DataSource } from 'typeorm'; +import { AuthUserEntity } from '../../../entities'; + +export async function handleUpdateUserPasswordHash( + dataSource: DataSource, + userId: string, + passwordHash: string +): Promise { + const repo = dataSource.getRepository(AuthUserEntity); + + await repo.update({ id: userId }, { passwordHash }); +} diff --git a/server/src/cqrs/commands/handlers/updateUserSigningPublicKey.ts b/server/src/cqrs/commands/handlers/updateUserSigningPublicKey.ts new file mode 100644 index 0000000..e18e5ae --- /dev/null +++ b/server/src/cqrs/commands/handlers/updateUserSigningPublicKey.ts @@ -0,0 +1,12 @@ +import { DataSource } from 'typeorm'; +import { AuthUserEntity } from '../../../entities'; + +export async function handleUpdateUserSigningPublicKey( + dataSource: DataSource, + userId: string, + signingPublicKey: string +): Promise { + const repo = dataSource.getRepository(AuthUserEntity); + + await repo.update({ id: userId }, { signingPublicKey }); +} diff --git a/server/src/cqrs/index.ts b/server/src/cqrs/index.ts index 6f83580..b4e532a 100644 --- a/server/src/cqrs/index.ts +++ b/server/src/cqrs/index.ts @@ -20,6 +20,8 @@ import { handleGetTrendingServers } from './queries/handlers/getTrendingServers' import { handleGetServerById } from './queries/handlers/getServerById'; import { handleGetJoinRequestById } from './queries/handlers/getJoinRequestById'; import { handleGetPendingRequestsForServer } from './queries/handlers/getPendingRequestsForServer'; +import { handleUpdateUserPasswordHash } from './commands/handlers/updateUserPasswordHash'; +import { handleUpdateUserSigningPublicKey } from './commands/handlers/updateUserSigningPublicKey'; export const registerUser = (user: AuthUserPayload) => handleRegisterUser({ type: CommandType.RegisterUser, payload: { user } }, getDataSource()); @@ -62,3 +64,9 @@ export const getJoinRequestById = (requestId: string) => export const getPendingRequestsForServer = (serverId: string) => handleGetPendingRequestsForServer({ type: QueryType.GetPendingRequestsForServer, payload: { serverId } }, getDataSource()); + +export const updateUserPasswordHash = (userId: string, passwordHash: string) => + handleUpdateUserPasswordHash(getDataSource(), userId, passwordHash); + +export const updateUserSigningPublicKey = (userId: string, signingPublicKey: string) => + handleUpdateUserSigningPublicKey(getDataSource(), userId, signingPublicKey); diff --git a/server/src/cqrs/mappers.ts b/server/src/cqrs/mappers.ts index 48e6302..a59b923 100644 --- a/server/src/cqrs/mappers.ts +++ b/server/src/cqrs/mappers.ts @@ -14,7 +14,8 @@ export function rowToAuthUser(row: AuthUserEntity): AuthUserPayload { username: row.username, passwordHash: row.passwordHash, displayName: row.displayName, - createdAt: row.createdAt + createdAt: row.createdAt, + signingPublicKey: row.signingPublicKey ?? null }; } diff --git a/server/src/cqrs/types.ts b/server/src/cqrs/types.ts index 69ebcf5..c8cc65a 100644 --- a/server/src/cqrs/types.ts +++ b/server/src/cqrs/types.ts @@ -28,6 +28,7 @@ export interface AuthUserPayload { passwordHash: string; displayName: string; createdAt: number; + signingPublicKey?: string | null; } export type ServerChannelType = 'text' | 'voice'; diff --git a/server/src/db/database.ts b/server/src/db/database.ts index b632409..0792cfa 100644 --- a/server/src/db/database.ts +++ b/server/src/db/database.ts @@ -21,7 +21,8 @@ import { PluginDataEntity, ServerPluginSettingsEntity, PluginUserMetadataEntity, - DeviceTokenEntity + DeviceTokenEntity, + SessionTokenEntity } from '../entities'; import { serverMigrations } from '../migrations'; import { @@ -272,7 +273,8 @@ export async function initDatabase(): Promise { PluginDataEntity, ServerPluginSettingsEntity, PluginUserMetadataEntity, - DeviceTokenEntity + DeviceTokenEntity, + SessionTokenEntity ], migrations: serverMigrations, synchronize: process.env.DB_SYNCHRONIZE === 'true', diff --git a/server/src/entities/AuthUserEntity.ts b/server/src/entities/AuthUserEntity.ts index 915fa8c..a5b2007 100644 --- a/server/src/entities/AuthUserEntity.ts +++ b/server/src/entities/AuthUserEntity.ts @@ -20,4 +20,7 @@ export class AuthUserEntity { @Column('integer') createdAt!: number; + + @Column('text', { nullable: true }) + signingPublicKey!: string | null; } diff --git a/server/src/entities/SessionTokenEntity.ts b/server/src/entities/SessionTokenEntity.ts new file mode 100644 index 0000000..449f143 --- /dev/null +++ b/server/src/entities/SessionTokenEntity.ts @@ -0,0 +1,22 @@ +import { + Entity, + PrimaryColumn, + Column, + Index +} from 'typeorm'; + +@Entity('session_tokens') +export class SessionTokenEntity { + @PrimaryColumn('text') + token!: string; + + @Index('idx_session_tokens_user_id') + @Column('text') + userId!: string; + + @Column('integer') + issuedAt!: number; + + @Column('integer') + expiresAt!: number; +} diff --git a/server/src/entities/index.ts b/server/src/entities/index.ts index 267ba76..4e75baa 100644 --- a/server/src/entities/index.ts +++ b/server/src/entities/index.ts @@ -18,3 +18,4 @@ export { PluginDataEntity } from './PluginDataEntity'; export { ServerPluginSettingsEntity } from './ServerPluginSettingsEntity'; export { PluginUserMetadataEntity } from './PluginUserMetadataEntity'; export { DeviceTokenEntity } from './DeviceTokenEntity'; +export { SessionTokenEntity } from './SessionTokenEntity'; diff --git a/server/src/index.ts b/server/src/index.ts index e9f3b91..508e5d0 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -21,6 +21,7 @@ import { ServerHttpProtocol } from './config/variables'; import { setupWebSocket } from './websocket'; +import { pruneExpiredSessionTokens } from './services/session-auth.service'; function formatHostForUrl(host: string): string { if (host.startsWith('[') || !host.includes(':')) { @@ -61,6 +62,7 @@ function buildServer(app: ReturnType, serverProtocol: ServerHt let listeningServer: ReturnType | null = null; let staleJoinRequestInterval: ReturnType | null = null; +let sessionTokenPruneInterval: ReturnType | null = null; async function bootstrap(): Promise { const variablesConfig = ensureVariablesConfig(); @@ -99,6 +101,11 @@ async function bootstrap(): Promise { .catch(err => console.error('Failed to clean up stale join requests:', err)); }, 60 * 1000); + sessionTokenPruneInterval = setInterval(() => { + pruneExpiredSessionTokens() + .catch(err => console.error('Failed to prune expired session tokens:', err)); + }, 60 * 1000); + const onListening = () => { const displayHost = formatHostForUrl(getDisplayHost(serverHost)); const wsProto = serverProtocol === 'https' ? 'wss' : 'ws'; @@ -137,6 +144,11 @@ async function gracefulShutdown(signal: string): Promise { staleJoinRequestInterval = null; } + if (sessionTokenPruneInterval) { + clearInterval(sessionTokenPruneInterval); + sessionTokenPruneInterval = null; + } + console.log(`\n[Shutdown] ${signal} received - closing database...`); if (listeningServer?.listening) { diff --git a/server/src/middleware/require-auth.ts b/server/src/middleware/require-auth.ts new file mode 100644 index 0000000..4e031a5 --- /dev/null +++ b/server/src/middleware/require-auth.ts @@ -0,0 +1,72 @@ +import '../types/express-augmentation'; +import { + NextFunction, + Request, + Response +} from 'express'; +import { consumeSessionToken } from '../services/session-auth.service'; + +function readBearerToken(req: Request): string | null { + const header = req.header('authorization'); + + if (!header || !header.toLowerCase().startsWith('bearer ')) { + return null; + } + + const token = header.slice(7).trim(); + + return token || null; +} + +export async function requireAuth(req: Request, res: Response, next: NextFunction): Promise { + const token = readBearerToken(req); + + if (!token) { + res.status(401).json({ error: 'Missing or invalid authorization token', errorCode: 'UNAUTHORIZED' }); + return; + } + + const session = await consumeSessionToken(token); + + if (!session) { + res.status(401).json({ error: 'Missing or invalid authorization token', errorCode: 'UNAUTHORIZED' }); + return; + } + + req.authToken = session.token; + req.authUserId = session.user.id; + req.authUser = session.user; + next(); +} + +export function getAuthenticatedUserId(req: Request): string { + const userId = req.authUserId; + + if (!userId) { + throw new Error('Authenticated user id missing after requireAuth'); + } + + return userId; +} + +export function rejectSpoofedUserId( + req: Request, + res: Response, + bodyUserId: unknown, + fieldName: string +): bodyUserId is string { + if (typeof bodyUserId !== 'string' || !bodyUserId.trim()) { + return false; + } + + if (bodyUserId !== req.authUserId) { + res.status(400).json({ + error: `${fieldName} must match the authenticated user`, + errorCode: 'USER_ID_MISMATCH' + }); + + return false; + } + + return true; +} diff --git a/server/src/migrations/1000000000011-SessionTokens.ts b/server/src/migrations/1000000000011-SessionTokens.ts new file mode 100644 index 0000000..175fc62 --- /dev/null +++ b/server/src/migrations/1000000000011-SessionTokens.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class SessionTokens1000000000011 implements MigrationInterface { + name = 'SessionTokens1000000000011'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "session_tokens" ( + "token" TEXT PRIMARY KEY NOT NULL, + "userId" TEXT NOT NULL, + "issuedAt" INTEGER NOT NULL, + "expiresAt" INTEGER NOT NULL + ) + `); + + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_session_tokens_user_id" ON "session_tokens" ("userId")`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS "session_tokens"`); + } +} diff --git a/server/src/migrations/1000000000012-SigningPublicKey.ts b/server/src/migrations/1000000000012-SigningPublicKey.ts new file mode 100644 index 0000000..ba8af58 --- /dev/null +++ b/server/src/migrations/1000000000012-SigningPublicKey.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class SigningPublicKey1000000000012 implements MigrationInterface { + name = 'SigningPublicKey1000000000012'; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE "users" ADD COLUMN "signingPublicKey" text'); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE "users" DROP COLUMN "signingPublicKey"'); + } +} diff --git a/server/src/migrations/index.ts b/server/src/migrations/index.ts index 3aea51e..04bad4c 100644 --- a/server/src/migrations/index.ts +++ b/server/src/migrations/index.ts @@ -9,6 +9,8 @@ import { PluginSupport1000000000007 } from './1000000000007-PluginSupport'; import { ServerPluginInstallMetadata1000000000008 } from './1000000000008-ServerPluginInstallMetadata'; import { ServerIcons1000000000009 } from './1000000000009-ServerIcons'; import { DeviceTokens1000000000010 } from './1000000000010-DeviceTokens'; +import { SessionTokens1000000000011 } from './1000000000011-SessionTokens'; +import { SigningPublicKey1000000000012 } from './1000000000012-SigningPublicKey'; export const serverMigrations = [ InitialSchema1000000000000, @@ -21,5 +23,7 @@ export const serverMigrations = [ PluginSupport1000000000007, ServerPluginInstallMetadata1000000000008, ServerIcons1000000000009, - DeviceTokens1000000000010 + DeviceTokens1000000000010, + SessionTokens1000000000011, + SigningPublicKey1000000000012 ]; diff --git a/server/src/routes/device-tokens.ts b/server/src/routes/device-tokens.ts index ce5e70a..b34eb91 100644 --- a/server/src/routes/device-tokens.ts +++ b/server/src/routes/device-tokens.ts @@ -4,6 +4,7 @@ import { listDeviceTokensForUser, upsertDeviceToken } from '../services/push-dispatch.service'; +import { requireAuth } from '../middleware/require-auth'; export interface DeviceTokenRecord { userId: string; @@ -14,6 +15,8 @@ export interface DeviceTokenRecord { const router = Router(); +router.use(requireAuth); + router.post('/', async (req, res) => { const { userId, platform, token } = req.body as Partial; @@ -21,12 +24,20 @@ router.post('/', async (req, res) => { return res.status(400).json({ error: 'Missing or invalid userId/platform/token' }); } + if (userId !== req.authUserId) { + return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' }); + } + await upsertDeviceToken({ userId, platform, token }); res.status(201).json({ ok: true }); }); router.get('/:userId', async (req, res) => { + if (req.params.userId !== req.authUserId) { + return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' }); + } + const records = await listDeviceTokensForUser(req.params.userId); res.json({ @@ -40,6 +51,10 @@ router.get('/:userId', async (req, res) => { }); router.post('/:userId/dispatch', async (req, res) => { + if (req.params.userId !== req.authUserId) { + return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' }); + } + const { title, body, data } = req.body as { title?: string; body?: string; diff --git a/server/src/routes/join-requests.ts b/server/src/routes/join-requests.ts index fa726b2..82ef84e 100644 --- a/server/src/routes/join-requests.ts +++ b/server/src/routes/join-requests.ts @@ -7,20 +7,29 @@ import { } from '../cqrs'; import { notifyUser } from '../websocket/broadcast'; import { resolveServerPermission } from '../services/server-permissions.service'; +import { getAuthenticatedUserId, requireAuth } from '../middleware/require-auth'; const router = Router(); -router.put('/:id', async (req, res) => { +router.put('/:id', requireAuth, async (req, res) => { const { id } = req.params; const { ownerId, status } = req.body; + const authenticatedUserId = getAuthenticatedUserId(req); const request = await getJoinRequestById(id); if (!request) return res.status(404).json({ error: 'Request not found' }); + if (ownerId && ownerId !== authenticatedUserId) { + return res.status(400).json({ + error: 'ownerId must match the authenticated user', + errorCode: 'USER_ID_MISMATCH' + }); + } + const server = await getServerById(request.serverId); - if (!server || !ownerId || !resolveServerPermission(server, String(ownerId), 'manageServer')) + if (!server || !resolveServerPermission(server, authenticatedUserId, 'manageServer')) return res.status(403).json({ error: 'Not authorized' }); await updateJoinRequestStatus(id, status as JoinRequestPayload['status']); diff --git a/server/src/routes/plugin-support.ts b/server/src/routes/plugin-support.ts index 4eee6a9..ef4fd00 100644 --- a/server/src/routes/plugin-support.ts +++ b/server/src/routes/plugin-support.ts @@ -8,6 +8,7 @@ import { upsertPluginRequirement } from '../services/plugin-support.service'; import { broadcastToServer } from '../websocket/broadcast'; +import { getAuthenticatedUserId, requireAuth } from '../middleware/require-auth'; const router = Router(); @@ -21,8 +22,28 @@ function sendPluginSupportError(error: unknown, res: Response): void { res.status(500).json({ error: 'Internal server error', errorCode: 'INTERNAL_ERROR' }); } -function readActorUserId(value: unknown): string { - return typeof value === 'string' ? value.trim() : ''; +type AuthenticatedRequest = Parameters[0] & { body: { actorUserId?: unknown } }; + +function readOptionalActorUserId(req: AuthenticatedRequest, res: Response): string | null { + let authenticatedUserId: string; + + try { + authenticatedUserId = getAuthenticatedUserId(req); + } catch { + res.status(401).json({ error: 'Missing or invalid authorization token', errorCode: 'UNAUTHORIZED' }); + return null; + } + + if (typeof req.body.actorUserId === 'string' && req.body.actorUserId.trim() && req.body.actorUserId !== authenticatedUserId) { + res.status(400).json({ + error: 'actorUserId must match the authenticated user', + errorCode: 'USER_ID_MISMATCH' + }); + + return null; + } + + return authenticatedUserId; } async function broadcastRequirementsSnapshot(serverId: string): Promise { @@ -43,12 +64,17 @@ router.get('/:serverId/plugins', async (req, res) => { } }); -router.put('/:serverId/plugins/:pluginId/requirement', async (req, res) => { +router.put('/:serverId/plugins/:pluginId/requirement', requireAuth, async (req, res) => { const { serverId, pluginId } = req.params; + const actorUserId = readOptionalActorUserId(req, res); + + if (!actorUserId) { + return; + } try { const requirement = await upsertPluginRequirement({ - actorUserId: readActorUserId(req.body.actorUserId), + actorUserId, installUrl: req.body.installUrl, manifest: req.body.manifest, pluginId, @@ -66,12 +92,17 @@ router.put('/:serverId/plugins/:pluginId/requirement', async (req, res) => { } }); -router.delete('/:serverId/plugins/:pluginId/requirement', async (req, res) => { +router.delete('/:serverId/plugins/:pluginId/requirement', requireAuth, async (req, res) => { const { serverId, pluginId } = req.params; + const actorUserId = readOptionalActorUserId(req, res); + + if (!actorUserId) { + return; + } try { await deletePluginRequirement({ - actorUserId: readActorUserId(req.body.actorUserId), + actorUserId, pluginId, serverId }); @@ -83,12 +114,17 @@ router.delete('/:serverId/plugins/:pluginId/requirement', async (req, res) => { } }); -router.put('/:serverId/plugins/:pluginId/events/:eventName', async (req, res) => { +router.put('/:serverId/plugins/:pluginId/events/:eventName', requireAuth, async (req, res) => { const { serverId, pluginId, eventName } = req.params; + const actorUserId = readOptionalActorUserId(req, res); + + if (!actorUserId) { + return; + } try { const eventDefinition = await upsertPluginEventDefinition({ - actorUserId: readActorUserId(req.body.actorUserId), + actorUserId, direction: req.body.direction, eventName, maxPayloadBytes: req.body.maxPayloadBytes, @@ -106,12 +142,17 @@ router.put('/:serverId/plugins/:pluginId/events/:eventName', async (req, res) => } }); -router.delete('/:serverId/plugins/:pluginId/events/:eventName', async (req, res) => { +router.delete('/:serverId/plugins/:pluginId/events/:eventName', requireAuth, async (req, res) => { const { serverId, pluginId, eventName } = req.params; + const actorUserId = readOptionalActorUserId(req, res); + + if (!actorUserId) { + return; + } try { await deletePluginEventDefinition({ - actorUserId: readActorUserId(req.body.actorUserId), + actorUserId, eventName, pluginId, serverId diff --git a/server/src/routes/servers.ts b/server/src/routes/servers.ts index 71e4e13..6e6faf6 100644 --- a/server/src/routes/servers.ts +++ b/server/src/routes/servers.ts @@ -35,6 +35,11 @@ import { canModerateServerMember, resolveServerPermission } from '../services/server-permissions.service'; +import { + getAuthenticatedUserId, + requireAuth, + rejectSpoofedUserId +} from '../middleware/require-auth'; const router = Router(); @@ -185,7 +190,7 @@ router.get('/trending', async (req, res) => { res.json({ servers: enrichedResults, total: enrichedResults.length, limit }); }); -router.post('/', async (req, res) => { +router.post('/', requireAuth, async (req, res) => { const { id: clientId, name, @@ -204,12 +209,17 @@ router.post('/', async (req, res) => { if (!name || !ownerId || !ownerPublicKey) return res.status(400).json({ error: 'Missing required fields' }); + if (!rejectSpoofedUserId(req, res, ownerId, 'ownerId')) { + return; + } + + const authenticatedOwnerId = getAuthenticatedUserId(req); const passwordHash = passwordHashForInput(password); const server: ServerPayload = { id: clientId || uuidv4(), name, description, - ownerId, + ownerId: authenticatedOwnerId, ownerPublicKey, hasPassword: !!passwordHash, passwordHash, @@ -225,12 +235,12 @@ router.post('/', async (req, res) => { }; await upsertServer(server); - await ensureServerMembership(server.id, ownerId); + await ensureServerMembership(server.id, authenticatedOwnerId); res.status(201).json(await enrichServer(server, getRequestOrigin(req))); }); -router.put('/:id', async (req, res) => { +router.put('/:id', requireAuth, async (req, res) => { const { id } = req.params; const { currentOwnerId, @@ -242,13 +252,16 @@ router.put('/:id', async (req, res) => { ...updates } = req.body; const existing = await getServerById(id); - const authenticatedOwnerId = currentOwnerId ?? req.body.ownerId; + const authenticatedOwnerId = getAuthenticatedUserId(req); if (!existing) return res.status(404).json({ error: 'Server not found' }); - if (!authenticatedOwnerId) { - return res.status(400).json({ error: 'Missing currentOwnerId' }); + if (currentOwnerId && currentOwnerId !== authenticatedOwnerId) { + return res.status(400).json({ + error: 'currentOwnerId must match the authenticated user', + errorCode: 'USER_ID_MISMATCH' + }); } if (!canManageServerUpdate(existing, authenticatedOwnerId, { @@ -276,18 +289,22 @@ router.put('/:id', async (req, res) => { res.json(await enrichServer(server, getRequestOrigin(req))); }); -router.post('/:id/join', async (req, res) => { +router.post('/:id/join', requireAuth, async (req, res) => { const { id: serverId } = req.params; const { userId, password, inviteId } = req.body; + const authenticatedUserId = getAuthenticatedUserId(req); - if (!userId) { - return res.status(400).json({ error: 'Missing userId', errorCode: 'MISSING_USER' }); + if (userId && userId !== authenticatedUserId) { + return res.status(400).json({ + error: 'userId must match the authenticated user', + errorCode: 'USER_ID_MISMATCH' + }); } try { const result = await joinServerWithAccess({ serverId, - userId: String(userId), + userId: authenticatedUserId, password: typeof password === 'string' ? password : undefined, inviteId: typeof inviteId === 'string' ? inviteId : undefined }); @@ -305,12 +322,16 @@ router.post('/:id/join', async (req, res) => { } }); -router.post('/:id/invites', async (req, res) => { +router.post('/:id/invites', requireAuth, async (req, res) => { const { id: serverId } = req.params; const { requesterUserId, requesterDisplayName } = req.body; + const authenticatedUserId = getAuthenticatedUserId(req); - if (!requesterUserId) { - return res.status(400).json({ error: 'Missing requesterUserId', errorCode: 'MISSING_USER' }); + if (requesterUserId && requesterUserId !== authenticatedUserId) { + return res.status(400).json({ + error: 'requesterUserId must match the authenticated user', + errorCode: 'USER_ID_MISMATCH' + }); } const server = await getServerById(serverId); @@ -322,7 +343,7 @@ router.post('/:id/invites', async (req, res) => { try { const invite = await createServerInvite( serverId, - String(requesterUserId), + authenticatedUserId, typeof requesterDisplayName === 'string' ? requesterDisplayName : undefined ); @@ -332,9 +353,10 @@ router.post('/:id/invites', async (req, res) => { } }); -router.post('/:id/moderation/kick', async (req, res) => { +router.post('/:id/moderation/kick', requireAuth, async (req, res) => { const { id: serverId } = req.params; const { actorUserId, targetUserId } = req.body; + const authenticatedUserId = getAuthenticatedUserId(req); const server = await getServerById(serverId); if (!server) { @@ -345,7 +367,14 @@ router.post('/:id/moderation/kick', async (req, res) => { return res.status(400).json({ error: 'Missing targetUserId', errorCode: 'MISSING_TARGET' }); } - if (!canModerateServerMember(server, String(actorUserId || ''), String(targetUserId), 'kickMembers')) { + if (actorUserId && actorUserId !== authenticatedUserId) { + return res.status(400).json({ + error: 'actorUserId must match the authenticated user', + errorCode: 'USER_ID_MISMATCH' + }); + } + + if (!canModerateServerMember(server, authenticatedUserId, String(targetUserId), 'kickMembers')) { return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' }); } @@ -354,9 +383,10 @@ router.post('/:id/moderation/kick', async (req, res) => { res.json({ ok: true }); }); -router.post('/:id/moderation/ban', async (req, res) => { +router.post('/:id/moderation/ban', requireAuth, async (req, res) => { const { id: serverId } = req.params; const { actorUserId, targetUserId, banId, displayName, reason, expiresAt } = req.body; + const authenticatedUserId = getAuthenticatedUserId(req); const server = await getServerById(serverId); if (!server) { @@ -367,7 +397,14 @@ router.post('/:id/moderation/ban', async (req, res) => { return res.status(400).json({ error: 'Missing targetUserId', errorCode: 'MISSING_TARGET' }); } - if (!canModerateServerMember(server, String(actorUserId || ''), String(targetUserId), 'banMembers')) { + if (actorUserId && actorUserId !== authenticatedUserId) { + return res.status(400).json({ + error: 'actorUserId must match the authenticated user', + errorCode: 'USER_ID_MISMATCH' + }); + } + + if (!canModerateServerMember(server, authenticatedUserId, String(targetUserId), 'banMembers')) { return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' }); } @@ -375,7 +412,7 @@ router.post('/:id/moderation/ban', async (req, res) => { serverId, userId: String(targetUserId), banId: typeof banId === 'string' ? banId : undefined, - bannedBy: String(actorUserId || ''), + bannedBy: authenticatedUserId, displayName: typeof displayName === 'string' ? displayName : undefined, reason: typeof reason === 'string' ? reason : undefined, expiresAt: typeof expiresAt === 'number' ? expiresAt : undefined @@ -384,16 +421,24 @@ router.post('/:id/moderation/ban', async (req, res) => { res.json({ ok: true }); }); -router.post('/:id/moderation/unban', async (req, res) => { +router.post('/:id/moderation/unban', requireAuth, async (req, res) => { const { id: serverId } = req.params; const { actorUserId, banId, targetUserId } = req.body; + const authenticatedUserId = getAuthenticatedUserId(req); const server = await getServerById(serverId); if (!server) { return res.status(404).json({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' }); } - if (!resolveServerPermission(server, String(actorUserId || ''), 'manageBans')) { + if (actorUserId && actorUserId !== authenticatedUserId) { + return res.status(400).json({ + error: 'actorUserId must match the authenticated user', + errorCode: 'USER_ID_MISMATCH' + }); + } + + if (!resolveServerPermission(server, authenticatedUserId, 'manageBans')) { return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' }); } @@ -406,25 +451,29 @@ router.post('/:id/moderation/unban', async (req, res) => { res.json({ ok: true }); }); -router.post('/:id/leave', async (req, res) => { +router.post('/:id/leave', requireAuth, async (req, res) => { const { id: serverId } = req.params; const { userId } = req.body; + const authenticatedUserId = getAuthenticatedUserId(req); const server = await getServerById(serverId); if (!server) { return res.status(404).json({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' }); } - if (!userId) { - return res.status(400).json({ error: 'Missing userId', errorCode: 'MISSING_USER' }); + if (userId && userId !== authenticatedUserId) { + return res.status(400).json({ + error: 'userId must match the authenticated user', + errorCode: 'USER_ID_MISMATCH' + }); } - await leaveServerUser(serverId, String(userId)); + await leaveServerUser(serverId, authenticatedUserId); res.json({ ok: true }); }); -router.post('/:id/heartbeat', async (req, res) => { +router.post('/:id/heartbeat', requireAuth, async (req, res) => { const { id } = req.params; const { currentUsers } = req.body; const existing = await getServerById(id); @@ -442,30 +491,38 @@ router.post('/:id/heartbeat', async (req, res) => { res.json({ ok: true }); }); -router.delete('/:id', async (req, res) => { +router.delete('/:id', requireAuth, async (req, res) => { const { id } = req.params; const { ownerId } = req.body; + const authenticatedOwnerId = getAuthenticatedUserId(req); const existing = await getServerById(id); if (!existing) return res.status(404).json({ error: 'Server not found' }); - if (existing.ownerId !== ownerId) + if (ownerId && ownerId !== authenticatedOwnerId) { + return res.status(400).json({ + error: 'ownerId must match the authenticated user', + errorCode: 'USER_ID_MISMATCH' + }); + } + + if (existing.ownerId !== authenticatedOwnerId) return res.status(403).json({ error: 'Not authorized' }); await deleteServer(id); res.json({ ok: true }); }); -router.get('/:id/requests', async (req, res) => { +router.get('/:id/requests', requireAuth, async (req, res) => { const { id: serverId } = req.params; - const { ownerId } = req.query; + const authenticatedUserId = getAuthenticatedUserId(req); const server = await getServerById(serverId); if (!server) return res.status(404).json({ error: 'Server not found' }); - if (server.ownerId !== ownerId) + if (!resolveServerPermission(server, authenticatedUserId, 'manageServer')) return res.status(403).json({ error: 'Not authorized' }); const requests = await getPendingRequestsForServer(serverId); diff --git a/server/src/routes/users.ts b/server/src/routes/users.ts index 5b96f8e..1305623 100644 --- a/server/src/routes/users.ts +++ b/server/src/routes/users.ts @@ -1,13 +1,30 @@ -import crypto from 'crypto'; import { Router } from 'express'; import { v4 as uuidv4 } from 'uuid'; -import { getUserByUsername, registerUser } from '../cqrs'; +import { + getUserById, + getUserByUsername, + registerUser, + updateUserPasswordHash, + updateUserSigningPublicKey +} from '../cqrs'; +import { hashPasswordForStorage, verifyPassword } from '../services/password-auth.service'; +import { issueSessionToken, revokeSessionToken } from '../services/session-auth.service'; +import { getAuthenticatedUserId, requireAuth } from '../middleware/require-auth'; const router = Router(); -function hashPassword(pw: string): string { - return crypto.createHash('sha256').update(pw) - .digest('hex'); +function buildAuthResponse(user: { + id: string; + username: string; + displayName: string; +}, token: string, expiresAt: number) { + return { + id: user.id, + username: user.username, + displayName: user.displayName, + token, + expiresAt + }; } router.post('/register', async (req, res) => { @@ -24,23 +41,64 @@ router.post('/register', async (req, res) => { const user = { id: uuidv4(), username, - passwordHash: hashPassword(password), + passwordHash: await hashPasswordForStorage(password), displayName: displayName || username, createdAt: Date.now() }; await registerUser(user); - res.status(201).json({ id: user.id, username: user.username, displayName: user.displayName }); + const session = await issueSessionToken(user.id); + + res.status(201).json(buildAuthResponse(user, session.token, session.expiresAt)); }); router.post('/login', async (req, res) => { const { username, password } = req.body; const user = await getUserByUsername(username); - if (!user || user.passwordHash !== hashPassword(password)) + if (!user || !(await verifyPassword(password, user.passwordHash))) return res.status(401).json({ error: 'Invalid credentials' }); - res.json({ id: user.id, username: user.username, displayName: user.displayName }); + const upgradedHash = await hashPasswordForStorage(password, user.passwordHash); + + if (upgradedHash !== user.passwordHash) { + await updateUserPasswordHash(user.id, upgradedHash); + } + + const session = await issueSessionToken(user.id); + + res.json(buildAuthResponse(user, session.token, session.expiresAt)); +}); + +router.put('/me/signing-key', requireAuth, async (req, res) => { + const { publicKeyJwk } = req.body; + const userId = getAuthenticatedUserId(req); + + if (!publicKeyJwk || typeof publicKeyJwk !== 'object') { + return res.status(400).json({ error: 'Missing publicKeyJwk', errorCode: 'INVALID_SIGNING_KEY' }); + } + + await updateUserSigningPublicKey(userId, JSON.stringify(publicKeyJwk)); + + res.json({ ok: true }); +}); + +router.get('/:id/signing-public-key', async (req, res) => { + const user = await getUserById(req.params.id); + + if (!user?.signingPublicKey) { + return res.status(404).json({ error: 'Signing public key not found', errorCode: 'SIGNING_KEY_NOT_FOUND' }); + } + + res.json({ publicKeyJwk: JSON.parse(user.signingPublicKey) }); +}); + +router.post('/logout', requireAuth, async (req, res) => { + if (req.authToken) { + await revokeSessionToken(req.authToken); + } + + res.json({ ok: true }); }); export default router; diff --git a/server/src/services/password-auth.service.spec.ts b/server/src/services/password-auth.service.spec.ts new file mode 100644 index 0000000..4f306ff --- /dev/null +++ b/server/src/services/password-auth.service.spec.ts @@ -0,0 +1,45 @@ +import { + describe, + it, + expect +} from 'vitest'; +import crypto from 'crypto'; +import { + hashPasswordForStorage, + isLegacySha256Hash, + verifyPassword +} from './password-auth.service'; + +describe('password-auth.service', () => { + it('stores bcrypt hashes for new passwords', async () => { + const hash = await hashPasswordForStorage('secret-pass'); + + expect(hash.startsWith('$2')).toBe(true); + expect(isLegacySha256Hash(hash)).toBe(false); + }); + + it('verifies bcrypt passwords', async () => { + const hash = await hashPasswordForStorage('secret-pass'); + + await expect(verifyPassword('secret-pass', hash)).resolves.toBe(true); + await expect(verifyPassword('wrong-pass', hash)).resolves.toBe(false); + }); + + it('verifies legacy sha256 passwords', async () => { + const legacyHash = crypto.createHash('sha256').update('legacy-pass') + .digest('hex'); + + expect(isLegacySha256Hash(legacyHash)).toBe(true); + await expect(verifyPassword('legacy-pass', legacyHash)).resolves.toBe(true); + await expect(verifyPassword('wrong-pass', legacyHash)).resolves.toBe(false); + }); + + it('rehashes legacy passwords to bcrypt after successful verification', async () => { + const legacyHash = crypto.createHash('sha256').update('legacy-pass') + .digest('hex'); + const upgraded = await hashPasswordForStorage('legacy-pass', legacyHash); + + expect(upgraded.startsWith('$2')).toBe(true); + await expect(verifyPassword('legacy-pass', upgraded)).resolves.toBe(true); + }); +}); diff --git a/server/src/services/password-auth.service.ts b/server/src/services/password-auth.service.ts new file mode 100644 index 0000000..a83a0ed --- /dev/null +++ b/server/src/services/password-auth.service.ts @@ -0,0 +1,33 @@ +import crypto from 'crypto'; +import bcrypt from 'bcryptjs'; + +const BCRYPT_ROUNDS = 12; + +export function isLegacySha256Hash(passwordHash: string): boolean { + return /^[a-f0-9]{64}$/i.test(passwordHash); +} + +export async function hashPasswordForStorage( + password: string, + existingHash?: string +): Promise { + if (existingHash && isLegacySha256Hash(existingHash)) { + const legacyMatches = crypto.createHash('sha256').update(password) + .digest('hex') === existingHash; + + if (legacyMatches) { + return bcrypt.hash(password, BCRYPT_ROUNDS); + } + } + + return bcrypt.hash(password, BCRYPT_ROUNDS); +} + +export async function verifyPassword(password: string, passwordHash: string): Promise { + if (isLegacySha256Hash(passwordHash)) { + return crypto.createHash('sha256').update(password) + .digest('hex') === passwordHash; + } + + return bcrypt.compare(password, passwordHash); +} diff --git a/server/src/services/server-access.service.ts b/server/src/services/server-access.service.ts index 29fae00..ef592ce 100644 --- a/server/src/services/server-access.service.ts +++ b/server/src/services/server-access.service.ts @@ -134,6 +134,34 @@ export async function countServerMemberships(serverId: string): Promise return await getMembershipRepository().count({ where: { serverId } }); } +export async function usersShareServerMembership(userA: string, userB: string): Promise { + if (userA === userB) { + return true; + } + + const repo = getMembershipRepository(); + const membershipsForA = await repo.find({ + where: { userId: userA }, + select: ['serverId'] + }); + + if (membershipsForA.length === 0) { + return false; + } + + for (const membership of membershipsForA) { + const shared = await repo.findOne({ + where: { serverId: membership.serverId, userId: userB } + }); + + if (shared) { + return true; + } + } + + return false; +} + export async function ensureServerMembership(serverId: string, userId: string): Promise { const repo = getMembershipRepository(); const now = Date.now(); diff --git a/server/src/services/session-auth.service.ts b/server/src/services/session-auth.service.ts new file mode 100644 index 0000000..f004e52 --- /dev/null +++ b/server/src/services/session-auth.service.ts @@ -0,0 +1,110 @@ +import { randomBytes } from 'crypto'; +import { getDataSource } from '../db/database'; +import { SessionTokenEntity } from '../entities/SessionTokenEntity'; +import { getUserById } from '../cqrs'; +import type { AuthUserPayload } from '../cqrs/types'; + +const DEFAULT_TOKEN_TTL_MS = 24 * 60 * 60 * 1000; + +export interface IssuedSessionToken { + token: string; + userId: string; + issuedAt: number; + expiresAt: number; +} + +export interface AuthenticatedSession { + token: string; + user: AuthUserPayload; + issuedAt: number; + expiresAt: number; +} + +function getTokenRepository() { + return getDataSource().getRepository(SessionTokenEntity); +} + +export function getSessionTokenTtlMs(): number { + const configured = Number(process.env.SESSION_TOKEN_TTL_MS); + + return Number.isFinite(configured) && configured > 0 + ? configured + : DEFAULT_TOKEN_TTL_MS; +} + +export async function issueSessionToken(userId: string): Promise { + const token = randomBytes(32).toString('hex'); + const issuedAt = Date.now(); + const expiresAt = issuedAt + getSessionTokenTtlMs(); + const repo = getTokenRepository(); + + await repo.save(repo.create({ + token, + userId, + issuedAt, + expiresAt + })); + + return { token, userId, issuedAt, expiresAt }; +} + +export async function consumeSessionToken(token: string): Promise { + const normalized = token.trim(); + + if (!normalized) { + return null; + } + + const repo = getTokenRepository(); + const record = await repo.findOne({ where: { token: normalized } }); + + if (!record || record.expiresAt < Date.now()) { + if (record) { + await repo.delete({ token: normalized }); + } + + return null; + } + + const user = await getUserById(record.userId); + + if (!user) { + await repo.delete({ token: normalized }); + return null; + } + + return { + token: record.token, + user, + issuedAt: record.issuedAt, + expiresAt: record.expiresAt + }; +} + +export async function revokeSessionToken(token: string): Promise { + const normalized = token.trim(); + + if (!normalized) { + return; + } + + await getTokenRepository().delete({ token: normalized }); +} + +export async function revokeAllSessionTokensForUser(userId: string): Promise { + await getTokenRepository().delete({ userId }); +} + +export async function pruneExpiredSessionTokens(): Promise { + const repo = getTokenRepository(); + const now = Date.now(); + const expired = await repo.createQueryBuilder('token') + .where('token.expiresAt < :now', { now }) + .getMany(); + + if (expired.length === 0) { + return; + } + + await repo.remove(expired); +} diff --git a/server/src/types/express-augmentation.ts b/server/src/types/express-augmentation.ts new file mode 100644 index 0000000..b7ad1a5 --- /dev/null +++ b/server/src/types/express-augmentation.ts @@ -0,0 +1,11 @@ +import type { AuthUserPayload } from '../cqrs/types'; + +declare module 'express-serve-static-core' { + interface Request { + authUserId?: string; + authUser?: AuthUserPayload; + authToken?: string; + } +} + +export {}; diff --git a/server/src/websocket/handler-auth.spec.ts b/server/src/websocket/handler-auth.spec.ts new file mode 100644 index 0000000..0cdd211 --- /dev/null +++ b/server/src/websocket/handler-auth.spec.ts @@ -0,0 +1,120 @@ +import { + describe, + it, + expect, + beforeEach, + vi +} from 'vitest'; +import { WebSocket } from 'ws'; +import { connectedUsers } from './state'; +import { ConnectedUser } from './types'; +import { handleWebSocketMessage } from './handler'; + +vi.mock('../services/server-access.service', () => ({ + authorizeWebSocketJoin: vi.fn(async () => ({ allowed: true as const })), + findServerMembership: vi.fn(async () => ({ id: 'membership-1' })), + usersShareServerMembership: vi.fn(async () => false) +})); + +vi.mock('../services/session-auth.service', () => ({ + consumeSessionToken: vi.fn(async (token: string) => { + if (token !== 'valid-token') { + return null; + } + + return { + token, + user: { + id: 'user-1', + username: 'alice', + displayName: 'Alice', + passwordHash: 'hash', + createdAt: Date.now() + }, + issuedAt: Date.now(), + expiresAt: Date.now() + 60_000 + }; + }) +})); + +function createMockWs(): WebSocket & { sentMessages: string[] } { + const sent: string[] = []; + const ws = { + readyState: WebSocket.OPEN, + send: (data: string) => { sent.push(data); }, + close: () => {}, + sentMessages: sent + } as unknown as WebSocket & { sentMessages: string[] }; + + return ws; +} + +function createConnectedUser(connectionId: string): ConnectedUser { + const ws = createMockWs(); + const user: ConnectedUser = { + oderId: connectionId, + ws, + authenticated: false, + serverIds: new Set(), + displayName: 'Test User', + lastPong: Date.now() + }; + + connectedUsers.set(connectionId, user); + + return user; +} + +describe('server websocket handler - authentication', () => { + beforeEach(() => { + connectedUsers.clear(); + vi.clearAllMocks(); + }); + + it('rejects non-identify messages until the connection is authenticated', async () => { + createConnectedUser('conn-1'); + + await handleWebSocketMessage('conn-1', { type: 'typing', serverId: 'server-1' }); + + const user = connectedUsers.get('conn-1'); + const sentMessages = (user?.ws as WebSocket & { sentMessages: string[] }).sentMessages; + const response = JSON.parse(sentMessages[0]) as { type: string }; + + expect(response.type).toBe('auth_required'); + expect(user?.authenticated).toBe(false); + }); + + it('rejects identify without a session token', async () => { + createConnectedUser('conn-1'); + + await handleWebSocketMessage('conn-1', { + type: 'identify', + oderId: 'user-1', + displayName: 'Alice' + }); + + const user = connectedUsers.get('conn-1'); + const sentMessages = (user?.ws as WebSocket & { sentMessages: string[] }).sentMessages; + const response = JSON.parse(sentMessages[0]) as { type: string; code: string }; + + expect(response.type).toBe('auth_error'); + expect(response.code).toBe('MISSING_TOKEN'); + expect(user?.authenticated).toBe(false); + }); + + it('binds identify to the authenticated user id from the token', async () => { + createConnectedUser('conn-1'); + + await handleWebSocketMessage('conn-1', { + type: 'identify', + token: 'valid-token', + oderId: 'user-1', + displayName: 'Alice' + }); + + const user = connectedUsers.get('conn-1'); + + expect(user?.authenticated).toBe(true); + expect(user?.oderId).toBe('user-1'); + }); +}); diff --git a/server/src/websocket/handler-plugin.spec.ts b/server/src/websocket/handler-plugin.spec.ts index 247081d..aab89be 100644 --- a/server/src/websocket/handler-plugin.spec.ts +++ b/server/src/websocket/handler-plugin.spec.ts @@ -63,6 +63,7 @@ function createConnectedUser( displayName: `User ${oderId}`, lastPong: Date.now(), oderId, + authenticated: true, serverIds: new Set(), ws: createMockWs(), ...overrides diff --git a/server/src/websocket/handler-status.spec.ts b/server/src/websocket/handler-status.spec.ts index 4b4dc16..98e969b 100644 --- a/server/src/websocket/handler-status.spec.ts +++ b/server/src/websocket/handler-status.spec.ts @@ -14,6 +14,41 @@ vi.mock('../services/server-access.service', () => ({ authorizeWebSocketJoin: vi.fn(async () => ({ allowed: true as const })) })); +let authenticatedUserId = 'user-1'; + +vi.mock('../services/session-auth.service', () => ({ + consumeSessionToken: vi.fn(async (token: string) => { + if (token !== 'test-token') { + return null; + } + + return { + token, + user: { + id: authenticatedUserId, + username: 'test-user', + displayName: 'Test User', + passwordHash: 'hash', + createdAt: Date.now() + }, + issuedAt: Date.now(), + expiresAt: Date.now() + 60_000 + }; + }) +})); + +vi.mock('../services/plugin-support.service', async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + getPluginRequirementsSnapshot: vi.fn(async () => ({ + requirements: [], + eventDefinitions: [] + })) + }; +}); + /** * Minimal mock WebSocket that records sent messages. */ @@ -38,6 +73,7 @@ function createConnectedUser( const user: ConnectedUser = { oderId, ws, + authenticated: true, serverIds: new Set(), displayName: 'Test User', lastPong: Date.now(), @@ -168,7 +204,8 @@ describe('server websocket handler - status_update', () => { getSentMessagesStore(user2).sentMessages.length = 0; // Identify first (required for handler) - await handleWebSocketMessage('conn-1', { type: 'identify', oderId: 'user-1', displayName: 'User 1' }); + authenticatedUserId = 'user-1'; + await handleWebSocketMessage('conn-1', { type: 'identify', token: 'test-token', oderId: 'user-1', displayName: 'User 1' }); // user-2 joins server -> should receive server_users with user-1's status getSentMessagesStore(user2).sentMessages.length = 0; @@ -201,7 +238,8 @@ describe('server websocket handler - user_joined includes status', () => { getRequiredConnectedUser('conn-1').status = 'busy'; // Identify user-1 - await handleWebSocketMessage('conn-1', { type: 'identify', oderId: 'user-1', displayName: 'User 1' }); + authenticatedUserId = 'user-1'; + await handleWebSocketMessage('conn-1', { type: 'identify', token: 'test-token', oderId: 'user-1', displayName: 'User 1' }); getSentMessagesStore(user2).sentMessages.length = 0; @@ -237,8 +275,10 @@ describe('server websocket handler - profile metadata in presence messages', () bob.serverIds.add('server-1'); getSentMessagesStore(bob).sentMessages.length = 0; + authenticatedUserId = 'user-1'; await handleWebSocketMessage('conn-1', { type: 'identify', + token: 'test-token', oderId: 'user-1', displayName: 'Alice Updated', description: 'Updated bio', @@ -261,8 +301,10 @@ describe('server websocket handler - profile metadata in presence messages', () alice.serverIds.add('server-1'); bob.serverIds.add('server-1'); + authenticatedUserId = 'user-1'; await handleWebSocketMessage('conn-1', { type: 'identify', + token: 'test-token', oderId: 'user-1', displayName: 'Alice', description: 'Alice bio', @@ -291,8 +333,10 @@ describe('server websocket handler - profile metadata in presence messages', () alice.serverIds.add('server-1'); bob.serverIds.add('server-1'); + authenticatedUserId = 'user-1'; await handleWebSocketMessage('conn-1', { type: 'identify', + token: 'test-token', oderId: 'user-1', displayName: 'Alice', homeSignalServerUrl: 'http://signal.example.com:3001/' diff --git a/server/src/websocket/handler.ts b/server/src/websocket/handler.ts index 186b93d..14d0bf2 100644 --- a/server/src/websocket/handler.ts +++ b/server/src/websocket/handler.ts @@ -7,7 +7,12 @@ import { getUniqueUsersInServer, isOderIdConnectedToServer } from './broadcast'; -import { authorizeWebSocketJoin } from '../services/server-access.service'; +import { + authorizeWebSocketJoin, + findServerMembership, + usersShareServerMembership +} from '../services/server-access.service'; +import { consumeSessionToken } from '../services/session-auth.service'; import { getPluginRequirementsSnapshot, PluginSupportError, @@ -131,8 +136,67 @@ async function sendPluginRequirements(user: ConnectedUser, serverId: string): Pr } } -function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: string): void { - const newOderId = readMessageId(message['oderId']) ?? connectionId; +const DIRECT_SIGNALING_TYPES = new Set([ + 'direct-message', + 'direct-message-status', + 'direct-message-mutation', + 'direct-message-typing', + 'direct-message-sync-request', + 'direct-message-sync', + 'direct-call' +]); +const SERVER_SCOPED_SIGNALING_TYPES = new Set([ + 'server_icon_peer_request', + 'server_icon_peer_data', + 'server_icon_available', + 'server_icon_sync_request' +]); + +function sendAuthRequired(user: ConnectedUser): void { + user.ws.send(JSON.stringify({ + type: 'auth_required', + message: 'identify with a valid session token before sending messages' + })); +} + +async function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: string): Promise { + const token = typeof message['token'] === 'string' ? message['token'].trim() : ''; + + if (!token) { + user.ws.send(JSON.stringify({ + type: 'auth_error', + code: 'MISSING_TOKEN', + message: 'identify requires a session token' + })); + + return; + } + + const session = await consumeSessionToken(token); + + if (!session) { + user.ws.send(JSON.stringify({ + type: 'auth_error', + code: 'INVALID_TOKEN', + message: 'invalid or expired session token' + })); + + return; + } + + const claimedOderId = readMessageId(message['oderId']); + + if (claimedOderId && claimedOderId !== session.user.id) { + user.ws.send(JSON.stringify({ + type: 'auth_error', + code: 'USER_ID_MISMATCH', + message: 'oderId must match the authenticated user' + })); + + return; + } + + const newOderId = session.user.id; const newScope = typeof message['connectionScope'] === 'string' ? message['connectionScope'] : undefined; const previousDisplayName = normalizeDisplayName(user.displayName); const previousDescription = user.description; @@ -140,6 +204,7 @@ function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: s const previousHomeSignalServerUrl = user.homeSignalServerUrl; user.oderId = newOderId; + user.authenticated = true; user.displayName = normalizeDisplayName(message['displayName'], normalizeDisplayName(user.displayName)); if (Object.prototype.hasOwnProperty.call(message, 'description')) { @@ -277,11 +342,45 @@ function handleLeaveServer(user: ConnectedUser, message: WsMessage, connectionId ); } -function forwardRtcMessage(user: ConnectedUser, message: WsMessage): void { +async function canForwardRtcMessage(user: ConnectedUser, message: WsMessage, targetUserId: string): Promise { + if (!targetUserId || targetUserId === user.oderId) { + return false; + } + + if (DIRECT_SIGNALING_TYPES.has(message.type)) { + return true; + } + + if (SERVER_SCOPED_SIGNALING_TYPES.has(message.type)) { + const serverId = readMessageId(message['serverId']); + + if (!serverId) { + return false; + } + + const senderMembership = await findServerMembership(serverId, user.oderId); + const targetMembership = await findServerMembership(serverId, targetUserId); + + return !!senderMembership && !!targetMembership; + } + + if (message.type === 'offer' || message.type === 'answer' || message.type === 'ice_candidate') { + return true; + } + + return usersShareServerMembership(user.oderId, targetUserId); +} + +async function forwardRtcMessage(user: ConnectedUser, message: WsMessage): Promise { const targetUserId = readMessageId(message['targetUserId']) ?? ''; console.log(`Forwarding ${message.type} from ${user.oderId} to ${targetUserId}`); + if (!(await canForwardRtcMessage(user, message, targetUserId))) { + console.log(`Blocked ${message.type} relay from ${user.oderId} to ${targetUserId}`); + return; + } + const targetUser = findUserByOderId(targetUserId); if (targetUser) { @@ -482,13 +581,18 @@ export async function handleWebSocketMessage(connectionId: string, message: WsMe user.lastPong = Date.now(); connectedUsers.set(connectionId, user); + if (!user.authenticated && message.type !== 'identify' && message.type !== 'keepalive') { + sendAuthRequired(user); + return; + } + switch (message.type) { case 'keepalive': user.ws.send(JSON.stringify({ type: 'keepalive_ack', serverTime: Date.now() })); break; case 'identify': - handleIdentify(user, message, connectionId); + await handleIdentify(user, message, connectionId); break; case 'join_server': @@ -515,7 +619,7 @@ export async function handleWebSocketMessage(connectionId: string, message: WsMe case 'direct-call': case 'server_icon_peer_request': case 'server_icon_peer_data': - forwardRtcMessage(user, message); + await forwardRtcMessage(user, message); break; case 'chat_message': diff --git a/server/src/websocket/index.ts b/server/src/websocket/index.ts index a7d7f6c..ed3b0ac 100644 --- a/server/src/websocket/index.ts +++ b/server/src/websocket/index.ts @@ -80,7 +80,13 @@ export function setupWebSocket(server: Server { const user = connectedUsers.get(connectionId); diff --git a/server/src/websocket/types.ts b/server/src/websocket/types.ts index 2f1fa0d..7525028 100644 --- a/server/src/websocket/types.ts +++ b/server/src/websocket/types.ts @@ -3,6 +3,7 @@ import { WebSocket } from 'ws'; export interface ConnectedUser { oderId: string; ws: WebSocket; + authenticated: boolean; serverIds: Set; viewedServerId?: string; displayName?: string; diff --git a/toju-app/public/i18n/catalog/auth.json b/toju-app/public/i18n/catalog/auth.json index f955286..a0c63b3 100644 --- a/toju-app/public/i18n/catalog/auth.json +++ b/toju-app/public/i18n/catalog/auth.json @@ -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." } } } diff --git a/toju-app/public/i18n/en.json b/toju-app/public/i18n/en.json index cb033af..956d769 100644 --- a/toju-app/public/i18n/en.json +++ b/toju-app/public/i18n/en.json @@ -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": { diff --git a/toju-app/src/app/app.config.ts b/toju-app/src/app/app.config.ts index 9153763..9302175 100644 --- a/toju-app/src/app/app.config.ts +++ b/toju-app/src/app/app.config.ts @@ -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 diff --git a/toju-app/src/app/core/platform/electron/electron-api.models.ts b/toju-app/src/app/core/platform/electron/electron-api.models.ts index 3565888..780cd60 100644 --- a/toju-app/src/app/core/platform/electron/electron-api.models.ts +++ b/toju-app/src/app/core/platform/electron/electron-api.models.ts @@ -294,9 +294,10 @@ export interface ElectronApi { relaunchApp: () => Promise; onDeepLinkReceived: (listener: (url: string) => void) => () => void; readClipboardFiles: () => Promise; - readFile: (filePath: string) => Promise; - readFileChunk: (filePath: string, start: number, end: number) => Promise; - getFileSize: (filePath: string) => Promise; + readFile: (filePath: string) => Promise; + readFileChunk: (filePath: string, start: number, end: number) => Promise; + getFileSize: (filePath: string) => Promise; + grantPluginReadRoot?: (rootPath: string) => Promise; writeFile: (filePath: string, data: string) => Promise; appendFile: (filePath: string, data: string) => Promise; saveFileAs: (defaultFileName: string, data: string) => Promise<{ saved: boolean; cancelled: boolean }>; diff --git a/toju-app/src/app/domains/attachment/application/services/attachment-transfer.service.ts b/toju-app/src/app/domains/attachment/application/services/attachment-transfer.service.ts index a8a79bc..612986c 100644 --- a/toju-app/src/app/domains/attachment/application/services/attachment-transfer.service.ts +++ b/toju-app/src/app/domains/attachment/application/services/attachment-transfer.service.ts @@ -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, diff --git a/toju-app/src/app/domains/attachment/infrastructure/services/attachment-storage.service.ts b/toju-app/src/app/domains/attachment/infrastructure/services/attachment-storage.service.ts index 501f855..04b4aad 100644 --- a/toju-app/src/app/domains/attachment/infrastructure/services/attachment-storage.service.ts +++ b/toju-app/src/app/domains/attachment/infrastructure/services/attachment-storage.service.ts @@ -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 { @@ -234,13 +237,14 @@ export class AttachmentStorageService { private async findExistingPath(candidates: (string | null | undefined)[]): Promise { 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; } diff --git a/toju-app/src/app/domains/attachment/infrastructure/util/attachment-storage.util.spec.ts b/toju-app/src/app/domains/attachment/infrastructure/util/attachment-storage.util.spec.ts new file mode 100644 index 0000000..6f0d03e --- /dev/null +++ b/toju-app/src/app/domains/attachment/infrastructure/util/attachment-storage.util.spec.ts @@ -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'); + }); +}); diff --git a/toju-app/src/app/domains/attachment/infrastructure/util/attachment-storage.util.ts b/toju-app/src/app/domains/attachment/infrastructure/util/attachment-storage.util.ts index ab1cd3c..93d747f 100644 --- a/toju-app/src/app/domains/attachment/infrastructure/util/attachment-storage.util.ts +++ b/toju-app/src/app/domains/attachment/infrastructure/util/attachment-storage.util.ts @@ -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'; diff --git a/toju-app/src/app/domains/authentication/application/services/auth-token-store.service.spec.ts b/toju-app/src/app/domains/authentication/application/services/auth-token-store.service.spec.ts new file mode 100644 index 0000000..083de2a --- /dev/null +++ b/toju-app/src/app/domains/authentication/application/services/auth-token-store.service.spec.ts @@ -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(); + + 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(); + }); +}); diff --git a/toju-app/src/app/domains/authentication/application/services/auth-token-store.service.ts b/toju-app/src/app/domains/authentication/application/services/auth-token-store.service.ts new file mode 100644 index 0000000..072bc7a --- /dev/null +++ b/toju-app/src/app/domains/authentication/application/services/auth-token-store.service.ts @@ -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; + + 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 { + try { + const raw = localStorage.getItem(STORAGE_KEY); + + if (!raw) { + return {}; + } + + const parsed = JSON.parse(raw) as Record; + + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch { + return {}; + } + } + + private writeStore(store: Record): void { + localStorage.setItem(STORAGE_KEY, JSON.stringify(store)); + } + + private normalizeServerUrl(serverUrl: string): string { + return serverUrl.trim().replace(/\/+$/, ''); + } +} diff --git a/toju-app/src/app/domains/authentication/application/services/authentication.service.ts b/toju-app/src/app/domains/authentication/application/services/authentication.service.ts index bd86151..de6d860 100644 --- a/toju-app/src/app/domains/authentication/application/services/authentication.service.ts +++ b/toju-app/src/app/domains/authentication/application/services/authentication.service.ts @@ -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(url, { username: params.username, password: params.password - }); + }).pipe( + tap((response) => { + this.persistSessionToken(params.serverId, response); + void this.messageSigning.registerSigningPublicKeyIfNeeded().catch(() => {}); + }) + ); } } diff --git a/toju-app/src/app/domains/authentication/application/services/message-signing.service.ts b/toju-app/src/app/domains/authentication/application/services/message-signing.service.ts new file mode 100644 index 0000000..4c24ad4 --- /dev/null +++ b/toju-app/src/app/domains/authentication/application/services/message-signing.service.ts @@ -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 | null = null; + + async ensureSigningKeyPair(): Promise { + if (!this.keyPairPromise) { + this.keyPairPromise = this.loadOrCreateKeyPair(); + } + + return await this.keyPairPromise; + } + + async getPublicKeyJwk(): Promise { + const keyPair = await this.ensureSigningKeyPair(); + + return await crypto.subtle.exportKey('jwk', keyPair.publicKey); + } + + async registerSigningPublicKeyIfNeeded(): Promise { + 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 { + 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 { + 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 { + 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 { + 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; + } +} diff --git a/toju-app/src/app/domains/authentication/domain/logic/auth-session.rules.spec.ts b/toju-app/src/app/domains/authentication/domain/logic/auth-session.rules.spec.ts new file mode 100644 index 0000000..2ec2ab0 --- /dev/null +++ b/toju-app/src/app/domains/authentication/domain/logic/auth-session.rules.spec.ts @@ -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; + + 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, + null, + () => null, + () => true + ) + ).toBe(true); + }); + + it('exports a stable session-expired error code', () => { + expect(SESSION_EXPIRED_ERROR_CODE).toBe('SESSION_EXPIRED'); + }); +}); diff --git a/toju-app/src/app/domains/authentication/domain/logic/auth-session.rules.ts b/toju-app/src/app/domains/authentication/domain/logic/auth-session.rules.ts new file mode 100644 index 0000000..b8c86e1 --- /dev/null +++ b/toju-app/src/app/domains/authentication/domain/logic/auth-session.rules.ts @@ -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, + activeServerUrl?: string | null +): string[] { + const urls = new Set(); + + 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, + 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; +} diff --git a/toju-app/src/app/domains/authentication/domain/models/authentication.model.ts b/toju-app/src/app/domains/authentication/domain/models/authentication.model.ts index 647dd74..ff46d62 100644 --- a/toju-app/src/app/domains/authentication/domain/models/authentication.model.ts +++ b/toju-app/src/app/domains/authentication/domain/models/authentication.model.ts @@ -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; } diff --git a/toju-app/src/app/domains/authentication/index.ts b/toju-app/src/app/domains/authentication/index.ts index 2e0e636..e9b5a92 100644 --- a/toju-app/src/app/domains/authentication/index.ts +++ b/toju-app/src/app/domains/authentication/index.ts @@ -1,2 +1,3 @@ export * from './application/services/authentication.service'; +export * from './application/services/auth-token-store.service'; export * from './domain/models/authentication.model'; diff --git a/toju-app/src/app/domains/authentication/infrastructure/auth-token.interceptor.ts b/toju-app/src/app/domains/authentication/infrastructure/auth-token.interceptor.ts new file mode 100644 index 0000000..c0eb6ad --- /dev/null +++ b/toju-app/src/app/domains/authentication/infrastructure/auth-token.interceptor.ts @@ -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}` + } + })); +}; diff --git a/toju-app/src/app/domains/chat/README.md b/toju-app/src/app/domains/chat/README.md index 98c2624..b9da600 100644 --- a/toju-app/src/app/domains/chat/README.md +++ b/toju-app/src/app/domains/chat/README.md @@ -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. diff --git a/toju-app/src/app/domains/chat/application/services/message-revision.service.ts b/toju-app/src/app/domains/chat/application/services/message-revision.service.ts new file mode 100644 index 0000000..a1e5bfe --- /dev/null +++ b/toju-app/src/app/domains/chat/application/services/message-revision.service.ts @@ -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 { + 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 { + await this.db.saveMessageRevision(revision); + } + + broadcastRevision(revision: MessageRevision): void { + this.realtime.broadcastMessage({ + type: 'message-revision', + revision + }); + } + + async persistAndBroadcast(revision: MessageRevision): Promise { + await this.persistRevision(revision); + this.broadcastRevision(revision); + } + + async buildCreateRevision(message: Message, actorId: string): Promise { + 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 { + 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); + } +} diff --git a/toju-app/src/app/domains/chat/domain/rules/message-integrity.rules.spec.ts b/toju-app/src/app/domains/chat/domain/rules/message-integrity.rules.spec.ts new file mode 100644 index 0000000..b2dcd42 --- /dev/null +++ b/toju-app/src/app/domains/chat/domain/rules/message-integrity.rules.spec.ts @@ -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 { + 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); + }); +}); diff --git a/toju-app/src/app/domains/chat/domain/rules/message-integrity.rules.ts b/toju-app/src/app/domains/chat/domain/rules/message-integrity.rules.ts new file mode 100644 index 0000000..d4ac540 --- /dev/null +++ b/toju-app/src/app/domains/chat/domain/rules/message-integrity.rules.ts @@ -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 | null | undefined): number { + return typeof message?.revision === 'number' && message.revision >= 0 + ? message.revision + : 0; +} + +export function resolveMessageRevision( + existing: Pick | 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 { + 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 { + 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; +} diff --git a/toju-app/src/app/domains/chat/domain/rules/message-revision-signing.rules.spec.ts b/toju-app/src/app/domains/chat/domain/rules/message-revision-signing.rules.spec.ts new file mode 100644 index 0000000..5021922 --- /dev/null +++ b/toju-app/src/app/domains/chat/domain/rules/message-revision-signing.rules.spec.ts @@ -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); + }); +}); diff --git a/toju-app/src/app/domains/chat/domain/rules/message-revision-signing.rules.ts b/toju-app/src/app/domains/chat/domain/rules/message-revision-signing.rules.ts new file mode 100644 index 0000000..365338f --- /dev/null +++ b/toju-app/src/app/domains/chat/domain/rules/message-revision-signing.rules.ts @@ -0,0 +1,24 @@ +import type { MessageRevision } from '../../../../shared-kernel'; + +export async function attachRevisionSignatureIfPossible( + revision: MessageRevision, + signRevision: (value: MessageRevision) => Promise +): Promise { + 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; +} diff --git a/toju-app/src/app/domains/chat/domain/rules/message-revision.builder.rules.spec.ts b/toju-app/src/app/domains/chat/domain/rules/message-revision.builder.rules.spec.ts new file mode 100644 index 0000000..bd43b77 --- /dev/null +++ b/toju-app/src/app/domains/chat/domain/rules/message-revision.builder.rules.spec.ts @@ -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 { + 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); + }); +}); diff --git a/toju-app/src/app/domains/chat/domain/rules/message-revision.builder.rules.ts b/toju-app/src/app/domains/chat/domain/rules/message-revision.builder.rules.ts new file mode 100644 index 0000000..d614786 --- /dev/null +++ b/toju-app/src/app/domains/chat/domain/rules/message-revision.builder.rules.ts @@ -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 { + 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; +} diff --git a/toju-app/src/app/domains/chat/domain/rules/message-sync.rules.spec.ts b/toju-app/src/app/domains/chat/domain/rules/message-sync.rules.spec.ts new file mode 100644 index 0000000..69c3e2e --- /dev/null +++ b/toju-app/src/app/domains/chat/domain/rules/message-sync.rules.spec.ts @@ -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']); + }); +}); diff --git a/toju-app/src/app/domains/chat/domain/rules/message-sync.rules.ts b/toju-app/src/app/domains/chat/domain/rules/message-sync.rules.ts index 9e34660..4f88830 100644 --- a/toju-app/src/app/domains/chat/domain/rules/message-sync.rules.ts +++ b/toju-app/src/app/domains/chat/domain/rules/message-sync.rules.ts @@ -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(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 + remoteItems: readonly RemoteInventoryItem[], + localMap: ReadonlyMap ): 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); } } diff --git a/toju-app/src/app/domains/chat/domain/rules/remark-sanitize.rules.spec.ts b/toju-app/src/app/domains/chat/domain/rules/remark-sanitize.rules.spec.ts new file mode 100644 index 0000000..44919c8 --- /dev/null +++ b/toju-app/src/app/domains/chat/domain/rules/remark-sanitize.rules.spec.ts @@ -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 world'); + + expect(JSON.stringify(tree)).not.toContain('script'); + }); + + it('clears dangerous link protocols', async () => { + const tree = await parseMarkdown('[click](javascript:alert(1))'); + + expect(JSON.stringify(tree)).not.toContain('javascript:'); + }); + + it('preserves safe custom emoji image data urls', async () => { + const tree = await parseMarkdown('![custom-emoji:party-id:party](data:image/webp;base64,abc)'); + + expect(JSON.stringify(tree)).toContain('data:image/webp;base64,abc'); + }); + + it('clears dangerous non-image data urls', async () => { + const tree = await parseMarkdown('![x](data:text/html,)'); + + expect(JSON.stringify(tree)).not.toContain('data:text/html'); + }); +}); diff --git a/toju-app/src/app/domains/chat/domain/rules/remark-sanitize.rules.ts b/toju-app/src/app/domains/chat/domain/rules/remark-sanitize.rules.ts new file mode 100644 index 0000000..46458fd --- /dev/null +++ b/toju-app/src/app/domains/chat/domain/rules/remark-sanitize.rules.ts @@ -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 = ''; + } + }); + }; +} diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-markdown/chat-message-markdown.component.ts b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-markdown/chat-message-markdown.component.ts index 4ee5771..0a4eb99 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-markdown/chat-message-markdown.component.ts +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-markdown/chat-message-markdown.component.ts @@ -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', diff --git a/toju-app/src/app/domains/plugins/README.md b/toju-app/src/app/domains/plugins/README.md index 6aa200d..3fac26b 100644 --- a/toju-app/src/app/domains/plugins/README.md +++ b/toju-app/src/app/domains/plugins/README.md @@ -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. diff --git a/toju-app/src/app/domains/plugins/application/services/plugin-client-api.service.spec.ts b/toju-app/src/app/domains/plugins/application/services/plugin-client-api.service.spec.ts index 2e7741a..dba3e80 100644 --- a/toju-app/src/app/domains/plugins/application/services/plugin-client-api.service.spec.ts +++ b/toju-app/src/app/domains/plugins/application/services/plugin-client-api.service.spec.ts @@ -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; setOutputVolume: ReturnType; }; + messageRevisions: { + broadcastRevision: ReturnType; + createSignedRevision: ReturnType; + persistRevision: ReturnType; + }; } 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[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(), 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 }; } diff --git a/toju-app/src/app/domains/plugins/application/services/plugin-client-api.service.ts b/toju-app/src/app/domains/plugins/application/services/plugin-client-api.service.ts index c63848b..3b5bab2 100644 --- a/toju-app/src/app/domains/plugins/application/services/plugin-client-api.service.ts +++ b/toju-app/src/app/domains/plugins/application/services/plugin-client-api.service.ts @@ -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 { + 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; + legacyBroadcast: (editedAt: number) => ChatEvent; + dispatch: (message: Message) => Action; + } + ): Promise { + 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 diff --git a/toju-app/src/app/domains/plugins/application/services/plugin-host.service.ts b/toju-app/src/app/domains/plugins/application/services/plugin-host.service.ts index ca94260..ebf87b8 100644 --- a/toju-app/src/app/domains/plugins/application/services/plugin-host.service.ts +++ b/toju-app/src/app/domains/plugins/application/services/plugin-host.service.ts @@ -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 { + private async createLocalModuleObjectUrl(entrypointUrl: string, sourcePath?: string): Promise { 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 { + private async createRemoteModuleObjectUrl(entrypointUrl: string, manifest: TojuPluginManifest): Promise { + 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 { + 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(); diff --git a/toju-app/src/app/domains/plugins/application/services/plugin-requirement.service.ts b/toju-app/src/app/domains/plugins/application/services/plugin-requirement.service.ts index 59bb545..dc225d8 100644 --- a/toju-app/src/app/domains/plugins/application/services/plugin-requirement.service.ts +++ b/toju-app/src/app/domains/plugins/application/services/plugin-requirement.service.ts @@ -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` ); } diff --git a/toju-app/src/app/domains/plugins/application/services/plugin-store.service.spec.ts b/toju-app/src/app/domains/plugins/application/services/plugin-store.service.spec.ts index 2d024b1..5105e23 100644 --- a/toju-app/src/app/domains/plugins/application/services/plugin-store.service.spec.ts +++ b/toju-app/src/app/domains/plugins/application/services/plugin-store.service.spec.ts @@ -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; getAppDataPath?: () => Promise; - readFile?: (filePath: string) => Promise; + grantPluginReadRoot?: (rootPath: string) => Promise; + readFile?: (filePath: string) => Promise; writeFile?: (filePath: string, data: string) => Promise; } | null = null ): PluginStoreService { diff --git a/toju-app/src/app/domains/plugins/application/services/plugin-store.service.ts b/toju-app/src/app/domains/plugins/application/services/plugin-store.service.ts index b7e22f3..f510ded 100644 --- a/toju-app/src/app/domains/plugins/application/services/plugin-store.service.ts +++ b/toju-app/src/app/domains/plugins/application/services/plugin-store.service.ts @@ -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 { + await this.ensurePluginSourceReadRoots(sourceUrls); + + if (this.stateMutated) { + return; + } + + await this.refreshSources(); + } + + private async ensurePluginSourceReadRoots(sourceUrls: readonly string[]): Promise { + await Promise.all(sourceUrls.map((sourceUrl) => this.ensurePluginSourceReadRoot(sourceUrl))); + } + + private async ensurePluginSourceReadRoot(sourceUrl: string): Promise { + if (!sourceUrl.startsWith('file://')) { + return; + } + + await grantPluginReadRoots(this.electronBridge.getApi(), sourceUrl); + } + private async readLocalFileUrl(fileUrl: string): Promise { 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); } diff --git a/toju-app/src/app/domains/plugins/domain/rules/plugin-local-file.rules.spec.ts b/toju-app/src/app/domains/plugins/domain/rules/plugin-local-file.rules.spec.ts new file mode 100644 index 0000000..9708e74 --- /dev/null +++ b/toju-app/src/app/domains/plugins/domain/rules/plugin-local-file.rules.spec.ts @@ -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' + ]); + }); +}); diff --git a/toju-app/src/app/domains/plugins/domain/rules/plugin-local-file.rules.ts b/toju-app/src/app/domains/plugins/domain/rules/plugin-local-file.rules.ts new file mode 100644 index 0000000..6445b95 --- /dev/null +++ b/toju-app/src/app/domains/plugins/domain/rules/plugin-local-file.rules.ts @@ -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[] { + const roots = new Set(); + + for (const fileUrl of fileUrls) { + if (!fileUrl?.startsWith('file://')) { + continue; + } + + roots.add(pluginReadRootForFileUrl(fileUrl)); + } + + return [...roots]; +} + +export async function grantPluginReadRoots( + api: Pick | null | undefined, + ...fileUrls: Array +): Promise { + 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; +} diff --git a/toju-app/src/app/domains/server-directory/application/services/server-directory.service.ts b/toju-app/src/app/domains/server-directory/application/services/server-directory.service.ts index e6dd165..79fa92f 100644 --- a/toju-app/src/app/domains/server-directory/application/services/server-directory.service.ts +++ b/toju-app/src/app/domains/server-directory/application/services/server-directory.service.ts @@ -266,7 +266,6 @@ export class ServerDirectoryService { updateServer( serverId: string, updates: Partial & { - currentOwnerId: string; actingRole?: string; password?: string | null; }, diff --git a/toju-app/src/app/domains/server-directory/domain/models/server-directory.model.ts b/toju-app/src/app/domains/server-directory/domain/models/server-directory.model.ts index ae39a1d..e31e3d2 100644 --- a/toju-app/src/app/domains/server-directory/domain/models/server-directory.model.ts +++ b/toju-app/src/app/domains/server-directory/domain/models/server-directory.model.ts @@ -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; } diff --git a/toju-app/src/app/domains/server-directory/infrastructure/services/server-directory-api.service.ts b/toju-app/src/app/domains/server-directory/infrastructure/services/server-directory-api.service.ts index d322dbe..a9370ae 100644 --- a/toju-app/src/app/domains/server-directory/infrastructure/services/server-directory-api.service.ts +++ b/toju-app/src/app/domains/server-directory/infrastructure/services/server-directory-api.service.ts @@ -184,7 +184,6 @@ export class ServerDirectoryApiService { updateServer( serverId: string, updates: Partial & { - currentOwnerId: string; actingRole?: string; password?: string | null; }, diff --git a/toju-app/src/app/features/dashboard/dashboard.component.html b/toju-app/src/app/features/dashboard/dashboard.component.html index e4734fd..875364d 100644 --- a/toju-app/src/app/features/dashboard/dashboard.component.html +++ b/toju-app/src/app/features/dashboard/dashboard.component.html @@ -91,7 +91,7 @@

{{ 'dashboard.openInvite' | translate }}

-

{{ invite }}

+

{{ invite.inviteId }}

{ 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', () => { diff --git a/toju-app/src/app/features/dashboard/dashboard.component.ts b/toju-app/src/app/features/dashboard/dashboard.component.ts index 3f8db25..0f5691b 100644 --- a/toju-app/src/app/features/dashboard/dashboard.component.ts +++ b/toju-app/src/app/features/dashboard/dashboard.component.ts @@ -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 { diff --git a/toju-app/src/app/features/dashboard/invite-query.util.spec.ts b/toju-app/src/app/features/dashboard/invite-query.util.spec.ts index e398c02..b932176 100644 --- a/toju-app/src/app/features/dashboard/invite-query.util.spec.ts +++ b/toju-app/src/app/features/dashboard/invite-query.util.spec.ts @@ -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' + }); }); }); diff --git a/toju-app/src/app/features/dashboard/invite-query.util.ts b/toju-app/src/app/features/dashboard/invite-query.util.ts index fbb27c2..fdef677 100644 --- a/toju-app/src/app/features/dashboard/invite-query.util.ts +++ b/toju-app/src/app/features/dashboard/invite-query.util.ts @@ -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/` - * - A full URL whose path contains `/invite/` + * - A path or URL containing `/invite/` + * - Browser invite URLs with `?server=` + * - Signal-server invite URLs where the origin is the signal server + * - `toju://invite/?server=` */ -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'; +} + diff --git a/toju-app/src/app/features/shell/title-bar/title-bar.component.ts b/toju-app/src/app/features/shell/title-bar/title-bar.component.ts index f3b0bce..0228f44 100644 --- a/toju-app/src/app/features/shell/title-bar/title-bar.component.ts +++ b/toju-app/src/app/features/shell/title-bar/title-bar.component.ts @@ -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 } }; diff --git a/toju-app/src/app/infrastructure/mobile/logic/mobile-sqlite-row-mapper.rules.ts b/toju-app/src/app/infrastructure/mobile/logic/mobile-sqlite-row-mapper.rules.ts index 4c83d18..172019e 100644 --- a/toju-app/src/app/infrastructure/mobile/logic/mobile-sqlite-row-mapper.rules.ts +++ b/toju-app/src/app/infrastructure/mobile/logic/mobile-sqlite-row-mapper.rules.ts @@ -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), diff --git a/toju-app/src/app/infrastructure/mobile/logic/mobile-sqlite-schema.rules.ts b/toju-app/src/app/infrastructure/mobile/logic/mobile-sqlite-schema.rules.ts index d300c35..d6825e5 100644 --- a/toju-app/src/app/infrastructure/mobile/logic/mobile-sqlite-schema.rules.ts +++ b/toju-app/src/app/infrastructure/mobile/logic/mobile-sqlite-schema.rules.ts @@ -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; } diff --git a/toju-app/src/app/infrastructure/persistence/browser-database-schema.ts b/toju-app/src/app/infrastructure/persistence/browser-database-schema.ts index dd78a6e..358bb80 100644 --- a/toju-app/src/app/infrastructure/persistence/browser-database-schema.ts +++ b/toju-app/src/app/infrastructure/persistence/browser-database-schema.ts @@ -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'); } diff --git a/toju-app/src/app/infrastructure/persistence/browser-database.service.ts b/toju-app/src/app/infrastructure/persistence/browser-database.service.ts index a8e10a8..3b0753a 100644 --- a/toju-app/src/app/infrastructure/persistence/browser-database.service.ts +++ b/toju-app/src/app/infrastructure/persistence/browser-database.service.ts @@ -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 { + 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. diff --git a/toju-app/src/app/infrastructure/persistence/capacitor-database.service.ts b/toju-app/src/app/infrastructure/persistence/capacitor-database.service.ts index a186bae..1a7f91c 100644 --- a/toju-app/src/app/infrastructure/persistence/capacitor-database.service.ts +++ b/toju-app/src/app/infrastructure/persistence/capacitor-database.service.ts @@ -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 { + 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 { 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 ] ); } diff --git a/toju-app/src/app/infrastructure/persistence/database.service.ts b/toju-app/src/app/infrastructure/persistence/database.service.ts index b5aca54..1810918 100644 --- a/toju-app/src/app/infrastructure/persistence/database.service.ts +++ b/toju-app/src/app/infrastructure/persistence/database.service.ts @@ -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 diff --git a/toju-app/src/app/infrastructure/persistence/electron-database.service.ts b/toju-app/src/app/infrastructure/persistence/electron-database.service.ts index a0acef5..7d561f8 100644 --- a/toju-app/src/app/infrastructure/persistence/electron-database.service.ts +++ b/toju-app/src/app/infrastructure/persistence/electron-database.service.ts @@ -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 { + 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. * diff --git a/toju-app/src/app/infrastructure/realtime/peer-connection-manager/messaging/data-channel.spec.ts b/toju-app/src/app/infrastructure/realtime/peer-connection-manager/messaging/data-channel.spec.ts index 598d4d0..5b35474 100644 --- a/toju-app/src/app/infrastructure/realtime/peer-connection-manager/messaging/data-channel.spec.ts +++ b/toju-app/src/app/infrastructure/realtime/peer-connection-manager/messaging/data-channel.spec.ts @@ -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(() => ({ diff --git a/toju-app/src/app/infrastructure/realtime/peer-connection-manager/recovery/peer-recovery.spec.ts b/toju-app/src/app/infrastructure/realtime/peer-connection-manager/recovery/peer-recovery.spec.ts index 404c751..a8bf93c 100644 --- a/toju-app/src/app/infrastructure/realtime/peer-connection-manager/recovery/peer-recovery.spec.ts +++ b/toju-app/src/app/infrastructure/realtime/peer-connection-manager/recovery/peer-recovery.spec.ts @@ -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(() => ({ diff --git a/toju-app/src/app/infrastructure/realtime/realtime-session.service.ts b/toju-app/src/app/infrastructure/realtime/realtime-session.service.ts index 06aa100..a154195 100644 --- a/toju-app/src/app/infrastructure/realtime/realtime-session.service.ts +++ b/toju-app/src/app/infrastructure/realtime/realtime-session.service.ts @@ -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) diff --git a/toju-app/src/app/infrastructure/realtime/realtime.constants.ts b/toju-app/src/app/infrastructure/realtime/realtime.constants.ts index d12cc20..ff13aad 100644 --- a/toju-app/src/app/infrastructure/realtime/realtime.constants.ts +++ b/toju-app/src/app/infrastructure/realtime/realtime.constants.ts @@ -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'; diff --git a/toju-app/src/app/infrastructure/realtime/realtime.types.ts b/toju-app/src/app/infrastructure/realtime/realtime.types.ts index 3aabe5e..39bc0a6 100644 --- a/toju-app/src/app/infrastructure/realtime/realtime.types.ts +++ b/toju-app/src/app/infrastructure/realtime/realtime.types.ts @@ -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. */ diff --git a/toju-app/src/app/infrastructure/realtime/signaling/signaling-transport-handler.ts b/toju-app/src/app/infrastructure/realtime/signaling/signaling-transport-handler.ts index 477aa9d..1b44460 100644 --- a/toju-app/src/app/infrastructure/realtime/signaling/signaling-transport-handler.ts +++ b/toju-app/src/app/infrastructure/realtime/signaling/signaling-transport-handler.ts @@ -13,6 +13,7 @@ interface SignalingTransportHandlerDependencies { signalingCoordinator: ServerSignalingCoordinator; logger: WebRTCLogger; getLocalPeerId(): string; + resolveSessionToken(signalUrl?: string): string | null; } export class SignalingTransportHandler { @@ -193,9 +194,16 @@ export class SignalingTransportHandler { 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 { if (signalUrl) { this.sendRawMessageToSignalUrl(signalUrl, { type: SIGNALING_TYPE_IDENTIFY, + token, oderId, displayName: normalizedDisplayName, description: normalizedDescription, @@ -225,6 +234,7 @@ export class SignalingTransportHandler { for (const { signalUrl: managerSignalUrl, manager } of connectedManagers) { manager.sendRawMessage({ type: SIGNALING_TYPE_IDENTIFY, + token, oderId, displayName: normalizedDisplayName, description: normalizedDescription, diff --git a/toju-app/src/app/infrastructure/realtime/signaling/signaling.manager.spec.ts b/toju-app/src/app/infrastructure/realtime/signaling/signaling.manager.spec.ts index 85a4109..1c32700 100644 --- a/toju-app/src/app/infrastructure/realtime/signaling/signaling.manager.spec.ts +++ b/toju-app/src/app/infrastructure/realtime/signaling/signaling.manager.spec.ts @@ -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']) ); diff --git a/toju-app/src/app/infrastructure/realtime/signaling/signaling.manager.ts b/toju-app/src/app/infrastructure/realtime/signaling/signaling.manager.ts index e4e0c83..76db503 100644 --- a/toju-app/src/app/infrastructure/realtime/signaling/signaling.manager.ts +++ b/toju-app/src/app/infrastructure/realtime/signaling/signaling.manager.ts @@ -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, diff --git a/toju-app/src/app/shared-kernel/chat-events.ts b/toju-app/src/app/shared-kernel/chat-events.ts index e395bca..02d6df7 100644 --- a/toju-app/src/app/shared-kernel/chat-events.ts +++ b/toju-app/src/app/shared-kernel/chat-events.ts @@ -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 diff --git a/toju-app/src/app/shared-kernel/index.ts b/toju-app/src/app/shared-kernel/index.ts index c651ded..2decbfa 100644 --- a/toju-app/src/app/shared-kernel/index.ts +++ b/toju-app/src/app/shared-kernel/index.ts @@ -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'; diff --git a/toju-app/src/app/shared-kernel/message-revision.models.ts b/toju-app/src/app/shared-kernel/message-revision.models.ts new file mode 100644 index 0000000..b7d0719 --- /dev/null +++ b/toju-app/src/app/shared-kernel/message-revision.models.ts @@ -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; +} diff --git a/toju-app/src/app/shared-kernel/message.models.ts b/toju-app/src/app/shared-kernel/message.models.ts index f2d0ed2..14bb586 100644 --- a/toju-app/src/app/shared-kernel/message.models.ts +++ b/toju-app/src/app/shared-kernel/message.models.ts @@ -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; diff --git a/toju-app/src/app/shared-kernel/plugin-system.contracts.ts b/toju-app/src/app/shared-kernel/plugin-system.contracts.ts index 9686e65..682f1f2 100644 --- a/toju-app/src/app/shared-kernel/plugin-system.contracts.ts +++ b/toju-app/src/app/shared-kernel/plugin-system.contracts.ts @@ -150,6 +150,7 @@ export interface TojuPluginManifest { bugs?: string; bundle?: { entrypoint?: string; + integrity?: string; url: string; }; capabilities?: PluginCapabilityId[]; diff --git a/toju-app/src/app/store/messages/messages-incoming.handlers.spec.ts b/toju-app/src/app/store/messages/messages-incoming.handlers.spec.ts index c5769b3..fe2c513 100644 --- a/toju-app/src/app/store/messages/messages-incoming.handlers.spec.ts +++ b/toju-app/src/app/store/messages/messages-incoming.handlers.spec.ts @@ -28,6 +28,9 @@ function createContext(overrides: Record = {}) { }, attachments: {}, debugging: {}, + messageRevisions: { + verifyRevision: vi.fn(async () => true) + }, currentUser: null, currentRoom: null, savedRooms: [], diff --git a/toju-app/src/app/store/messages/messages-incoming.handlers.ts b/toju-app/src/app/store/messages/messages-incoming.handlers.ts index 5a310c4..a43f6d3 100644 --- a/toju-app/src/app/store/messages/messages-incoming.handlers.ts +++ b/toju-app/src/app/store/messages/messages-incoming.handlers.ts @@ -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; type AttachmentMetaMap = Record; @@ -61,7 +65,7 @@ type IncomingMessageType = interface IncomingMessageEvent extends Omit { 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 { + 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> = { 'chat-message': handleChatMessage, 'message-edited': handleMessageEdited, 'message-deleted': handleMessageDeleted, + 'message-revision': handleMessageRevision, // Reactions 'reaction-added': handleReactionAdded, diff --git a/toju-app/src/app/store/messages/messages.effects.ts b/toju-app/src/app/store/messages/messages.effects.ts index f0582a3..ef84f6f 100644 --- a/toju-app/src/app/store/messages/messages.effects.ts +++ b/toju-app/src/app/store/messages/messages.effects.ts @@ -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[0], ctx).pipe( catchError((error) => { const eventRecord = event as unknown as Record; 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 diff --git a/toju-app/src/app/store/messages/messages.helpers.ts b/toju-app/src/app/store/messages/messages.helpers.ts index 9da9ce4..f3b02c3 100644 --- a/toju-app/src/app/store/messages/messages.helpers.ts +++ b/toju-app/src/app/store/messages/messages.helpers.ts @@ -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 { + 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 -): Promise> { - const map = new Map(); +): Promise> { + const map = new Map(); 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 { - 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 { + 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 { + 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) diff --git a/toju-app/src/app/store/rooms/room-settings.effects.ts b/toju-app/src/app/store/rooms/room-settings.effects.ts index 089dda1..88a728f 100644 --- a/toju-app/src/app/store/rooms/room-settings.effects.ts +++ b/toju-app/src/app/store/rooms/room-settings.effects.ts @@ -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 diff --git a/toju-app/src/app/store/rooms/room-state-sync.effects.ts b/toju-app/src/app/store/rooms/room-state-sync.effects.ts index 5b71359..386caba 100644 --- a/toju-app/src/app/store/rooms/room-state-sync.effects.ts +++ b/toju-app/src/app/store/rooms/room-state-sync.effects.ts @@ -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; } diff --git a/toju-app/src/app/store/rooms/rooms.effects.ts b/toju-app/src/app/store/rooms/rooms.effects.ts index a42ccd8..e22e262 100644 --- a/toju-app/src/app/store/rooms/rooms.effects.ts +++ b/toju-app/src/app/store/rooms/rooms.effects.ts @@ -753,7 +753,6 @@ export class RoomsEffects { }); this.serverDirectory.updateServer(roomId, { - currentOwnerId: currentUser.id, actingRole: 'host', ownerId: nextHostId, ownerPublicKey: nextHostOderId diff --git a/toju-app/src/app/store/users/users.effects.ts b/toju-app/src/app/store/users/users.effects.ts index 238f753..f17354a 100644 --- a/toju-app/src/app/store/users/users.effects.ts +++ b/toju-app/src/app/store/users/users.effects.ts @@ -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 @@ -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): string { const displayName = user.displayName?.trim(); diff --git a/toju-app/vitest.config.ts b/toju-app/vitest.config.ts index da3ac39..7cdf747 100644 --- a/toju-app/vitest.config.ts +++ b/toju-app/vitest.config.ts @@ -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'] }