Compare commits
5 Commits
85eef74bf7
...
v1.0.177
| Author | SHA1 | Date | |
|---|---|---|---|
| 80d7728e66 | |||
| 83456c018c | |||
| 9fc26b1ccf | |||
| 45675192a5 | |||
| ee293d7daf |
@@ -1,14 +1,6 @@
|
||||
name: Build Android APK
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
paths:
|
||||
- 'toju-app/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'tools/build-android-apk.sh'
|
||||
- '.gitea/workflows/build-android-apk.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -69,12 +61,39 @@ jobs:
|
||||
NODE_ENV: development
|
||||
run: npm ci
|
||||
|
||||
- name: Resolve release version
|
||||
id: version
|
||||
run: node tools/resolve-release-version.js --write-output
|
||||
|
||||
- name: Ensure draft release exists
|
||||
id: release
|
||||
env:
|
||||
GITEA_RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
run: >
|
||||
node tools/gitea-release.js ensure-draft
|
||||
--server-url "${{ github.server_url }}"
|
||||
--repository "${{ github.repository }}"
|
||||
--tag "${{ steps.version.outputs.release_tag }}"
|
||||
--target "${{ github.sha }}"
|
||||
--name "${{ steps.version.outputs.release_name }}"
|
||||
--body "Automated draft release from ${{ github.ref_name }} @ ${{ github.sha }}"
|
||||
--write-output
|
||||
|
||||
- name: Build debug APK
|
||||
run: bash tools/build-android-apk.sh
|
||||
|
||||
- name: Upload APK artifact
|
||||
uses: https://github.com/actions/upload-artifact@v4
|
||||
with:
|
||||
name: metoyou-android-debug-apk
|
||||
path: toju-app/android/app/build/outputs/apk/debug/app-debug.apk
|
||||
if-no-files-found: error
|
||||
- name: Stage Android APK
|
||||
run: |
|
||||
mkdir -p dist-android
|
||||
cp toju-app/android/app/build/outputs/apk/debug/app-debug.apk \
|
||||
"dist-android/Toju-${{ steps.version.outputs.release_version }}-android-debug.apk"
|
||||
|
||||
- name: Upload Android APK to draft release
|
||||
env:
|
||||
GITEA_RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
run: >
|
||||
node tools/gitea-release.js upload-built-assets
|
||||
--server-url "${{ github.server_url }}"
|
||||
--repository "${{ github.repository }}"
|
||||
--release-id "${{ steps.release.outputs.release_id }}"
|
||||
--dist-android dist-android
|
||||
|
||||
@@ -110,6 +110,87 @@ jobs:
|
||||
--dist-electron dist-electron
|
||||
--dist-server dist-server
|
||||
|
||||
build-android:
|
||||
needs: prepare
|
||||
runs-on: ubuntu-latest
|
||||
container: node:22
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: https://github.com/actions/checkout@v4
|
||||
|
||||
- name: Restore npm cache
|
||||
uses: https://github.com/actions/cache@v4
|
||||
with:
|
||||
path: /root/.npm
|
||||
key: npm-android-${{ hashFiles('package-lock.json') }}
|
||||
restore-keys: npm-android-
|
||||
|
||||
- name: Restore Gradle cache
|
||||
uses: https://github.com/actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
/root/.gradle/caches
|
||||
/root/.gradle/wrapper
|
||||
key: gradle-android-${{ hashFiles('toju-app/android/**/*.gradle*', 'toju-app/android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||
restore-keys: gradle-android-
|
||||
|
||||
- name: Install Android build toolchain
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends wget unzip ca-certificates gnupg
|
||||
|
||||
install -d /etc/apt/keyrings
|
||||
wget -qO - https://packages.adoptium.net/artifactory/api/gpg/key/public | gpg --dearmor -o /etc/apt/keyrings/adoptium.gpg
|
||||
echo "deb [signed-by=/etc/apt/keyrings/adoptium.gpg] https://packages.adoptium.net/artifactory/deb bookworm main" > /etc/apt/sources.list.d/adoptium.list
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends temurin-21-jdk
|
||||
|
||||
export ANDROID_SDK_ROOT=/opt/android-sdk
|
||||
mkdir -p "$ANDROID_SDK_ROOT/cmdline-tools"
|
||||
cd /tmp
|
||||
wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
|
||||
unzip -q commandlinetools-linux-11076708_latest.zip
|
||||
mv cmdline-tools "$ANDROID_SDK_ROOT/cmdline-tools/latest"
|
||||
export PATH="$PATH:$ANDROID_SDK_ROOT/cmdline-tools/latest/bin:$ANDROID_SDK_ROOT/platform-tools"
|
||||
|
||||
yes | sdkmanager --licenses >/dev/null
|
||||
sdkmanager "platform-tools" "platforms;android-36" "build-tools;35.0.0"
|
||||
|
||||
echo "ANDROID_SDK_ROOT=$ANDROID_SDK_ROOT" >> "$GITHUB_ENV"
|
||||
echo "ANDROID_HOME=$ANDROID_SDK_ROOT" >> "$GITHUB_ENV"
|
||||
echo "JAVA_HOME=/usr/lib/jvm/temurin-21-jdk-amd64" >> "$GITHUB_ENV"
|
||||
echo "PATH=$PATH:$ANDROID_SDK_ROOT/cmdline-tools/latest/bin:$ANDROID_SDK_ROOT/platform-tools" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install dependencies
|
||||
env:
|
||||
NODE_ENV: development
|
||||
run: npm ci
|
||||
|
||||
- name: Set CI release version
|
||||
run: >
|
||||
node tools/set-release-version.js
|
||||
--version "${{ needs.prepare.outputs.release_version }}"
|
||||
|
||||
- name: Build debug APK
|
||||
run: bash tools/build-android-apk.sh
|
||||
|
||||
- name: Stage Android APK
|
||||
run: |
|
||||
mkdir -p dist-android
|
||||
cp toju-app/android/app/build/outputs/apk/debug/app-debug.apk \
|
||||
"dist-android/Toju-${{ needs.prepare.outputs.release_version }}-android-debug.apk"
|
||||
|
||||
- name: Upload Android APK
|
||||
env:
|
||||
GITEA_RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
run: >
|
||||
node tools/gitea-release.js upload-built-assets
|
||||
--server-url "${{ github.server_url }}"
|
||||
--repository "${{ github.repository }}"
|
||||
--release-id "${{ needs.prepare.outputs.release_id }}"
|
||||
--dist-android dist-android
|
||||
|
||||
build-windows:
|
||||
needs: prepare
|
||||
runs-on: windows
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -59,6 +59,7 @@ Thumbs.db
|
||||
.env
|
||||
.certs/
|
||||
/server/data/variables.json
|
||||
/server/data/metoyou.sqlite
|
||||
dist-server/*
|
||||
|
||||
doc/**
|
||||
|
||||
@@ -124,7 +124,8 @@ Behavioral changes to any of these qualify as a feature-doc update under the rul
|
||||
- `release-draft.yml` — queues release builds on push to `main` / `master`
|
||||
- `publish-draft-release.yml` — publishes draft releases
|
||||
- `deploy-web-apps.yml` — deploys the marketing site and Docusaurus docs
|
||||
- `build-android-apk.yml` — builds a debug Capacitor Android APK on push (mobile-related paths) or manual dispatch; uploads `app-debug.apk` as a workflow artifact
|
||||
- `build-android-apk.yml` — manual **workflow_dispatch** debug Capacitor Android APK build; uploads `Toju-<version>-android-debug.apk` to the draft release (same path as desktop assets)
|
||||
- `release-draft.yml` job `build-android` — builds and uploads the debug APK to each queued draft release alongside desktop/server archives
|
||||
- All checks must pass before merging a PR
|
||||
- Workflow status is visible in the Gitea PR view; use the web UI or `tea` CLI to inspect runs
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -25,6 +25,34 @@ Durable rules for AI agents working on this project. Read this file at session s
|
||||
|
||||
## Lessons
|
||||
|
||||
### Store clientInstanceId in sessionStorage not localStorage [realtime] [multi-device]
|
||||
|
||||
- **Trigger:** same user logged in on two tabs, browsers, or synced profiles sees alternating "Disconnected from signaling server" and no cross-device chat/voice sync.
|
||||
- **Rule:** persist `metoyou.clientInstanceId` in `sessionStorage` (one id per tab/window) and clear any legacy `localStorage` copy on first read.
|
||||
- **Why:** server identify evicts stale sockets with the same `(oderId, connectionScope, clientInstanceId)` tuple; a shared localStorage id makes each client kick the other in a reconnect loop.
|
||||
- **Example:** `ClientInstanceService.getClientInstanceId()` writes to `sessionStorage`; two tabs get different ids and stay connected simultaneously.
|
||||
|
||||
### Revalidate IndexedDB scope without reinitializing on every read [persistence] [performance]
|
||||
|
||||
- **Trigger:** `DatabaseService.ensureReady()` called `initialize()` before every delegated read/write to fix user-scope races.
|
||||
- **Rule:** cache the last validated `metoyou_currentUserId` and only re-run backend initialization when that scope changes or an in-flight initialize completes with a different scope.
|
||||
- **Why:** per-operation revalidation fans out across ban lookups, room loads, and message reads, causing channel/chat UI to stay blank until repeated server clicks eventually win the race.
|
||||
- **Example:** `ensureReady()` returns immediately when `isReady()` and `validatedUserScope` still match `getStoredCurrentUserId()`.
|
||||
|
||||
### Restore local user scope before protected writes [authentication] [persistence]
|
||||
|
||||
- **Trigger:** a logged-in in-memory user can create rooms or messages after `metoyou_currentUserId` was cleared by a late session-expired path.
|
||||
- **Rule:** before protected local persistence or server-directory actions, restore `metoyou_currentUserId` from the current user and avoid treating a live current user as unauthenticated.
|
||||
- **Why:** otherwise rooms/messages fall into the anonymous IndexedDB scope, and route checks redirect to login even though NgRx still has the authenticated user.
|
||||
- **Example:** `MessagesEffects.sendMessage$`, `RoomsEffects.createRoom$`, and server-directory create/join components call `setStoredCurrentUserId(currentUser.id)` before writing or joining.
|
||||
|
||||
### 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.
|
||||
|
||||
13
agents-docs/adr/0002-session-token-authentication.md
Normal file
13
agents-docs/adr/0002-session-token-authentication.md
Normal file
@@ -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.
|
||||
16
agents-docs/adr/0003-multi-client-sessions.md
Normal file
16
agents-docs/adr/0003-multi-client-sessions.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# ADR-0003: Multi-Client Sessions with Connection-Scoped Routing
|
||||
|
||||
## Status
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
Users expect to stay logged in on multiple devices simultaneously (Discord-style). The signaling server already issued multiple session tokens per user, but WebSocket broadcasts deduplicated by `oderId`, which prevented a user's second device from receiving chat, typing, or voice-state updates from their first device. Voice had no per-device identity, so two clients could both attempt to transmit audio.
|
||||
|
||||
## Decision
|
||||
Introduce a stable per-install `clientInstanceId` on the product client. Route server broadcasts by **connection id** (exclude only the sender socket) while keeping presence `user_joined` / `user_left` identity-scoped. Track `voiceActive` per connection; relay RTC to the voice-active socket. Enforce single voice owner per user via `VoiceState.clientInstanceId` and `voice_client_takeover` handoff between connections.
|
||||
|
||||
## Consequences
|
||||
- **Positive:** Chat and presence sync across a user's devices; voice behaves like Discord (one transmitting client, passive viewers, explicit takeover).
|
||||
- **Positive:** Stale-tab hygiene uses `(oderId, connectionScope, clientInstanceId)` eviction without kicking other devices.
|
||||
- **Negative:** `findUserByOderId` semantics change — RTC now prefers voice-active connections; callers must not assume one socket per user.
|
||||
- **Negative:** Clients must include `clientInstanceId` on identify and voice payloads; older builds without it still work but cannot participate in multi-device voice exclusivity reliably.
|
||||
23
agents-docs/adr/0003-signed-message-revisions.md
Normal file
23
agents-docs/adr/0003-signed-message-revisions.md
Normal file
@@ -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.
|
||||
76
agents-docs/features/authentication.md
Normal file
76
agents-docs/features/authentication.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# Authentication
|
||||
|
||||
Session-token authentication for the signaling server and product client.
|
||||
|
||||
## Trust boundaries
|
||||
|
||||
| Surface | Identity proof | Notes |
|
||||
|---|---|---|
|
||||
| Signaling server REST (mutations) | `Authorization: Bearer <token>` | 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": "<uuid>",
|
||||
"username": "alice",
|
||||
"displayName": "Alice",
|
||||
"token": "<opaque-hex>",
|
||||
"expiresAt": 1710000000000
|
||||
}
|
||||
```
|
||||
|
||||
- Tokens are opaque 64-character hex strings stored in server SQLite (`session_tokens`).
|
||||
- Default TTL: 10 years (`SESSION_TOKEN_TTL_MS` env override supported on the signaling server).
|
||||
- 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": "<session-token>",
|
||||
"oderId": "<user-id>",
|
||||
"displayName": "Alice",
|
||||
"connectionScope": "ws://host:3001",
|
||||
"clientInstanceId": "<per-install-uuid>"
|
||||
}
|
||||
```
|
||||
|
||||
- `oderId` must match the token's user id when provided.
|
||||
- `clientInstanceId` is a stable per-install UUID generated by the product client (`metoyou.clientInstanceId` in `localStorage`). The signaling server uses it to distinguish multiple WebSocket connections for the same user and to route voice ownership.
|
||||
- Server responds with `auth_error` or `auth_required` when authentication fails.
|
||||
|
||||
## Multi-device sessions
|
||||
|
||||
- Each login/register issues a **new** session token; prior tokens remain valid until they expire or the client calls `POST /api/users/logout` with that token.
|
||||
- The same user may keep multiple WebSocket connections open (different devices or browser profiles). Server broadcasts (chat, typing, voice state, status) exclude only the **sending connection**, so other connections for that identity still receive updates.
|
||||
- Voice/WebRTC is exclusive per user: only one `clientInstanceId` may own active voice at a time. Other connections show passive UI and can send `voice_client_takeover` to move voice to the local device.
|
||||
- Stale reconnect hygiene: when a client re-identifies with the same `(oderId, connectionScope, clientInstanceId)` tuple, the server closes the older socket for that tuple.
|
||||
|
||||
## 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.
|
||||
60
agents-docs/features/message-integrity.md
Normal file
60
agents-docs/features/message-integrity.md
Normal file
@@ -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.
|
||||
@@ -48,9 +48,11 @@ Config: `toju-app/capacitor.config.ts` (`webDir: ../dist/client/browser`).
|
||||
|
||||
### CI (Gitea)
|
||||
|
||||
Workflow `.gitea/workflows/build-android-apk.yml` runs automatically on push to `main` / `master` when mobile client paths change (`toju-app/**`, root lockfile, or the workflow itself). Use **workflow_dispatch** to build on demand from any branch.
|
||||
Release workflow `.gitea/workflows/release-draft.yml` builds a debug Android APK on every push to `main` / `master` (job `build-android`), stages it as `Toju-<version>-android-debug.apk`, and uploads it to the same draft Gitea release as the desktop `.exe` / `.deb` assets via `tools/gitea-release.js`.
|
||||
|
||||
The job installs JDK 21 and Android SDK platform 36 inside the `node:22` container, runs `tools/build-android-apk.sh`, and uploads `metoyou-android-debug-apk` (`app-debug.apk`) as a workflow artifact. No signing keystore is configured — output is a **debug** APK suitable for sideloading and QA.
|
||||
Manual-only workflow `.gitea/workflows/build-android-apk.yml` (**workflow_dispatch**) repeats the same build and release upload on demand from any branch.
|
||||
|
||||
Both jobs install JDK 21 and Android SDK platform 36 inside the `node:22` container and run `tools/build-android-apk.sh`. No signing keystore is configured — output is a **debug** APK suitable for sideloading and QA.
|
||||
|
||||
Optional `google-services.json` is not injected in CI; push registration in artifact builds follows the same optional-Firebase behavior as local unsigned debug builds.
|
||||
|
||||
|
||||
@@ -48,7 +48,8 @@ export const test = base.extend<MultiClientFixture>({
|
||||
|
||||
const context = await browser.newContext({
|
||||
permissions: ['microphone', 'camera'],
|
||||
baseURL: 'http://localhost:4200'
|
||||
baseURL: 'http://localhost:4200',
|
||||
viewport: { width: 1440, height: 900 }
|
||||
});
|
||||
|
||||
await installTestServerEndpoint(context, testServer.port);
|
||||
|
||||
75
e2e/helpers/auth-api.ts
Normal file
75
e2e/helpers/auth-api.ts
Normal file
@@ -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<string, string> {
|
||||
return {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
}
|
||||
|
||||
export async function registerTestUser(
|
||||
request: APIRequestContext,
|
||||
baseUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
displayName?: string
|
||||
): Promise<AuthSession> {
|
||||
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<AuthSession> {
|
||||
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<string | null> {
|
||||
return await page.evaluate(({ storageKey, url }) => {
|
||||
try {
|
||||
const store = JSON.parse(localStorage.getItem(storageKey) || '{}') as Record<string, { token: string; expiresAt: number }>;
|
||||
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 });
|
||||
}
|
||||
205
e2e/helpers/multi-device-session.ts
Normal file
205
e2e/helpers/multi-device-session.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { expect, type Page } from '@playwright/test';
|
||||
import { type Client } from '../fixtures/multi-client';
|
||||
import { LoginPage } from '../pages/login.page';
|
||||
import { RegisterPage } from '../pages/register.page';
|
||||
import { ServerSearchPage } from '../pages/server-search.page';
|
||||
import { ChatRoomPage } from '../pages/chat-room.page';
|
||||
import { ChatMessagesPage } from '../pages/chat-messages.page';
|
||||
|
||||
export const MULTI_DEVICE_PASSWORD = 'TestPass123!';
|
||||
export const MULTI_DEVICE_VOICE_CHANNEL = 'General';
|
||||
|
||||
export interface MultiDeviceCredentials {
|
||||
username: string;
|
||||
displayName: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface MultiDeviceScenario {
|
||||
clientA: Client;
|
||||
clientB: Client;
|
||||
credentials: MultiDeviceCredentials;
|
||||
serverName: string;
|
||||
messagesA: ChatMessagesPage;
|
||||
messagesB: ChatMessagesPage;
|
||||
roomA: ChatRoomPage;
|
||||
roomB: ChatRoomPage;
|
||||
}
|
||||
|
||||
export function uniqueMultiDeviceName(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 10_000)}`;
|
||||
}
|
||||
|
||||
export async function createMultiDeviceScenario(
|
||||
createClient: () => Promise<Client>,
|
||||
options: { suffix?: string; serverDescription?: string } = {}
|
||||
): Promise<MultiDeviceScenario> {
|
||||
const suffix = options.suffix ?? uniqueMultiDeviceName('multi-device');
|
||||
const credentials: MultiDeviceCredentials = {
|
||||
username: `multi_${suffix}`,
|
||||
displayName: 'Multi Device User',
|
||||
password: MULTI_DEVICE_PASSWORD
|
||||
};
|
||||
const serverName = `Multi Device Server ${suffix}`;
|
||||
|
||||
const clientA = await createClient();
|
||||
const clientB = await createClient();
|
||||
|
||||
await warmClientPage(clientA.page);
|
||||
await warmClientPage(clientB.page);
|
||||
|
||||
const registerPage = new RegisterPage(clientA.page);
|
||||
|
||||
await registerPage.goto();
|
||||
await registerPage.register(credentials.username, credentials.displayName, credentials.password);
|
||||
await expect(clientA.page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
|
||||
|
||||
const searchA = new ServerSearchPage(clientA.page);
|
||||
|
||||
await searchA.createServer(serverName, {
|
||||
description: options.serverDescription ?? 'Multi-device session coverage'
|
||||
});
|
||||
await expect(clientA.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||
await waitForCurrentRoomName(clientA.page, serverName);
|
||||
|
||||
const roomA = new ChatRoomPage(clientA.page);
|
||||
|
||||
await roomA.ensureVoiceChannelExists(MULTI_DEVICE_VOICE_CHANNEL);
|
||||
|
||||
await loginSecondDeviceIntoServer(clientB.page, credentials, serverName);
|
||||
await waitForCurrentRoomName(clientB.page, serverName);
|
||||
|
||||
const messagesA = new ChatMessagesPage(clientA.page);
|
||||
const messagesB = new ChatMessagesPage(clientB.page);
|
||||
const roomB = new ChatRoomPage(clientB.page);
|
||||
|
||||
await messagesA.waitForReady();
|
||||
await messagesB.waitForReady();
|
||||
|
||||
return {
|
||||
clientA,
|
||||
clientB,
|
||||
credentials,
|
||||
serverName,
|
||||
messagesA,
|
||||
messagesB,
|
||||
roomA,
|
||||
roomB
|
||||
};
|
||||
}
|
||||
|
||||
export async function loginSecondDeviceIntoServer(
|
||||
page: Page,
|
||||
credentials: MultiDeviceCredentials,
|
||||
serverName: string
|
||||
): Promise<void> {
|
||||
const loginPage = new LoginPage(page);
|
||||
|
||||
await loginPage.goto();
|
||||
await loginPage.login(credentials.username, credentials.password);
|
||||
await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
|
||||
|
||||
const search = new ServerSearchPage(page);
|
||||
|
||||
await search.joinServerFromSearch(serverName);
|
||||
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 });
|
||||
}
|
||||
|
||||
export async function expectCrossDeviceMessage(
|
||||
sender: ChatMessagesPage,
|
||||
receiver: ChatMessagesPage,
|
||||
message: string,
|
||||
timeout = 60_000
|
||||
): Promise<void> {
|
||||
await sender.sendMessage(message);
|
||||
|
||||
await expect.poll(async () => {
|
||||
return await receiver.getMessageItemByText(message).isVisible().catch(() => false);
|
||||
}, { timeout }).toBe(true);
|
||||
}
|
||||
|
||||
async function warmClientPage(page: Page): Promise<void> {
|
||||
await page.goto('/dashboard', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForLoadState('networkidle').catch(() => undefined);
|
||||
}
|
||||
|
||||
async function waitForCurrentRoomName(page: Page, roomName: string, timeout = 20_000): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
(expectedRoomName) => {
|
||||
interface RoomShape { name?: string }
|
||||
interface AngularDebugApi {
|
||||
getComponent: (element: Element) => Record<string, unknown>;
|
||||
}
|
||||
|
||||
const host = document.querySelector('app-rooms-side-panel');
|
||||
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
||||
|
||||
if (!host || !debugApi?.getComponent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const component = debugApi.getComponent(host);
|
||||
const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null;
|
||||
|
||||
return currentRoom?.name === expectedRoomName;
|
||||
},
|
||||
roomName,
|
||||
{ timeout }
|
||||
);
|
||||
}
|
||||
|
||||
export async function readClientInstanceId(page: Page): Promise<string | null> {
|
||||
return page.evaluate(() => localStorage.getItem('metoyou.clientInstanceId'));
|
||||
}
|
||||
|
||||
export async function logoutFromMenu(page: Page): Promise<void> {
|
||||
const menuButton = page.getByRole('button', { name: 'Menu' });
|
||||
const logoutButton = page.getByRole('button', { name: 'Logout' });
|
||||
|
||||
await expect(menuButton).toBeVisible({ timeout: 10_000 });
|
||||
await menuButton.click();
|
||||
await expect(logoutButton).toBeVisible({ timeout: 10_000 });
|
||||
await logoutButton.click();
|
||||
await expect(page).toHaveURL(/\/login/, { timeout: 15_000 });
|
||||
}
|
||||
|
||||
export function channelsSidePanel(page: Page) {
|
||||
return page.locator('app-rooms-side-panel').first();
|
||||
}
|
||||
|
||||
export function membersSidePanel(page: Page) {
|
||||
return page.locator('app-rooms-side-panel').last();
|
||||
}
|
||||
|
||||
export function passiveVoiceChannelJoinBadge(page: Page, channelName = MULTI_DEVICE_VOICE_CHANNEL) {
|
||||
return page
|
||||
.locator(`button[data-channel-type="voice"][data-channel-name="${channelName}"]`)
|
||||
.getByText('Join', { exact: true });
|
||||
}
|
||||
|
||||
export async function expectPassiveVoiceOnDevice(
|
||||
page: Page,
|
||||
options: { timeout?: number; displayName?: string; channelName?: string } = {}
|
||||
): Promise<void> {
|
||||
const timeout = options.timeout ?? 45_000;
|
||||
const channelName = options.channelName ?? MULTI_DEVICE_VOICE_CHANNEL;
|
||||
const displayName = options.displayName;
|
||||
|
||||
await expect.poll(async () => {
|
||||
const membersLabel = await membersSidePanel(page)
|
||||
.getByText('In voice on another device', { exact: false })
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
const joinBadge = await passiveVoiceChannelJoinBadge(page, channelName).isVisible().catch(() => false);
|
||||
const grayedVoiceUser = displayName
|
||||
? await channelsSidePanel(page).locator('.opacity-50').filter({ hasText: displayName }).first().isVisible().catch(() => false)
|
||||
: false;
|
||||
|
||||
return membersLabel || joinBadge || grayedVoiceUser;
|
||||
}, { timeout }).toBe(true);
|
||||
}
|
||||
|
||||
export async function expectActiveVoiceOnDevice(page: Page, timeout = 20_000): Promise<void> {
|
||||
await expect(page.locator('app-voice-controls, app-voice-workspace').first()).toBeVisible({ timeout });
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -34,9 +34,22 @@ export class ChatMessagesPage {
|
||||
}
|
||||
|
||||
async sendMessage(content: string): Promise<void> {
|
||||
let lastError: unknown;
|
||||
|
||||
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||
try {
|
||||
await this.waitForReady();
|
||||
await this.composerInput.fill(content);
|
||||
await expect(this.composerInput).toHaveValue(content, { timeout: 5_000 });
|
||||
await expect(this.sendButton).toBeEnabled({ timeout: 5_000 });
|
||||
await this.sendButton.click();
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError instanceof Error ? lastError : new Error('Failed to send chat message');
|
||||
}
|
||||
|
||||
async typeDraft(content: string): Promise<void> {
|
||||
@@ -44,6 +57,13 @@ export class ChatMessagesPage {
|
||||
await this.composerInput.fill(content);
|
||||
}
|
||||
|
||||
/** Types into the composer in a way that emits input/typing events (not just fill). */
|
||||
async typeDraftWithTypingEvents(content: string): Promise<void> {
|
||||
await this.waitForReady();
|
||||
await this.composerInput.click();
|
||||
await this.composerInput.pressSequentially(content, { delay: 40 });
|
||||
}
|
||||
|
||||
async clearDraft(): Promise<void> {
|
||||
await this.waitForReady();
|
||||
await this.composerInput.fill('');
|
||||
|
||||
@@ -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<string, { token: string; expiresAt: number }>;
|
||||
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
|
||||
})
|
||||
});
|
||||
|
||||
@@ -10,15 +10,14 @@ export class LoginPage {
|
||||
readonly registerLink: Locator;
|
||||
|
||||
constructor(private page: Page) {
|
||||
this.form = page.locator('#login-username').locator('xpath=ancestor::div[contains(@class, "space-y-3")]')
|
||||
.first();
|
||||
this.form = page.locator('form').filter({ has: page.locator('#login-username') });
|
||||
|
||||
this.usernameInput = page.locator('#login-username');
|
||||
this.passwordInput = page.locator('#login-password');
|
||||
this.serverSelect = page.locator('#login-server');
|
||||
this.submitButton = this.form.getByRole('button', { name: 'Login' });
|
||||
this.errorText = page.locator('.text-destructive');
|
||||
this.registerLink = this.form.getByRole('button', { name: 'Register' });
|
||||
this.registerLink = page.getByRole('button', { name: 'Register' });
|
||||
}
|
||||
|
||||
async goto() {
|
||||
|
||||
27
e2e/run-playwright.mjs
Normal file
27
e2e/run-playwright.mjs
Normal file
@@ -0,0 +1,27 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const e2eDirectory = fileURLToPath(new URL('.', import.meta.url));
|
||||
const env = { ...process.env };
|
||||
const browsersPath = env.PLAYWRIGHT_BROWSERS_PATH;
|
||||
|
||||
if (browsersPath?.includes('/cursor-sandbox-cache/')) {
|
||||
delete env.PLAYWRIGHT_BROWSERS_PATH;
|
||||
}
|
||||
|
||||
const [command = 'test', ...args] = process.argv.slice(2);
|
||||
const executable = process.platform === 'win32' ? 'npx.cmd' : 'npx';
|
||||
const child = spawn(executable, ['playwright', command, ...args], {
|
||||
cwd: e2eDirectory,
|
||||
env,
|
||||
stdio: 'inherit'
|
||||
});
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal);
|
||||
return;
|
||||
}
|
||||
|
||||
process.exit(code ?? 1);
|
||||
});
|
||||
153
e2e/tests/auth/login-return-url.spec.ts
Normal file
153
e2e/tests/auth/login-return-url.spec.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { test, expect } from '../../fixtures/multi-client';
|
||||
import { LoginPage } from '../../pages/login.page';
|
||||
import { RegisterPage } from '../../pages/register.page';
|
||||
|
||||
interface TestUser {
|
||||
username: string;
|
||||
displayName: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
test.describe('Login returnUrl handling', () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test('unwraps nested login returnUrl chains after successful login', async ({ createClient }) => {
|
||||
const client = await createClient();
|
||||
const { page } = client;
|
||||
const suffix = uniqueName('nested-return');
|
||||
const user: TestUser = {
|
||||
username: `user_${suffix}`,
|
||||
displayName: 'Return Url User',
|
||||
password: 'TestPass123!'
|
||||
};
|
||||
|
||||
await test.step('Create an account', async () => {
|
||||
const registerPage = new RegisterPage(page);
|
||||
|
||||
await registerPage.goto();
|
||||
await registerPage.register(user.username, user.displayName, user.password);
|
||||
await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
|
||||
});
|
||||
|
||||
await test.step('Log out and open a deeply nested login returnUrl', async () => {
|
||||
await logout(page);
|
||||
|
||||
const nestedReturnUrl = '/login?returnUrl=%2Flogin%3FreturnUrl%3D%252Fservers';
|
||||
|
||||
await page.goto(`/login?returnUrl=${encodeURIComponent(nestedReturnUrl)}`, {
|
||||
waitUntil: 'domcontentloaded'
|
||||
});
|
||||
|
||||
await expect(page.locator('#login-username')).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
await test.step('Login lands on the original destination instead of looping on /login', async () => {
|
||||
const loginPage = new LoginPage(page);
|
||||
|
||||
await loginPage.login(user.username, user.password);
|
||||
await expect(page).toHaveURL(/\/servers/, { timeout: 15_000 });
|
||||
await expect(page).not.toHaveURL(/returnUrl=.*login/);
|
||||
});
|
||||
});
|
||||
|
||||
test('redirects unauthenticated /servers visits to login and returns there after login', async ({ createClient }) => {
|
||||
const client = await createClient();
|
||||
const { page } = client;
|
||||
const suffix = uniqueName('servers-return');
|
||||
const user: TestUser = {
|
||||
username: `user_${suffix}`,
|
||||
displayName: 'Servers Return User',
|
||||
password: 'TestPass123!'
|
||||
};
|
||||
|
||||
await test.step('Create an account and log out', async () => {
|
||||
const registerPage = new RegisterPage(page);
|
||||
|
||||
await registerPage.goto();
|
||||
await registerPage.register(user.username, user.displayName, user.password);
|
||||
await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
|
||||
await logout(page);
|
||||
});
|
||||
|
||||
await test.step('Visiting /servers sends the user to a single-level login returnUrl', async () => {
|
||||
await page.goto('/servers', { waitUntil: 'domcontentloaded' });
|
||||
await expect(page).toHaveURL(/\/login/, { timeout: 15_000 });
|
||||
await expect(page).toHaveURL(/returnUrl=%2Fservers/);
|
||||
await expect(page).not.toHaveURL(/returnUrl=.*login/);
|
||||
});
|
||||
|
||||
await test.step('Logging in returns to /servers', async () => {
|
||||
const loginPage = new LoginPage(page);
|
||||
|
||||
await loginPage.login(user.username, user.password);
|
||||
await expect(page).toHaveURL(/\/servers/, { timeout: 15_000 });
|
||||
await expect(page.locator('app-server-browser')).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test('lets a returning user log back in after an expired session redirect', async ({ createClient }) => {
|
||||
const client = await createClient();
|
||||
const { page } = client;
|
||||
const suffix = uniqueName('expired-session');
|
||||
const user: TestUser = {
|
||||
username: `user_${suffix}`,
|
||||
displayName: 'Expired Session User',
|
||||
password: 'TestPass123!'
|
||||
};
|
||||
|
||||
await test.step('Create an account', async () => {
|
||||
const registerPage = new RegisterPage(page);
|
||||
|
||||
await registerPage.goto();
|
||||
await registerPage.register(user.username, user.displayName, user.password);
|
||||
await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
|
||||
});
|
||||
|
||||
await test.step('Simulate an expired session while keeping the persisted user id', async () => {
|
||||
await page.evaluate(() => {
|
||||
const storageKey = 'metoyou.authTokens';
|
||||
const raw = localStorage.getItem(storageKey);
|
||||
|
||||
if (!raw) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw) as Record<string, { token: string; expiresAt: number }>;
|
||||
const expiredStore = Object.fromEntries(
|
||||
Object.entries(parsed).map(([url, entry]) => [url, { ...entry, expiresAt: 0 }])
|
||||
);
|
||||
|
||||
localStorage.setItem(storageKey, JSON.stringify(expiredStore));
|
||||
});
|
||||
|
||||
await page.goto('/servers', { waitUntil: 'domcontentloaded' });
|
||||
await expect(page).toHaveURL(/\/login/, { timeout: 15_000 });
|
||||
await expect(page).toHaveURL(/returnUrl=%2Fservers/);
|
||||
await expect(page).not.toHaveURL(/returnUrl=.*login/);
|
||||
});
|
||||
|
||||
await test.step('The user can authenticate again and reach /servers', async () => {
|
||||
const loginPage = new LoginPage(page);
|
||||
|
||||
await loginPage.login(user.username, user.password);
|
||||
await expect(page).toHaveURL(/\/servers/, { timeout: 15_000 });
|
||||
await expect(page.locator('app-server-browser')).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function logout(page: import('@playwright/test').Page): Promise<void> {
|
||||
const menuButton = page.getByRole('button', { name: 'Menu' });
|
||||
const logoutButton = page.getByRole('button', { name: 'Logout' });
|
||||
|
||||
await expect(menuButton).toBeVisible({ timeout: 10_000 });
|
||||
await menuButton.click();
|
||||
await expect(logoutButton).toBeVisible({ timeout: 10_000 });
|
||||
await logoutButton.click();
|
||||
await expect(page).toHaveURL(/\/login/, { timeout: 15_000 });
|
||||
}
|
||||
|
||||
function uniqueName(prefix: string): string {
|
||||
return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36)
|
||||
.slice(2, 8)}`;
|
||||
}
|
||||
93
e2e/tests/auth/multi-device-session.spec.ts
Normal file
93
e2e/tests/auth/multi-device-session.spec.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
test,
|
||||
expect
|
||||
} from '../../fixtures/multi-client';
|
||||
import {
|
||||
MULTI_DEVICE_VOICE_CHANNEL,
|
||||
channelsSidePanel,
|
||||
createMultiDeviceScenario,
|
||||
expectCrossDeviceMessage,
|
||||
expectActiveVoiceOnDevice,
|
||||
expectPassiveVoiceOnDevice,
|
||||
logoutFromMenu,
|
||||
membersSidePanel,
|
||||
passiveVoiceChannelJoinBadge,
|
||||
readClientInstanceId,
|
||||
uniqueMultiDeviceName
|
||||
} from '../../helpers/multi-device-session';
|
||||
|
||||
test.describe('Multi-device session', () => {
|
||||
test.describe.configure({ timeout: 300_000, retries: 1 });
|
||||
|
||||
test('covers identity, chat sync, typing exclusion, and voice exclusivity', async ({ createClient }) => {
|
||||
const scenario = await createMultiDeviceScenario(createClient);
|
||||
const messageAtoB = `Cross-device A to B ${uniqueMultiDeviceName('msg')}`;
|
||||
const messageBtoA = `Cross-device B to A ${uniqueMultiDeviceName('msg')}`;
|
||||
const typingDraft = `Typing draft ${uniqueMultiDeviceName('draft')}`;
|
||||
|
||||
await test.step('assigns distinct clientInstanceId per browser context', async () => {
|
||||
const instanceA = await readClientInstanceId(scenario.clientA.page);
|
||||
const instanceB = await readClientInstanceId(scenario.clientB.page);
|
||||
|
||||
expect(instanceA).toBeTruthy();
|
||||
expect(instanceB).toBeTruthy();
|
||||
expect(instanceA).not.toEqual(instanceB);
|
||||
});
|
||||
|
||||
await test.step('syncs chat from device A to device B', async () => {
|
||||
await expectCrossDeviceMessage(scenario.messagesA, scenario.messagesB, messageAtoB);
|
||||
});
|
||||
|
||||
await test.step('syncs chat from device B to device A', async () => {
|
||||
await expectCrossDeviceMessage(scenario.messagesB, scenario.messagesA, messageBtoA);
|
||||
});
|
||||
|
||||
await test.step('does not show own typing indicator on the other device for the same user', async () => {
|
||||
await scenario.messagesA.typeDraftWithTypingEvents(typingDraft);
|
||||
|
||||
await expect(
|
||||
scenario.clientB.page.getByText(`${scenario.credentials.displayName} is typing`, { exact: false })
|
||||
).toHaveCount(0, { timeout: 5_000 });
|
||||
});
|
||||
|
||||
await test.step('shows passive in-voice UI on the second device when the first joins voice', async () => {
|
||||
await scenario.roomA.joinVoiceChannel(MULTI_DEVICE_VOICE_CHANNEL);
|
||||
await expectActiveVoiceOnDevice(scenario.clientA.page);
|
||||
|
||||
await expectPassiveVoiceOnDevice(scenario.clientB.page, {
|
||||
displayName: scenario.credentials.displayName
|
||||
});
|
||||
await expect(
|
||||
membersSidePanel(scenario.clientB.page).getByText('In voice on another device', { exact: false })
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
await expect(
|
||||
channelsSidePanel(scenario.clientB.page).locator('.opacity-50').filter({
|
||||
hasText: scenario.credentials.displayName
|
||||
}).first()
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
});
|
||||
|
||||
await test.step('shows Join takeover affordance on passive device voice channel', async () => {
|
||||
await expect(passiveVoiceChannelJoinBadge(scenario.clientB.page)).toBeVisible({ timeout: 20_000 });
|
||||
});
|
||||
|
||||
await test.step('transfers voice ownership when the passive device takes over', async () => {
|
||||
await scenario.roomB.joinVoiceChannel(MULTI_DEVICE_VOICE_CHANNEL);
|
||||
await expectActiveVoiceOnDevice(scenario.clientB.page);
|
||||
|
||||
await expectPassiveVoiceOnDevice(scenario.clientA.page, {
|
||||
displayName: scenario.credentials.displayName
|
||||
});
|
||||
});
|
||||
|
||||
await test.step('keeps the second device logged in when the first device logs out', async () => {
|
||||
const message = `Still logged in ${uniqueMultiDeviceName('logout')}`;
|
||||
|
||||
await logoutFromMenu(scenario.clientA.page);
|
||||
|
||||
await scenario.messagesB.sendMessage(message);
|
||||
await expect(scenario.messagesB.getMessageItemByText(message)).toBeVisible({ timeout: 20_000 });
|
||||
await expect(scenario.clientB.page).toHaveURL(/\/room\//, { timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -48,14 +48,13 @@ test.describe('User session data isolation', () => {
|
||||
|
||||
await test.step('Alice registers and creates local chat history', async () => {
|
||||
await registerUser(client.page, alice);
|
||||
await createServerAndSendMessage(client.page, aliceServerName, aliceMessage);
|
||||
await createServerAndSendMessage(client.page, alice, aliceServerName, aliceMessage);
|
||||
});
|
||||
|
||||
await test.step('Alice sees the same saved room and message after a full restart', async () => {
|
||||
await restartPersistentClient(client, testServer.port);
|
||||
await openApp(client.page);
|
||||
await expect(client.page).not.toHaveURL(/\/login/, { timeout: 15_000 });
|
||||
await expectSavedRoomAndHistory(client.page, aliceServerName, aliceMessage);
|
||||
await expectSavedRoomAndHistory(client.page, alice, aliceServerName, aliceMessage);
|
||||
});
|
||||
} finally {
|
||||
await closePersistentClient(client);
|
||||
@@ -88,11 +87,11 @@ test.describe('User session data isolation', () => {
|
||||
|
||||
await test.step('Alice creates persisted local data and verifies it survives a restart', async () => {
|
||||
await registerUser(client.page, alice);
|
||||
await createServerAndSendMessage(client.page, aliceServerName, aliceMessage);
|
||||
await createServerAndSendMessage(client.page, alice, aliceServerName, aliceMessage);
|
||||
|
||||
await restartPersistentClient(client, testServer.port);
|
||||
await openApp(client.page);
|
||||
await expectSavedRoomAndHistory(client.page, aliceServerName, aliceMessage);
|
||||
await expectSavedRoomAndHistory(client.page, alice, aliceServerName, aliceMessage);
|
||||
});
|
||||
|
||||
await test.step('Bob starts from a blank slate in the same browser profile', async () => {
|
||||
@@ -102,11 +101,11 @@ test.describe('User session data isolation', () => {
|
||||
});
|
||||
|
||||
await test.step('Bob gets only his own saved room and history after a restart', async () => {
|
||||
await createServerAndSendMessage(client.page, bobServerName, bobMessage);
|
||||
await createServerAndSendMessage(client.page, bob, bobServerName, bobMessage);
|
||||
|
||||
await restartPersistentClient(client, testServer.port);
|
||||
await openApp(client.page);
|
||||
await expectSavedRoomAndHistory(client.page, bobServerName, bobMessage);
|
||||
await expectSavedRoomAndHistory(client.page, bob, bobServerName, bobMessage);
|
||||
await expectSavedRoomHidden(client.page, aliceServerName);
|
||||
});
|
||||
|
||||
@@ -117,7 +116,7 @@ test.describe('User session data isolation', () => {
|
||||
|
||||
await expectSavedRoomVisible(client.page, aliceServerName);
|
||||
await expectSavedRoomHidden(client.page, bobServerName);
|
||||
await expectSavedRoomAndHistory(client.page, aliceServerName, aliceMessage);
|
||||
await expectSavedRoomAndHistory(client.page, alice, aliceServerName, aliceMessage);
|
||||
});
|
||||
} finally {
|
||||
await closePersistentClient(client);
|
||||
@@ -194,32 +193,58 @@ async function logoutUser(page: Page): Promise<void> {
|
||||
await expect(loginPage.usernameInput).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
async function createServerAndSendMessage(page: Page, serverName: string, messageText: string): Promise<void> {
|
||||
async function createServerAndSendMessage(page: Page, user: TestUser, serverName: string, messageText: string): Promise<void> {
|
||||
const searchPage = new ServerSearchPage(page);
|
||||
const messagesPage = new ChatMessagesPage(page);
|
||||
|
||||
await searchPage.createServer(serverName, {
|
||||
description: `User session isolation coverage for ${serverName}`
|
||||
});
|
||||
await loginIfNeeded(page, user);
|
||||
await ensureCurrentUserScope(page, user);
|
||||
await page.goto('/create-server', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
if (await waitForLoginForm(page, 5_000)) {
|
||||
await loginUser(page, user);
|
||||
await page.goto('/create-server', { waitUntil: 'domcontentloaded' });
|
||||
}
|
||||
|
||||
await expect(searchPage.serverNameInput).toBeVisible({ timeout: 10_000 });
|
||||
await searchPage.serverNameInput.fill(serverName);
|
||||
await searchPage.serverDescriptionInput.fill(`User session isolation coverage for ${serverName}`);
|
||||
await searchPage.createSubmitButton.click();
|
||||
|
||||
await expect(page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||
|
||||
await messagesPage.sendMessage(messageText);
|
||||
await expect(messagesPage.getMessageItemByText(messageText)).toBeVisible({ timeout: 20_000 });
|
||||
await expectMessagePersistedInIndexedDb(page, messageText);
|
||||
}
|
||||
|
||||
async function expectSavedRoomAndHistory(page: Page, roomName: string, messageText: string): Promise<void> {
|
||||
const railRoomButton = getRailSavedRoomButton(page, roomName);
|
||||
const messagesPage = new ChatMessagesPage(page);
|
||||
async function expectSavedRoomAndHistory(page: Page, user: TestUser, roomName: string, messageText: string): Promise<void> {
|
||||
if (await waitForVisibleText(page, messageText, 5_000)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await expect(railRoomButton).toBeVisible({ timeout: 20_000 });
|
||||
await page.goto('/servers', { waitUntil: 'domcontentloaded' });
|
||||
const searchRoomButton = getSearchSavedRoomButton(page, roomName);
|
||||
if (await new LoginPage(page).usernameInput.isVisible().catch(() => false)) {
|
||||
await loginUser(page, user);
|
||||
}
|
||||
|
||||
await expect(searchRoomButton).toBeVisible({ timeout: 20_000 });
|
||||
await searchRoomButton.click();
|
||||
await expectMessagePersistedInIndexedDb(page, messageText);
|
||||
|
||||
const persistedRoomId = await getPersistedRoomIdForMessage(page, messageText);
|
||||
|
||||
if (persistedRoomId) {
|
||||
await openPersistedRoomById(page, user, persistedRoomId);
|
||||
await expect(page.getByText(messageText, { exact: false })).toBeVisible({ timeout: 20_000 });
|
||||
return;
|
||||
}
|
||||
|
||||
if (await openSavedRoomFromRail(page, roomName)) {
|
||||
await expect(page.getByText(messageText, { exact: false })).toBeVisible({ timeout: 20_000 });
|
||||
return;
|
||||
}
|
||||
|
||||
await joinServerFromSearchAfterLogin(page, user, roomName);
|
||||
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
await expect(messagesPage.getMessageItemByText(messageText)).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByText(messageText, { exact: false })).toBeVisible({ timeout: 20_000 });
|
||||
}
|
||||
|
||||
async function expectBlankSlate(page: Page, hiddenRoomNames: string[]): Promise<void> {
|
||||
@@ -232,14 +257,17 @@ async function expectBlankSlate(page: Page, hiddenRoomNames: string[]): Promise<
|
||||
}
|
||||
|
||||
async function expectSavedRoomVisible(page: Page, roomName: string): Promise<void> {
|
||||
await expect(getRailSavedRoomButton(page, roomName)).toBeVisible({ timeout: 20_000 });
|
||||
if (await page.getByText(roomName, { exact: false }).first()
|
||||
.isVisible()
|
||||
.catch(() => false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await page.goto('/servers', { waitUntil: 'domcontentloaded' });
|
||||
await expect(getSearchSavedRoomButton(page, roomName)).toBeVisible({ timeout: 20_000 });
|
||||
}
|
||||
|
||||
async function expectSavedRoomHidden(page: Page, roomName: string): Promise<void> {
|
||||
await expect(getRailSavedRoomButton(page, roomName)).toHaveCount(0);
|
||||
|
||||
if (!page.url().includes('/servers')) {
|
||||
await page.goto('/servers', { waitUntil: 'domcontentloaded' });
|
||||
}
|
||||
@@ -247,14 +275,227 @@ async function expectSavedRoomHidden(page: Page, roomName: string): Promise<void
|
||||
await expect(getSearchSavedRoomButton(page, roomName)).toHaveCount(0);
|
||||
}
|
||||
|
||||
function getRailSavedRoomButton(page: Page, roomName: string) {
|
||||
return page.locator(`button[title="${roomName}"]`).first();
|
||||
}
|
||||
|
||||
function getSearchSavedRoomButton(page: Page, roomName: string) {
|
||||
return page.locator('app-server-browser').getByRole('button', { name: roomName, exact: true });
|
||||
}
|
||||
|
||||
async function openSavedRoomFromRail(page: Page, roomName: string): Promise<boolean> {
|
||||
try {
|
||||
await expect(page.locator('app-servers-rail')).toBeVisible({ timeout: 10_000 });
|
||||
const clicked = await page.locator('app-servers-rail button').evaluateAll((buttons, expectedName) => {
|
||||
const expectedPrefix = expectedName.slice(0, 24);
|
||||
const button = buttons.find((candidate) => {
|
||||
const title = (candidate as HTMLButtonElement).title;
|
||||
|
||||
return title === expectedName || title.startsWith(expectedPrefix);
|
||||
}) as HTMLButtonElement | undefined;
|
||||
|
||||
button?.click();
|
||||
return !!button;
|
||||
}, roomName);
|
||||
|
||||
if (!clicked) {
|
||||
return await openSavedRoomFromDashboard(page, roomName);
|
||||
}
|
||||
|
||||
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
return true;
|
||||
} catch {
|
||||
return await openSavedRoomFromDashboard(page, roomName);
|
||||
}
|
||||
}
|
||||
|
||||
async function openSavedRoomFromDashboard(page: Page, roomName: string): Promise<boolean> {
|
||||
const roomNamePattern = new RegExp(escapeRegExp(roomName.slice(0, 24)));
|
||||
const roomButton = page.getByRole('button', { name: roomNamePattern }).first();
|
||||
|
||||
try {
|
||||
await expect(roomButton).toBeVisible({ timeout: 10_000 });
|
||||
await roomButton.click();
|
||||
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
return true;
|
||||
} catch {
|
||||
return await joinVisibleServerFromDashboard(page, roomNamePattern);
|
||||
}
|
||||
}
|
||||
|
||||
async function joinVisibleServerFromDashboard(page: Page, roomNamePattern: RegExp): Promise<boolean> {
|
||||
const serverRow = page.locator('div', { hasText: roomNamePattern }).filter({
|
||||
has: page.getByRole('button', { name: 'Join' })
|
||||
})
|
||||
.last();
|
||||
const joinButton = serverRow.getByRole('button', { name: 'Join' });
|
||||
|
||||
try {
|
||||
await expect(joinButton).toBeVisible({ timeout: 10_000 });
|
||||
await joinButton.click();
|
||||
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function joinServerFromSearchAfterLogin(page: Page, user: TestUser, roomName: string): Promise<void> {
|
||||
const searchPage = new ServerSearchPage(page);
|
||||
|
||||
await loginIfNeeded(page, user);
|
||||
await searchPage.goto();
|
||||
|
||||
if (!await waitForServerSearch(page, 5_000)) {
|
||||
await loginUser(page, user);
|
||||
await searchPage.goto();
|
||||
}
|
||||
|
||||
await expect(searchPage.searchInput).toBeVisible({ timeout: 15_000 });
|
||||
await searchPage.searchInput.fill(roomName);
|
||||
|
||||
const serverCard = page.locator('div[title]', { hasText: roomName }).first();
|
||||
|
||||
await expect(serverCard).toBeVisible({ timeout: 15_000 });
|
||||
await serverCard.dblclick();
|
||||
}
|
||||
|
||||
async function loginIfNeeded(page: Page, user: TestUser): Promise<void> {
|
||||
const loginPage = new LoginPage(page);
|
||||
|
||||
if (page.url().includes('/login')) {
|
||||
await expect(loginPage.usernameInput).toBeVisible({ timeout: 15_000 });
|
||||
await loginUser(page, user);
|
||||
return;
|
||||
}
|
||||
|
||||
if (await loginPage.usernameInput.isVisible().catch(() => false)) {
|
||||
await loginUser(page, user);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureCurrentUserScope(page: Page, user: TestUser): Promise<void> {
|
||||
if (await hasCurrentUserScope(page)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await loginUser(page, user);
|
||||
await expect.poll(() => hasCurrentUserScope(page), { timeout: 10_000 }).toBe(true);
|
||||
}
|
||||
|
||||
async function hasCurrentUserScope(page: Page): Promise<boolean> {
|
||||
return page.evaluate(() => !!localStorage.getItem('metoyou_currentUserId')?.trim());
|
||||
}
|
||||
|
||||
async function openPersistedRoomById(page: Page, user: TestUser, roomId: string): Promise<void> {
|
||||
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||
await page.goto(`/room/${roomId}`, { waitUntil: 'domcontentloaded' });
|
||||
|
||||
if (await waitForLoginForm(page, 5_000)) {
|
||||
await loginUser(page, user);
|
||||
continue;
|
||||
}
|
||||
|
||||
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
|
||||
if (!await waitForLoginForm(page, 2_000)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await loginUser(page, user);
|
||||
}
|
||||
|
||||
await page.goto(`/room/${roomId}`, { waitUntil: 'domcontentloaded' });
|
||||
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
}
|
||||
|
||||
async function waitForLoginForm(page: Page, timeout: number): Promise<boolean> {
|
||||
try {
|
||||
await expect(new LoginPage(page).usernameInput).toBeVisible({ timeout });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForServerSearch(page: Page, timeout: number): Promise<boolean> {
|
||||
try {
|
||||
await expect(new ServerSearchPage(page).searchInput).toBeVisible({ timeout });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForVisibleText(page: Page, text: string, timeout: number): Promise<boolean> {
|
||||
try {
|
||||
await expect(page.getByText(text, { exact: false })).toBeVisible({ timeout });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function expectMessagePersistedInIndexedDb(page: Page, messageText: string): Promise<void> {
|
||||
await expect.poll(
|
||||
() => getPersistedRoomIdForMessage(page, messageText).then((roomId) => !!roomId),
|
||||
{ timeout: 10_000 }
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
async function getPersistedRoomIdForMessage(page: Page, messageText: string): Promise<string | null> {
|
||||
return page.evaluate(async (expectedContent) => {
|
||||
const currentUserId = localStorage.getItem('metoyou_currentUserId')?.trim();
|
||||
const preferredDatabaseName = `metoyou::${encodeURIComponent(currentUserId || 'anonymous')}`;
|
||||
const discoveredDatabaseNames = typeof indexedDB.databases === 'function'
|
||||
? (await indexedDB.databases())
|
||||
.map((database) => database.name)
|
||||
.filter((name): name is string => !!name && (name === 'metoyou' || name.startsWith('metoyou::')))
|
||||
: null;
|
||||
const databaseNames = discoveredDatabaseNames ?? [preferredDatabaseName];
|
||||
const remainingDatabaseNames = databaseNames.filter((name) => name !== preferredDatabaseName);
|
||||
const orderedDatabaseNames = databaseNames.includes(preferredDatabaseName)
|
||||
? [preferredDatabaseName].concat(remainingDatabaseNames)
|
||||
: remainingDatabaseNames;
|
||||
|
||||
for (const databaseName of orderedDatabaseNames) {
|
||||
const database = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open(databaseName);
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
});
|
||||
|
||||
try {
|
||||
if (!database.objectStoreNames.contains('messages')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const transaction = database.transaction('messages', 'readonly');
|
||||
const request = transaction.objectStore('messages').getAll();
|
||||
const roomId = await new Promise<string | null>((resolve, reject) => {
|
||||
request.onerror = () => reject(request.error);
|
||||
|
||||
request.onsuccess = () => {
|
||||
const match = ((request.result as { content?: string; roomId?: string }[]) ?? [])
|
||||
.find((message) => message.content === expectedContent);
|
||||
|
||||
resolve(match?.roomId ?? null);
|
||||
};
|
||||
});
|
||||
|
||||
if (roomId) {
|
||||
return roomId;
|
||||
}
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}, messageText);
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
async function retryTransientNavigation<T>(navigate: () => Promise<T>, attempts = 4): Promise<T> {
|
||||
let lastError: unknown;
|
||||
|
||||
|
||||
@@ -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<PluginSnapshotResponse>(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<PluginRequirementResponse>(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<PluginSnapshotResponse>(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<CreatedServerResponse> {
|
||||
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<string, string>,
|
||||
eventDefinition: ReturnType<typeof getPluginApiTestEvent>
|
||||
): Promise<PluginEventDefinitionResponse> {
|
||||
return await expectJson<PluginEventDefinitionResponse>(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<T>(response: APIResponse, status = 200): Promise<T> {
|
||||
return await response.json() as T;
|
||||
}
|
||||
|
||||
async function identifySocket(socket: TestSocket, token: string, displayName: string): Promise<void> {
|
||||
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<TestSocket> {
|
||||
const socketUrl = baseUrl.replace(/^http/, 'ws');
|
||||
const socket = new WebSocket(socketUrl);
|
||||
|
||||
@@ -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);
|
||||
@@ -140,10 +150,32 @@ test.describe('Mixed signal-config voice', () => {
|
||||
}
|
||||
});
|
||||
|
||||
let secondaryRoomId = '';
|
||||
|
||||
// ── Create rooms ────────────────────────────────────────────
|
||||
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<string, { token: string; expiresAt: number }>;
|
||||
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 +184,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 ─────────────────────────────────────
|
||||
@@ -171,26 +205,33 @@ test.describe('Mixed signal-config voice', () => {
|
||||
// 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 +504,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 +589,6 @@ async function getCurrentRoomId(page: Page): Promise<string> {
|
||||
});
|
||||
}
|
||||
|
||||
async function getCurrentUserId(page: Page): Promise<string> {
|
||||
return await page.evaluate(() => {
|
||||
interface AngularDebugApi {
|
||||
getComponent: (element: Element) => Record<string, unknown>;
|
||||
}
|
||||
|
||||
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<void> {
|
||||
|
||||
14
electron/api/auth-store.spec.ts
Normal file
14
electron/api/auth-store.spec.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
import { getLocalApiTokenTtlMs } from './auth-store';
|
||||
|
||||
const TEN_YEARS_MS = 10 * 365 * 24 * 60 * 60 * 1000;
|
||||
|
||||
describe('auth-store', () => {
|
||||
it('defaults local API tokens to a very long lifetime', () => {
|
||||
expect(getLocalApiTokenTtlMs()).toBe(TEN_YEARS_MS);
|
||||
});
|
||||
});
|
||||
@@ -10,9 +10,13 @@ export interface IssuedToken {
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
const TOKEN_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
const DEFAULT_TOKEN_TTL_MS = 10 * 365 * 24 * 60 * 60 * 1000;
|
||||
const tokens = new Map<string, IssuedToken>();
|
||||
|
||||
export function getLocalApiTokenTtlMs(): number {
|
||||
return DEFAULT_TOKEN_TTL_MS;
|
||||
}
|
||||
|
||||
export function issueToken(params: {
|
||||
userId: string;
|
||||
username: string;
|
||||
@@ -24,7 +28,7 @@ export function issueToken(params: {
|
||||
const issued: IssuedToken = {
|
||||
token,
|
||||
issuedAt,
|
||||
expiresAt: issuedAt + TOKEN_TTL_MS,
|
||||
expiresAt: issuedAt + getLocalApiTokenTtlMs(),
|
||||
userId: params.userId,
|
||||
username: params.username,
|
||||
displayName: params.displayName,
|
||||
|
||||
@@ -22,6 +22,12 @@ import {
|
||||
setupWindowControlHandlers
|
||||
} from '../ipc';
|
||||
import { startIdleMonitor, stopIdleMonitor } from '../idle/idle-monitor';
|
||||
import {
|
||||
attachRendererDiagnosticsHooks,
|
||||
ensurePerfDiagIpcRegistered,
|
||||
shutdownPerfDiagnostics,
|
||||
startPerfDiagnostics
|
||||
} from '../diagnostics';
|
||||
|
||||
function startLocalApiAfterWindowReady(): void {
|
||||
setImmediate(() => {
|
||||
@@ -32,6 +38,8 @@ function startLocalApiAfterWindowReady(): void {
|
||||
}
|
||||
|
||||
export function registerAppLifecycle(): void {
|
||||
ensurePerfDiagIpcRegistered();
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
const dockIconPath = getDockIconPath();
|
||||
|
||||
@@ -45,7 +53,15 @@ export function registerAppLifecycle(): void {
|
||||
await migrateLegacyDesktopBranding();
|
||||
await synchronizeAutoStartSetting();
|
||||
initializeDesktopUpdater();
|
||||
startPerfDiagnostics();
|
||||
await createWindow();
|
||||
|
||||
const mainWindow = getMainWindow();
|
||||
|
||||
if (mainWindow) {
|
||||
attachRendererDiagnosticsHooks(mainWindow);
|
||||
}
|
||||
|
||||
startLocalApiAfterWindowReady();
|
||||
startIdleMonitor();
|
||||
|
||||
@@ -67,6 +83,7 @@ export function registerAppLifecycle(): void {
|
||||
|
||||
app.on('before-quit', async (event) => {
|
||||
prepareWindowForAppQuit();
|
||||
await shutdownPerfDiagnostics();
|
||||
|
||||
if (getDataSource()?.isInitialized) {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -57,6 +57,8 @@ export interface MessagePayload {
|
||||
content: string;
|
||||
timestamp: number;
|
||||
editedAt?: number;
|
||||
revision?: number;
|
||||
headHash?: string;
|
||||
reactions?: ReactionPayload[];
|
||||
isDeleted?: boolean;
|
||||
replyToId?: string;
|
||||
|
||||
27
electron/diagnostics/diagnostics.flags.spec.ts
Normal file
27
electron/diagnostics/diagnostics.flags.spec.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect
|
||||
} from 'vitest';
|
||||
import { isPerfDiagEnabled } from './diagnostics.flags';
|
||||
|
||||
describe('isPerfDiagEnabled', () => {
|
||||
it('returns false when the flag is unset', () => {
|
||||
expect(isPerfDiagEnabled({}, false)).toBe(false);
|
||||
expect(isPerfDiagEnabled({}, true)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true in development when METOYOU_PERF_DIAG is truthy', () => {
|
||||
expect(isPerfDiagEnabled({ METOYOU_PERF_DIAG: '1' }, false)).toBe(true);
|
||||
expect(isPerfDiagEnabled({ METOYOU_PERF_DIAG: 'true' }, false)).toBe(true);
|
||||
expect(isPerfDiagEnabled({ METOYOU_PERF_DIAG: 'on' }, false)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false in packaged builds unless force is set', () => {
|
||||
expect(isPerfDiagEnabled({ METOYOU_PERF_DIAG: '1' }, true)).toBe(false);
|
||||
expect(isPerfDiagEnabled({
|
||||
METOYOU_PERF_DIAG: '1',
|
||||
METOYOU_PERF_DIAG_FORCE: '1'
|
||||
}, true)).toBe(true);
|
||||
});
|
||||
});
|
||||
29
electron/diagnostics/diagnostics.flags.ts
Normal file
29
electron/diagnostics/diagnostics.flags.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export const PERF_DIAG_ENV = 'METOYOU_PERF_DIAG';
|
||||
export const PERF_DIAG_FORCE_ENV = 'METOYOU_PERF_DIAG_FORCE';
|
||||
|
||||
const TRUTHY = new Set([
|
||||
'1',
|
||||
'true',
|
||||
'yes',
|
||||
'on'
|
||||
]);
|
||||
|
||||
function isTruthyFlag(value: string | undefined): boolean {
|
||||
return TRUTHY.has(String(value ?? '').trim()
|
||||
.toLowerCase());
|
||||
}
|
||||
|
||||
export function isPerfDiagEnabled(
|
||||
env: NodeJS.ProcessEnv,
|
||||
isPackaged: boolean
|
||||
): boolean {
|
||||
if (!isTruthyFlag(env[PERF_DIAG_ENV])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isPackaged && !isTruthyFlag(env[PERF_DIAG_FORCE_ENV])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
214
electron/diagnostics/diagnostics.lifecycle.ts
Normal file
214
electron/diagnostics/diagnostics.lifecycle.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
ipcMain
|
||||
} from 'electron';
|
||||
import { collectAppMetricsSnapshot } from '../app-metrics';
|
||||
import { sumWorkingSetKb } from './process-metrics.rules';
|
||||
import { isPerfDiagEnabled } from './diagnostics.flags';
|
||||
import type { PerfDiagEntry } from './diagnostics.models';
|
||||
import { PerfDiagWriter } from './diagnostics.writer';
|
||||
|
||||
const PROCESS_POLL_INTERVAL_MS = 5_000;
|
||||
|
||||
let activeWriter: PerfDiagWriter | null = null;
|
||||
let processPollTimer: NodeJS.Timeout | null = null;
|
||||
let diagnosticsEnabled = false;
|
||||
let ipcRegistered = false;
|
||||
|
||||
export function isPerfDiagActive(): boolean {
|
||||
return diagnosticsEnabled;
|
||||
}
|
||||
|
||||
export function ensurePerfDiagIpcRegistered(): void {
|
||||
if (ipcRegistered) {
|
||||
return;
|
||||
}
|
||||
|
||||
ipcRegistered = true;
|
||||
|
||||
ipcMain.handle('perf-diag-is-enabled', () => diagnosticsEnabled);
|
||||
|
||||
ipcMain.handle('perf-diag-report', (_event, entry: PerfDiagEntry) => {
|
||||
const writer = activeWriter;
|
||||
|
||||
if (!diagnosticsEnabled || !writer) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
writer.append(normalizeRendererEntry(entry));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function getActivePerfDiagWriter(): PerfDiagWriter | null {
|
||||
return activeWriter;
|
||||
}
|
||||
|
||||
export function startPerfDiagnostics(): PerfDiagWriter | null {
|
||||
ensurePerfDiagIpcRegistered();
|
||||
diagnosticsEnabled = isPerfDiagEnabled(process.env, app.isPackaged);
|
||||
|
||||
if (!diagnosticsEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sessionId = `${Date.now().toString(36)}-${process.pid}`;
|
||||
const writer = new PerfDiagWriter({
|
||||
userDataPath: app.getPath('userData'),
|
||||
sessionId
|
||||
});
|
||||
|
||||
activeWriter = writer;
|
||||
registerProcessCrashHandlers(writer);
|
||||
startProcessMetricsPolling(writer);
|
||||
|
||||
writer.append({
|
||||
collectedAt: Date.now(),
|
||||
source: 'main',
|
||||
type: 'session',
|
||||
payload: {
|
||||
event: 'started',
|
||||
sessionId,
|
||||
filePath: writer.snapshotFilePath
|
||||
}
|
||||
});
|
||||
|
||||
return writer;
|
||||
}
|
||||
|
||||
export function attachRendererDiagnosticsHooks(window: BrowserWindow): void {
|
||||
const writer = activeWriter;
|
||||
|
||||
if (!writer) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.webContents.on('render-process-gone', (_event, details) => {
|
||||
writer.append({
|
||||
collectedAt: Date.now(),
|
||||
source: 'main',
|
||||
type: 'crash',
|
||||
payload: {
|
||||
reason: details.reason,
|
||||
exitCode: details.exitCode
|
||||
}
|
||||
});
|
||||
|
||||
void writer.flushSnapshot('render-process-gone');
|
||||
});
|
||||
|
||||
window.webContents.on('unresponsive', () => {
|
||||
writer.append({
|
||||
collectedAt: Date.now(),
|
||||
source: 'main',
|
||||
type: 'unresponsive',
|
||||
payload: {}
|
||||
});
|
||||
});
|
||||
|
||||
window.webContents.on('responsive', () => {
|
||||
writer.append({
|
||||
collectedAt: Date.now(),
|
||||
source: 'main',
|
||||
type: 'session',
|
||||
payload: { event: 'renderer-responsive' }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function shutdownPerfDiagnostics(): Promise<void> {
|
||||
if (!activeWriter) {
|
||||
return;
|
||||
}
|
||||
|
||||
await activeWriter.flushSnapshot('shutdown');
|
||||
|
||||
if (processPollTimer) {
|
||||
clearInterval(processPollTimer);
|
||||
processPollTimer = null;
|
||||
}
|
||||
|
||||
activeWriter = null;
|
||||
diagnosticsEnabled = false;
|
||||
}
|
||||
|
||||
function registerProcessCrashHandlers(writer: PerfDiagWriter): void {
|
||||
app.on('child-process-gone', (_event, details) => {
|
||||
writer.append({
|
||||
collectedAt: Date.now(),
|
||||
source: 'main',
|
||||
type: 'crash',
|
||||
payload: {
|
||||
type: details.type,
|
||||
reason: details.reason,
|
||||
exitCode: details.exitCode,
|
||||
serviceName: details.serviceName ?? null,
|
||||
name: details.name ?? null
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
process.on('uncaughtException', (error) => {
|
||||
writer.append({
|
||||
collectedAt: Date.now(),
|
||||
source: 'main',
|
||||
type: 'crash',
|
||||
payload: {
|
||||
scope: 'main-uncaughtException',
|
||||
message: error.message
|
||||
}
|
||||
});
|
||||
|
||||
void writer.flushSnapshot('uncaughtException');
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
writer.append({
|
||||
collectedAt: Date.now(),
|
||||
source: 'main',
|
||||
type: 'crash',
|
||||
payload: {
|
||||
scope: 'main-unhandledRejection',
|
||||
reason: String(reason)
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function startProcessMetricsPolling(writer: PerfDiagWriter): void {
|
||||
const sample = (): void => {
|
||||
try {
|
||||
const metrics = collectAppMetricsSnapshot();
|
||||
const totalKb = sumWorkingSetKb(metrics.processes);
|
||||
|
||||
writer.append({
|
||||
collectedAt: metrics.collectedAt,
|
||||
source: 'main',
|
||||
type: 'process',
|
||||
payload: {
|
||||
totalWorkingSetKb: totalKb,
|
||||
processes: metrics.processes
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
// Collector failures must never affect the app.
|
||||
}
|
||||
};
|
||||
|
||||
sample();
|
||||
processPollTimer = setInterval(sample, PROCESS_POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
function normalizeRendererEntry(entry: PerfDiagEntry): PerfDiagEntry {
|
||||
return {
|
||||
collectedAt: Number(entry.collectedAt) || Date.now(),
|
||||
source: 'renderer',
|
||||
type: entry.type,
|
||||
payload: entry.payload ?? {}
|
||||
};
|
||||
}
|
||||
17
electron/diagnostics/diagnostics.models.ts
Normal file
17
electron/diagnostics/diagnostics.models.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export type PerfDiagSource = 'main' | 'renderer';
|
||||
|
||||
export type PerfDiagEntryType =
|
||||
| 'session'
|
||||
| 'process'
|
||||
| 'store'
|
||||
| 'components'
|
||||
| 'heap'
|
||||
| 'crash'
|
||||
| 'unresponsive';
|
||||
|
||||
export interface PerfDiagEntry {
|
||||
collectedAt: number;
|
||||
source: PerfDiagSource;
|
||||
type: PerfDiagEntryType;
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
53
electron/diagnostics/diagnostics.rules.spec.ts
Normal file
53
electron/diagnostics/diagnostics.rules.spec.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect
|
||||
} from 'vitest';
|
||||
import {
|
||||
formatPerfDiagLine,
|
||||
pushRingBuffer,
|
||||
resolveDiagnosticsFilePath
|
||||
} from './diagnostics.rules';
|
||||
|
||||
describe('pushRingBuffer', () => {
|
||||
it('appends items until capacity is reached', () => {
|
||||
expect(pushRingBuffer([1, 2], 3, 4)).toEqual([
|
||||
1,
|
||||
2,
|
||||
3
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops the oldest items when capacity is exceeded', () => {
|
||||
expect(pushRingBuffer([
|
||||
1,
|
||||
2,
|
||||
3
|
||||
], 4, 3)).toEqual([
|
||||
2,
|
||||
3,
|
||||
4
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatPerfDiagLine', () => {
|
||||
it('serializes one JSON object per line', () => {
|
||||
const line = formatPerfDiagLine({
|
||||
collectedAt: 1_700_000_000_000,
|
||||
source: 'main',
|
||||
type: 'process',
|
||||
payload: { browserKb: 128 }
|
||||
});
|
||||
|
||||
expect(line).toBe('{"collectedAt":1700000000000,"source":"main","type":"process","payload":{"browserKb":128}}');
|
||||
expect(line.endsWith('\n')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveDiagnosticsFilePath', () => {
|
||||
it('places session files under diagnostics/', () => {
|
||||
expect(resolveDiagnosticsFilePath('/tmp/user-data', 'session-1'))
|
||||
.toBe('/tmp/user-data/diagnostics/perf-session-1.jsonl');
|
||||
});
|
||||
});
|
||||
24
electron/diagnostics/diagnostics.rules.ts
Normal file
24
electron/diagnostics/diagnostics.rules.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import * as path from 'path';
|
||||
import type { PerfDiagEntry } from './diagnostics.models';
|
||||
|
||||
export function pushRingBuffer<T>(items: readonly T[], item: T, capacity: number): T[] {
|
||||
const next = [...items, item];
|
||||
|
||||
if (next.length <= capacity) {
|
||||
return next;
|
||||
}
|
||||
|
||||
return next.slice(next.length - capacity);
|
||||
}
|
||||
|
||||
export function formatPerfDiagLine(entry: PerfDiagEntry): string {
|
||||
return JSON.stringify(entry);
|
||||
}
|
||||
|
||||
export function resolveDiagnosticsFilePath(userDataPath: string, sessionId: string): string {
|
||||
return path.join(userDataPath, 'diagnostics', `perf-${sessionId}.jsonl`);
|
||||
}
|
||||
|
||||
export function resolveDiagnosticsDirectory(userDataPath: string): string {
|
||||
return path.join(userDataPath, 'diagnostics');
|
||||
}
|
||||
108
electron/diagnostics/diagnostics.writer.ts
Normal file
108
electron/diagnostics/diagnostics.writer.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import * as fsp from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import type { PerfDiagEntry } from './diagnostics.models';
|
||||
import {
|
||||
formatPerfDiagLine,
|
||||
pushRingBuffer,
|
||||
resolveDiagnosticsFilePath
|
||||
} from './diagnostics.rules';
|
||||
|
||||
const DEFAULT_RING_CAPACITY = 120;
|
||||
const FLUSH_DEBOUNCE_MS = 250;
|
||||
|
||||
export interface PerfDiagWriterOptions {
|
||||
userDataPath: string;
|
||||
sessionId: string;
|
||||
ringCapacity?: number;
|
||||
}
|
||||
|
||||
export class PerfDiagWriter {
|
||||
private readonly filePath: string;
|
||||
private readonly ringCapacity: number;
|
||||
private readonly pendingLines: string[] = [];
|
||||
private ring: PerfDiagEntry[] = [];
|
||||
private flushTimer: NodeJS.Timeout | null = null;
|
||||
private flushInFlight: Promise<void> | null = null;
|
||||
private disabled = false;
|
||||
|
||||
constructor(options: PerfDiagWriterOptions) {
|
||||
this.filePath = resolveDiagnosticsFilePath(options.userDataPath, options.sessionId);
|
||||
this.ringCapacity = options.ringCapacity ?? DEFAULT_RING_CAPACITY;
|
||||
}
|
||||
|
||||
get snapshotFilePath(): string {
|
||||
return this.filePath;
|
||||
}
|
||||
|
||||
get bufferedEntries(): readonly PerfDiagEntry[] {
|
||||
return this.ring;
|
||||
}
|
||||
|
||||
append(entry: PerfDiagEntry): void {
|
||||
if (this.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.ring = pushRingBuffer(this.ring, entry, this.ringCapacity);
|
||||
this.pendingLines.push(`${formatPerfDiagLine(entry)}\n`);
|
||||
this.scheduleFlush();
|
||||
} catch {
|
||||
this.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
if (this.disabled || this.pendingLines.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.flushInFlight) {
|
||||
await this.flushInFlight;
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = this.pendingLines.splice(0, this.pendingLines.length);
|
||||
|
||||
this.flushInFlight = this.writeLines(lines)
|
||||
.catch(() => {
|
||||
this.disabled = true;
|
||||
})
|
||||
.finally(() => {
|
||||
this.flushInFlight = null;
|
||||
});
|
||||
|
||||
await this.flushInFlight;
|
||||
}
|
||||
|
||||
async flushSnapshot(label: string): Promise<void> {
|
||||
this.append({
|
||||
collectedAt: Date.now(),
|
||||
source: 'main',
|
||||
type: 'session',
|
||||
payload: {
|
||||
event: label,
|
||||
filePath: this.filePath,
|
||||
entries: this.ring
|
||||
}
|
||||
});
|
||||
|
||||
await this.flush();
|
||||
}
|
||||
|
||||
private scheduleFlush(): void {
|
||||
if (this.flushTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.flushTimer = setTimeout(() => {
|
||||
this.flushTimer = null;
|
||||
void this.flush();
|
||||
}, FLUSH_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
private async writeLines(lines: string[]): Promise<void> {
|
||||
await fsp.mkdir(path.dirname(this.filePath), { recursive: true });
|
||||
await fsp.appendFile(this.filePath, lines.join(''), 'utf8');
|
||||
}
|
||||
}
|
||||
11
electron/diagnostics/index.ts
Normal file
11
electron/diagnostics/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export { isPerfDiagEnabled, PERF_DIAG_ENV, PERF_DIAG_FORCE_ENV } from './diagnostics.flags';
|
||||
export {
|
||||
attachRendererDiagnosticsHooks,
|
||||
ensurePerfDiagIpcRegistered,
|
||||
getActivePerfDiagWriter,
|
||||
isPerfDiagActive,
|
||||
shutdownPerfDiagnostics,
|
||||
startPerfDiagnostics
|
||||
} from './diagnostics.lifecycle';
|
||||
export type { PerfDiagEntry, PerfDiagEntryType, PerfDiagSource } from './diagnostics.models';
|
||||
export { PerfDiagWriter } from './diagnostics.writer';
|
||||
19
electron/diagnostics/process-metrics.rules.ts
Normal file
19
electron/diagnostics/process-metrics.rules.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export interface ProcessWorkingSetSnapshot {
|
||||
workingSetKb: number | null;
|
||||
}
|
||||
|
||||
export function sumWorkingSetKb(processes: readonly ProcessWorkingSetSnapshot[]): number | null {
|
||||
let total = 0;
|
||||
let hasAny = false;
|
||||
|
||||
for (const process of processes) {
|
||||
if (process.workingSetKb == null || process.workingSetKb < 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
total += process.workingSetKb;
|
||||
hasAny = true;
|
||||
}
|
||||
|
||||
return hasAny ? total : null;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -7,14 +7,25 @@ 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(() => ({
|
||||
const {
|
||||
mockGetSystemIdleTime,
|
||||
mockSend,
|
||||
mockGetMainWindow
|
||||
} = vi.hoisted(() => {
|
||||
const send = vi.fn();
|
||||
const getSystemIdleTime = vi.fn(() => 0);
|
||||
const getMainWindow = vi.fn(() => ({
|
||||
isDestroyed: () => false,
|
||||
webContents: { send: mockSend }
|
||||
webContents: { send }
|
||||
}));
|
||||
|
||||
return {
|
||||
mockGetSystemIdleTime: getSystemIdleTime,
|
||||
mockSend: send,
|
||||
mockGetMainWindow: getMainWindow
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
powerMonitor: {
|
||||
getSystemIdleTime: mockGetSystemIdleTime
|
||||
|
||||
@@ -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<string | null> {
|
||||
return await resolveReadablePath(filePath);
|
||||
}
|
||||
|
||||
async function resolveWritableUserDataFilePath(filePath: string): Promise<string | null> {
|
||||
try {
|
||||
return await assertPathUnderUserData(filePath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const MIME_TYPES_BY_EXTENSION: Record<string, string> = {
|
||||
'.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<DesktopSettings>) => {
|
||||
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;
|
||||
});
|
||||
|
||||
|
||||
15
electron/migrations/1000000000013-MessageIntegrity.ts
Normal file
15
electron/migrations/1000000000013-MessageIntegrity.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class MessageIntegrity1000000000013 implements MigrationInterface {
|
||||
name = 'MessageIntegrity1000000000013';
|
||||
|
||||
async up(queryRunner: QueryRunner): Promise<void> {
|
||||
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<void> {
|
||||
await queryRunner.query('ALTER TABLE "messages" DROP COLUMN "headHash"');
|
||||
await queryRunner.query('ALTER TABLE "messages" DROP COLUMN "revision"');
|
||||
}
|
||||
}
|
||||
70
electron/path-jail.spec.ts
Normal file
70
electron/path-jail.spec.ts
Normal file
@@ -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 });
|
||||
});
|
||||
});
|
||||
108
electron/path-jail.ts
Normal file
108
electron/path-jail.ts
Normal file
@@ -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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
return assertPathUnderRoot(app.getPath('userData'), candidatePath, allowedSubdirs);
|
||||
}
|
||||
|
||||
const grantedPluginReadRoots = new Set<string>();
|
||||
|
||||
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<string> {
|
||||
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<string | null> {
|
||||
if (typeof candidatePath !== 'string' || !candidatePath.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await assertPathUnderUserData(candidatePath);
|
||||
} catch {
|
||||
try {
|
||||
return await assertPathUnderGrantedPluginRoot(candidatePath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -252,6 +252,13 @@ export interface ElectronAPI {
|
||||
workingSetKb: number | null;
|
||||
}[];
|
||||
}>;
|
||||
isPerfDiagEnabled: () => Promise<boolean>;
|
||||
reportPerfDiagSample: (entry: {
|
||||
collectedAt: number;
|
||||
source: 'main' | 'renderer';
|
||||
type: string;
|
||||
payload: Record<string, unknown>;
|
||||
}) => Promise<boolean>;
|
||||
getAppDataPath: () => Promise<string>;
|
||||
openCurrentDataFolder: () => Promise<boolean>;
|
||||
exportUserData: () => Promise<ExportUserDataResult>;
|
||||
@@ -314,9 +321,10 @@ export interface ElectronAPI {
|
||||
relaunchApp: () => Promise<boolean>;
|
||||
onDeepLinkReceived: (listener: (url: string) => void) => () => void;
|
||||
readClipboardFiles: () => Promise<ClipboardFilePayload[]>;
|
||||
readFile: (filePath: string) => Promise<string>;
|
||||
readFileChunk: (filePath: string, start: number, end: number) => Promise<string>;
|
||||
getFileSize: (filePath: string) => Promise<number>;
|
||||
readFile: (filePath: string) => Promise<string | null>;
|
||||
readFileChunk: (filePath: string, start: number, end: number) => Promise<string | null>;
|
||||
getFileSize: (filePath: string) => Promise<number | null>;
|
||||
grantPluginReadRoot: (rootPath: string) => Promise<boolean>;
|
||||
writeFile: (filePath: string, data: string) => Promise<boolean>;
|
||||
appendFile: (filePath: string, data: string) => Promise<boolean>;
|
||||
saveFileAs: (defaultFileName: string, data: string) => Promise<{ saved: boolean; cancelled: boolean }>;
|
||||
@@ -387,6 +395,8 @@ const electronAPI: ElectronAPI = {
|
||||
};
|
||||
},
|
||||
getAppMetrics: () => ipcRenderer.invoke('get-app-metrics'),
|
||||
isPerfDiagEnabled: () => ipcRenderer.invoke('perf-diag-is-enabled'),
|
||||
reportPerfDiagSample: (entry) => ipcRenderer.invoke('perf-diag-report', entry),
|
||||
getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'),
|
||||
openCurrentDataFolder: () => ipcRenderer.invoke('open-current-data-folder'),
|
||||
exportUserData: () => ipcRenderer.invoke('export-user-data'),
|
||||
@@ -451,6 +461,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),
|
||||
|
||||
10
package.json
10
package.json
@@ -52,10 +52,12 @@
|
||||
"server:bundle:win": "node tools/package-server-executable.js --target node18-win-x64 --output metoyou-server-win-x64.exe",
|
||||
"sort:props": "node tools/sort-template-properties.js",
|
||||
"i18n:sync": "node tools/sync-app-i18n-catalog.mjs",
|
||||
"test:e2e": "cd e2e && npx playwright test",
|
||||
"test:e2e:ui": "cd e2e && npx playwright test --ui",
|
||||
"test:e2e:debug": "cd e2e && npx playwright test --debug",
|
||||
"test:e2e:report": "cd e2e && npx playwright show-report ../test-results/html-report",
|
||||
"test:e2e": "node e2e/run-playwright.mjs test",
|
||||
"test:e2e:ui": "node e2e/run-playwright.mjs test --ui",
|
||||
"test:e2e:debug": "node e2e/run-playwright.mjs test --debug",
|
||||
"test:e2e:report": "node e2e/run-playwright.mjs show-report ../test-results/html-report",
|
||||
"perf:diag:view": "node tools/perf-diag-viewer.js",
|
||||
"perf:diag:tail": "node tools/perf-diag-viewer.js --tail",
|
||||
"cap:sync": "cd toju-app && npx cap sync",
|
||||
"cap:open:android": "node tools/cap-open-android.js",
|
||||
"cap:open:ios": "cd toju-app && npx cap open ios",
|
||||
|
||||
@@ -21,6 +21,9 @@ 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`. Multiple valid tokens may exist per user (multi-device login). | "API key", "JWT" |
|
||||
| **Client instance id** | Opaque per-install string on WebSocket `identify` and `voice_state`; used to distinguish connections for the same `oderId` and to track which connection owns active voice. | "device id" |
|
||||
| **Voice-active connection** | WebSocket connection for a user with `voiceActive=true` after a connected `voice_state`; preferred target for RTC relay. | "voice owner socket" |
|
||||
|
||||
## Relationships
|
||||
|
||||
|
||||
Binary file not shown.
46
server/package-lock.json
generated
46
server/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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<string, unknown>
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
12
server/src/cqrs/commands/handlers/updateUserPasswordHash.ts
Normal file
12
server/src/cqrs/commands/handlers/updateUserPasswordHash.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { AuthUserEntity } from '../../../entities';
|
||||
|
||||
export async function handleUpdateUserPasswordHash(
|
||||
dataSource: DataSource,
|
||||
userId: string,
|
||||
passwordHash: string
|
||||
): Promise<void> {
|
||||
const repo = dataSource.getRepository(AuthUserEntity);
|
||||
|
||||
await repo.update({ id: userId }, { passwordHash });
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { AuthUserEntity } from '../../../entities';
|
||||
|
||||
export async function handleUpdateUserSigningPublicKey(
|
||||
dataSource: DataSource,
|
||||
userId: string,
|
||||
signingPublicKey: string
|
||||
): Promise<void> {
|
||||
const repo = dataSource.getRepository(AuthUserEntity);
|
||||
|
||||
await repo.update({ id: userId }, { signingPublicKey });
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface AuthUserPayload {
|
||||
passwordHash: string;
|
||||
displayName: string;
|
||||
createdAt: number;
|
||||
signingPublicKey?: string | null;
|
||||
}
|
||||
|
||||
export type ServerChannelType = 'text' | 'voice';
|
||||
|
||||
@@ -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<void> {
|
||||
PluginDataEntity,
|
||||
ServerPluginSettingsEntity,
|
||||
PluginUserMetadataEntity,
|
||||
DeviceTokenEntity
|
||||
DeviceTokenEntity,
|
||||
SessionTokenEntity
|
||||
],
|
||||
migrations: serverMigrations,
|
||||
synchronize: process.env.DB_SYNCHRONIZE === 'true',
|
||||
|
||||
@@ -20,4 +20,7 @@ export class AuthUserEntity {
|
||||
|
||||
@Column('integer')
|
||||
createdAt!: number;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
signingPublicKey!: string | null;
|
||||
}
|
||||
|
||||
22
server/src/entities/SessionTokenEntity.ts
Normal file
22
server/src/entities/SessionTokenEntity.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -18,3 +18,4 @@ export { PluginDataEntity } from './PluginDataEntity';
|
||||
export { ServerPluginSettingsEntity } from './ServerPluginSettingsEntity';
|
||||
export { PluginUserMetadataEntity } from './PluginUserMetadataEntity';
|
||||
export { DeviceTokenEntity } from './DeviceTokenEntity';
|
||||
export { SessionTokenEntity } from './SessionTokenEntity';
|
||||
|
||||
@@ -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<typeof createApp>, serverProtocol: ServerHt
|
||||
|
||||
let listeningServer: ReturnType<typeof buildServer> | null = null;
|
||||
let staleJoinRequestInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let sessionTokenPruneInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
const variablesConfig = ensureVariablesConfig();
|
||||
@@ -99,6 +101,11 @@ async function bootstrap(): Promise<void> {
|
||||
.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<void> {
|
||||
staleJoinRequestInterval = null;
|
||||
}
|
||||
|
||||
if (sessionTokenPruneInterval) {
|
||||
clearInterval(sessionTokenPruneInterval);
|
||||
sessionTokenPruneInterval = null;
|
||||
}
|
||||
|
||||
console.log(`\n[Shutdown] ${signal} received - closing database...`);
|
||||
|
||||
if (listeningServer?.listening) {
|
||||
|
||||
72
server/src/middleware/require-auth.ts
Normal file
72
server/src/middleware/require-auth.ts
Normal file
@@ -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<void> {
|
||||
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;
|
||||
}
|
||||
22
server/src/migrations/1000000000011-SessionTokens.ts
Normal file
22
server/src/migrations/1000000000011-SessionTokens.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class SessionTokens1000000000011 implements MigrationInterface {
|
||||
name = 'SessionTokens1000000000011';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
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<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "session_tokens"`);
|
||||
}
|
||||
}
|
||||
13
server/src/migrations/1000000000012-SigningPublicKey.ts
Normal file
13
server/src/migrations/1000000000012-SigningPublicKey.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class SigningPublicKey1000000000012 implements MigrationInterface {
|
||||
name = 'SigningPublicKey1000000000012';
|
||||
|
||||
async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query('ALTER TABLE "users" ADD COLUMN "signingPublicKey" text');
|
||||
}
|
||||
|
||||
async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query('ALTER TABLE "users" DROP COLUMN "signingPublicKey"');
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
];
|
||||
|
||||
@@ -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<DeviceTokenRecord>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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<typeof getAuthenticatedUserId>[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<void> {
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
45
server/src/services/password-auth.service.spec.ts
Normal file
45
server/src/services/password-auth.service.spec.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
33
server/src/services/password-auth.service.ts
Normal file
33
server/src/services/password-auth.service.ts
Normal file
@@ -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<string> {
|
||||
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<boolean> {
|
||||
if (isLegacySha256Hash(passwordHash)) {
|
||||
return crypto.createHash('sha256').update(password)
|
||||
.digest('hex') === passwordHash;
|
||||
}
|
||||
|
||||
return bcrypt.compare(password, passwordHash);
|
||||
}
|
||||
@@ -134,6 +134,34 @@ export async function countServerMemberships(serverId: string): Promise<number>
|
||||
return await getMembershipRepository().count({ where: { serverId } });
|
||||
}
|
||||
|
||||
export async function usersShareServerMembership(userA: string, userB: string): Promise<boolean> {
|
||||
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<ServerMembershipEntity> {
|
||||
const repo = getMembershipRepository();
|
||||
const now = Date.now();
|
||||
|
||||
39
server/src/services/session-auth.service.spec.ts
Normal file
39
server/src/services/session-auth.service.spec.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
afterEach,
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
import { getSessionTokenTtlMs } from './session-auth.service';
|
||||
|
||||
const TEN_YEARS_MS = 10 * 365 * 24 * 60 * 60 * 1000;
|
||||
|
||||
describe('session-auth.service', () => {
|
||||
const originalTtl = process.env.SESSION_TOKEN_TTL_MS;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalTtl === undefined) {
|
||||
delete process.env.SESSION_TOKEN_TTL_MS;
|
||||
} else {
|
||||
process.env.SESSION_TOKEN_TTL_MS = originalTtl;
|
||||
}
|
||||
});
|
||||
|
||||
it('defaults session tokens to a very long lifetime', () => {
|
||||
delete process.env.SESSION_TOKEN_TTL_MS;
|
||||
|
||||
expect(getSessionTokenTtlMs()).toBe(TEN_YEARS_MS);
|
||||
});
|
||||
|
||||
it('honors SESSION_TOKEN_TTL_MS when configured', () => {
|
||||
process.env.SESSION_TOKEN_TTL_MS = '3600000';
|
||||
|
||||
expect(getSessionTokenTtlMs()).toBe(3_600_000);
|
||||
});
|
||||
|
||||
it('falls back to the default when SESSION_TOKEN_TTL_MS is invalid', () => {
|
||||
process.env.SESSION_TOKEN_TTL_MS = 'not-a-number';
|
||||
|
||||
expect(getSessionTokenTtlMs()).toBe(TEN_YEARS_MS);
|
||||
});
|
||||
});
|
||||
110
server/src/services/session-auth.service.ts
Normal file
110
server/src/services/session-auth.service.ts
Normal file
@@ -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 = 10 * 365 * 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<IssuedSessionToken> {
|
||||
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<AuthenticatedSession | null> {
|
||||
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<void> {
|
||||
const normalized = token.trim();
|
||||
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
|
||||
await getTokenRepository().delete({ token: normalized });
|
||||
}
|
||||
|
||||
export async function revokeAllSessionTokensForUser(userId: string): Promise<void> {
|
||||
await getTokenRepository().delete({ userId });
|
||||
}
|
||||
|
||||
export async function pruneExpiredSessionTokens(): Promise<void> {
|
||||
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);
|
||||
}
|
||||
11
server/src/types/express-augmentation.ts
Normal file
11
server/src/types/express-augmentation.ts
Normal file
@@ -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 {};
|
||||
102
server/src/websocket/broadcast.spec.ts
Normal file
102
server/src/websocket/broadcast.spec.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
import { WebSocket } from 'ws';
|
||||
import { connectedUsers } from './state';
|
||||
import { ConnectedUser } from './types';
|
||||
import { broadcastToServer, findUserByOderId, findVoiceActiveConnection } from './broadcast';
|
||||
|
||||
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,
|
||||
oderId: string,
|
||||
overrides: Partial<ConnectedUser> = {}
|
||||
): ConnectedUser {
|
||||
const user: ConnectedUser = {
|
||||
oderId,
|
||||
ws: createMockWs(),
|
||||
authenticated: true,
|
||||
serverIds: new Set(['server-1']),
|
||||
displayName: 'Test User',
|
||||
lastPong: Date.now(),
|
||||
...overrides
|
||||
};
|
||||
|
||||
connectedUsers.set(connectionId, user);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
describe('broadcastToServer', () => {
|
||||
beforeEach(() => {
|
||||
connectedUsers.clear();
|
||||
});
|
||||
|
||||
it('delivers chat_message to every connection in the server except the sender connection', () => {
|
||||
createConnectedUser('conn-a1', 'user-1');
|
||||
const connA2 = createConnectedUser('conn-a2', 'user-1');
|
||||
const connB = createConnectedUser('conn-b', 'user-2');
|
||||
|
||||
broadcastToServer('server-1', { type: 'chat_message', text: 'hello' }, {
|
||||
excludeConnectionId: 'conn-a1'
|
||||
});
|
||||
|
||||
expect((connA2.ws as WebSocket & { sentMessages: string[] }).sentMessages).toHaveLength(1);
|
||||
expect((connB.ws as WebSocket & { sentMessages: string[] }).sentMessages).toHaveLength(1);
|
||||
expect(connectedUsers.get('conn-a1')?.ws).toBeDefined();
|
||||
expect((connectedUsers.get('conn-a1')!.ws as WebSocket & { sentMessages: string[] }).sentMessages).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('excludes every connection for an identity when excludeIdentityOderId is set', () => {
|
||||
const connA1 = createConnectedUser('conn-a1', 'user-1');
|
||||
const connA2 = createConnectedUser('conn-a2', 'user-1');
|
||||
const connB = createConnectedUser('conn-b', 'user-2');
|
||||
|
||||
broadcastToServer('server-1', { type: 'user_left', oderId: 'user-1' }, {
|
||||
excludeIdentityOderId: 'user-1'
|
||||
});
|
||||
|
||||
expect((connA1.ws as WebSocket & { sentMessages: string[] }).sentMessages).toHaveLength(0);
|
||||
expect((connA2.ws as WebSocket & { sentMessages: string[] }).sentMessages).toHaveLength(0);
|
||||
expect((connB.ws as WebSocket & { sentMessages: string[] }).sentMessages).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findVoiceActiveConnection', () => {
|
||||
beforeEach(() => {
|
||||
connectedUsers.clear();
|
||||
});
|
||||
|
||||
it('returns the connection marked voiceActive for the user', () => {
|
||||
createConnectedUser('conn-passive', 'user-1', { voiceActive: false });
|
||||
const active = createConnectedUser('conn-active', 'user-1', { voiceActive: true });
|
||||
|
||||
expect(findVoiceActiveConnection('user-1')).toBe(active);
|
||||
});
|
||||
|
||||
it('returns undefined when no voiceActive connection exists', () => {
|
||||
createConnectedUser('conn-1', 'user-1');
|
||||
|
||||
expect(findVoiceActiveConnection('user-1')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('findUserByOderId falls back to any open connection when no voiceActive connection exists', () => {
|
||||
const fallback = createConnectedUser('conn-1', 'user-1');
|
||||
|
||||
expect(findUserByOderId('user-1')).toBe(fallback);
|
||||
});
|
||||
});
|
||||
@@ -7,19 +7,35 @@ interface WsMessage {
|
||||
type: string;
|
||||
}
|
||||
|
||||
export function broadcastToServer(serverId: string, message: WsMessage, excludeOderId?: string): void {
|
||||
console.log(`Broadcasting to server ${serverId}, excluding ${excludeOderId}:`, message.type);
|
||||
export interface BroadcastOptions {
|
||||
/** Skip only the sending WebSocket connection. */
|
||||
excludeConnectionId?: string;
|
||||
/** Skip every open connection for this identity (presence events). */
|
||||
excludeIdentityOderId?: string;
|
||||
}
|
||||
|
||||
// Deduplicate by oderId so users with multiple connections (e.g. from
|
||||
// different signal URLs routing to the same server) receive the
|
||||
// broadcast only once.
|
||||
const sentToOderIds = new Set<string>();
|
||||
export function broadcastToServer(serverId: string, message: WsMessage, options?: BroadcastOptions): void {
|
||||
console.log(
|
||||
`Broadcasting to server ${serverId}, excluding connection ${options?.excludeConnectionId ?? 'none'} ` +
|
||||
`identity ${options?.excludeIdentityOderId ?? 'none'}:`,
|
||||
message.type
|
||||
);
|
||||
|
||||
connectedUsers.forEach((user) => {
|
||||
if (user.serverIds.has(serverId) && user.oderId !== excludeOderId && !sentToOderIds.has(user.oderId)) {
|
||||
sentToOderIds.add(user.oderId);
|
||||
console.log(` -> Sending to ${user.displayName} (${user.oderId})`);
|
||||
connectedUsers.forEach((user, connectionId) => {
|
||||
if (
|
||||
!user.serverIds.has(serverId)
|
||||
|| connectionId === options?.excludeConnectionId
|
||||
|| (options?.excludeIdentityOderId && user.oderId === options.excludeIdentityOderId)
|
||||
|| user.ws.readyState !== WebSocket.OPEN
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(` -> Sending to ${user.displayName} (${user.oderId}) via ${connectionId}`);
|
||||
user.ws.send(JSON.stringify(message));
|
||||
} catch (error) {
|
||||
console.warn(`Failed to broadcast ${message.type} to ${user.displayName ?? 'Unknown'} (${user.oderId})`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -77,7 +93,45 @@ export function notifyUser(oderId: string, message: WsMessage): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function notifyOtherConnectionsForOderId(
|
||||
oderId: string,
|
||||
message: WsMessage,
|
||||
excludeConnectionId?: string
|
||||
): void {
|
||||
connectedUsers.forEach((user, connectionId) => {
|
||||
if (
|
||||
connectionId === excludeConnectionId
|
||||
|| user.oderId !== oderId
|
||||
|| user.ws.readyState !== WebSocket.OPEN
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
user.ws.send(JSON.stringify(message));
|
||||
} catch (error) {
|
||||
console.warn(`Failed to notify ${user.displayName ?? 'Unknown'} (${user.oderId})`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function findUserByOderId(oderId: string) {
|
||||
return findVoiceActiveConnection(oderId) ?? findAnyConnectionForOderId(oderId);
|
||||
}
|
||||
|
||||
export function findVoiceActiveConnection(oderId: string): ConnectedUser | undefined {
|
||||
let voiceActiveMatch: ConnectedUser | undefined;
|
||||
|
||||
connectedUsers.forEach((user) => {
|
||||
if (user.oderId === oderId && user.voiceActive && user.ws.readyState === WebSocket.OPEN) {
|
||||
voiceActiveMatch = user;
|
||||
}
|
||||
});
|
||||
|
||||
return voiceActiveMatch;
|
||||
}
|
||||
|
||||
export function findAnyConnectionForOderId(oderId: string): ConnectedUser | undefined {
|
||||
let match: ConnectedUser | undefined;
|
||||
|
||||
connectedUsers.forEach((user) => {
|
||||
|
||||
120
server/src/websocket/handler-auth.spec.ts
Normal file
120
server/src/websocket/handler-auth.spec.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
219
server/src/websocket/handler-multi-client.spec.ts
Normal file
219
server/src/websocket/handler-multi-client.spec.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
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 () => true)
|
||||
}));
|
||||
|
||||
vi.mock('../services/session-auth.service', () => ({
|
||||
consumeSessionToken: vi.fn(async (token: string) => {
|
||||
if (token !== 'test-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
|
||||
};
|
||||
})
|
||||
}));
|
||||
|
||||
vi.mock('../services/plugin-support.service', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../services/plugin-support.service')>();
|
||||
|
||||
return {
|
||||
...actual,
|
||||
getPluginRequirementsSnapshot: vi.fn(async () => ({
|
||||
requirements: [],
|
||||
eventDefinitions: []
|
||||
}))
|
||||
};
|
||||
});
|
||||
|
||||
function createMockWs(): WebSocket & { sentMessages: string[]; closeCalled: boolean } {
|
||||
const sent: string[] = [];
|
||||
const ws = {
|
||||
readyState: WebSocket.OPEN,
|
||||
send: (data: string) => { sent.push(data); },
|
||||
close: () => { ws.closeCalled = true; },
|
||||
terminate: () => { ws.closeCalled = true; },
|
||||
closeCalled: false,
|
||||
sentMessages: sent
|
||||
} as unknown as WebSocket & { sentMessages: string[]; closeCalled: boolean };
|
||||
|
||||
return ws;
|
||||
}
|
||||
|
||||
function createConnectedUser(
|
||||
connectionId: string,
|
||||
overrides: Partial<ConnectedUser> = {}
|
||||
): ConnectedUser {
|
||||
const ws = createMockWs();
|
||||
const user: ConnectedUser = {
|
||||
oderId: connectionId,
|
||||
ws,
|
||||
authenticated: false,
|
||||
serverIds: new Set(),
|
||||
displayName: 'Alice',
|
||||
lastPong: Date.now(),
|
||||
...overrides
|
||||
};
|
||||
|
||||
connectedUsers.set(connectionId, user);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
function getSentMessages(user: ConnectedUser): string[] {
|
||||
return (user.ws as WebSocket & { sentMessages: string[] }).sentMessages;
|
||||
}
|
||||
|
||||
describe('server websocket handler - multi-client sessions', () => {
|
||||
beforeEach(() => {
|
||||
connectedUsers.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('relays voice_state to other connections for the same user', async () => {
|
||||
const sender = createConnectedUser('conn-a1', {
|
||||
authenticated: true,
|
||||
oderId: 'user-1',
|
||||
serverIds: new Set(['server-1']),
|
||||
clientInstanceId: 'device-a'
|
||||
});
|
||||
const passive = createConnectedUser('conn-a2', {
|
||||
authenticated: true,
|
||||
oderId: 'user-1',
|
||||
serverIds: new Set(['server-1']),
|
||||
clientInstanceId: 'device-b'
|
||||
});
|
||||
|
||||
getSentMessages(passive).length = 0;
|
||||
|
||||
await handleWebSocketMessage('conn-a1', {
|
||||
type: 'voice_state',
|
||||
serverId: 'server-1',
|
||||
voiceState: {
|
||||
isConnected: true,
|
||||
roomId: 'voice-1',
|
||||
serverId: 'server-1',
|
||||
clientInstanceId: 'device-a'
|
||||
}
|
||||
});
|
||||
|
||||
const messages = getSentMessages(passive).map((raw) => JSON.parse(raw) as { type: string });
|
||||
const voiceState = messages.find((message) => message.type === 'voice_state');
|
||||
|
||||
expect(voiceState).toBeDefined();
|
||||
expect(connectedUsers.get('conn-a1')?.voiceActive).toBe(true);
|
||||
expect(connectedUsers.get('conn-a2')?.voiceActive).toBeFalsy();
|
||||
});
|
||||
|
||||
it('forwards RTC offers to the voice-active connection for the target user', async () => {
|
||||
const sender = createConnectedUser('conn-sender', {
|
||||
authenticated: true,
|
||||
oderId: 'user-2',
|
||||
serverIds: new Set(['server-1'])
|
||||
});
|
||||
createConnectedUser('conn-passive', {
|
||||
authenticated: true,
|
||||
oderId: 'user-1',
|
||||
serverIds: new Set(['server-1']),
|
||||
clientInstanceId: 'device-passive'
|
||||
});
|
||||
const active = createConnectedUser('conn-active', {
|
||||
authenticated: true,
|
||||
oderId: 'user-1',
|
||||
serverIds: new Set(['server-1']),
|
||||
voiceActive: true,
|
||||
clientInstanceId: 'device-active'
|
||||
});
|
||||
|
||||
getSentMessages(active).length = 0;
|
||||
|
||||
await handleWebSocketMessage('conn-sender', {
|
||||
type: 'offer',
|
||||
targetUserId: 'user-1',
|
||||
serverId: 'server-1',
|
||||
payload: { sdp: { type: 'offer', sdp: 'v=0' } }
|
||||
});
|
||||
|
||||
const messages = getSentMessages(active).map((raw) => JSON.parse(raw) as { type: string });
|
||||
|
||||
expect(messages.some((message) => message.type === 'offer')).toBe(true);
|
||||
});
|
||||
|
||||
it('relays voice_client_takeover to other connections for the same user', async () => {
|
||||
createConnectedUser('conn-requester', {
|
||||
authenticated: true,
|
||||
oderId: 'user-1',
|
||||
serverIds: new Set(['server-1']),
|
||||
clientInstanceId: 'device-b'
|
||||
});
|
||||
const active = createConnectedUser('conn-active', {
|
||||
authenticated: true,
|
||||
oderId: 'user-1',
|
||||
serverIds: new Set(['server-1']),
|
||||
voiceActive: true,
|
||||
clientInstanceId: 'device-a'
|
||||
});
|
||||
|
||||
getSentMessages(active).length = 0;
|
||||
|
||||
await handleWebSocketMessage('conn-requester', {
|
||||
type: 'voice_client_takeover',
|
||||
clientInstanceId: 'device-b'
|
||||
});
|
||||
|
||||
const messages = getSentMessages(active).map((raw) => JSON.parse(raw) as { type: string; clientInstanceId?: string });
|
||||
const takeover = messages.find((message) => message.type === 'voice_client_takeover');
|
||||
|
||||
expect(takeover?.clientInstanceId).toBe('device-b');
|
||||
});
|
||||
|
||||
it('evicts a stale connection with the same identity scope and client instance', async () => {
|
||||
const stale = createConnectedUser('conn-stale', {
|
||||
authenticated: true,
|
||||
oderId: 'user-1',
|
||||
connectionScope: 'ws://localhost:3001',
|
||||
clientInstanceId: 'device-a'
|
||||
});
|
||||
createConnectedUser('conn-new', {
|
||||
authenticated: false,
|
||||
connectionScope: 'ws://localhost:3001',
|
||||
clientInstanceId: 'device-a'
|
||||
});
|
||||
|
||||
await handleWebSocketMessage('conn-new', {
|
||||
type: 'identify',
|
||||
token: 'test-token',
|
||||
oderId: 'user-1',
|
||||
displayName: 'Alice',
|
||||
connectionScope: 'ws://localhost:3001',
|
||||
clientInstanceId: 'device-a'
|
||||
});
|
||||
|
||||
expect(connectedUsers.has('conn-stale')).toBe(false);
|
||||
expect((stale.ws as WebSocket & { closeCalled: boolean }).closeCalled).toBe(true);
|
||||
expect(connectedUsers.get('conn-new')?.authenticated).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -63,6 +63,7 @@ function createConnectedUser(
|
||||
displayName: `User ${oderId}`,
|
||||
lastPong: Date.now(),
|
||||
oderId,
|
||||
authenticated: true,
|
||||
serverIds: new Set(),
|
||||
ws: createMockWs(),
|
||||
...overrides
|
||||
|
||||
@@ -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<typeof import('../services/plugin-support.service')>();
|
||||
|
||||
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/'
|
||||
|
||||
@@ -5,9 +5,15 @@ import {
|
||||
findUserByOderId,
|
||||
getServerIdsForOderId,
|
||||
getUniqueUsersInServer,
|
||||
isOderIdConnectedToServer
|
||||
isOderIdConnectedToServer,
|
||||
notifyOtherConnectionsForOderId
|
||||
} 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,
|
||||
@@ -67,6 +73,74 @@ function buildPresenceUserPayload(user: ConnectedUser): {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeClientInstanceId(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized = value.trim();
|
||||
|
||||
return normalized || undefined;
|
||||
}
|
||||
|
||||
function readVoiceConnected(message: WsMessage): boolean {
|
||||
const voiceState = message['voiceState'];
|
||||
|
||||
if (!voiceState || typeof voiceState !== 'object') {
|
||||
return message['isConnected'] === true;
|
||||
}
|
||||
|
||||
return (voiceState as { isConnected?: boolean }).isConnected === true;
|
||||
}
|
||||
|
||||
function evictStaleClientInstanceConnections(
|
||||
oderId: string,
|
||||
connectionScope: string | undefined,
|
||||
clientInstanceId: string | undefined,
|
||||
keepConnectionId: string
|
||||
): void {
|
||||
if (!clientInstanceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
connectedUsers.forEach((candidate, connectionId) => {
|
||||
if (
|
||||
connectionId === keepConnectionId
|
||||
|| candidate.oderId !== oderId
|
||||
|| candidate.connectionScope !== connectionScope
|
||||
|| candidate.clientInstanceId !== clientInstanceId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
candidate.ws.close();
|
||||
} catch {
|
||||
console.warn(`Failed to close stale connection ${connectionId} for ${oderId}`);
|
||||
}
|
||||
|
||||
connectedUsers.delete(connectionId);
|
||||
});
|
||||
}
|
||||
|
||||
function clearVoiceActiveForOderId(oderId: string, exceptConnectionId?: string): void {
|
||||
connectedUsers.forEach((candidate, connectionId) => {
|
||||
if (candidate.oderId !== oderId || connectionId === exceptConnectionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
candidate.voiceActive = false;
|
||||
connectedUsers.set(connectionId, candidate);
|
||||
});
|
||||
}
|
||||
|
||||
function sendVoiceStateSnapshotToConnection(user: ConnectedUser, snapshot: Record<string, unknown>): void {
|
||||
user.ws.send(JSON.stringify({
|
||||
type: 'voice_state',
|
||||
...snapshot
|
||||
}));
|
||||
}
|
||||
|
||||
function readMessageId(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined;
|
||||
@@ -131,15 +205,79 @@ 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<void> {
|
||||
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 newClientInstanceId = normalizeClientInstanceId(message['clientInstanceId']);
|
||||
const previousDisplayName = normalizeDisplayName(user.displayName);
|
||||
const previousDescription = user.description;
|
||||
const previousProfileUpdatedAt = user.profileUpdatedAt;
|
||||
const previousHomeSignalServerUrl = user.homeSignalServerUrl;
|
||||
|
||||
evictStaleClientInstanceConnections(newOderId, newScope, newClientInstanceId, connectionId);
|
||||
|
||||
user.oderId = newOderId;
|
||||
user.authenticated = true;
|
||||
user.clientInstanceId = newClientInstanceId;
|
||||
user.displayName = normalizeDisplayName(message['displayName'], normalizeDisplayName(user.displayName));
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(message, 'description')) {
|
||||
@@ -158,6 +296,17 @@ function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: s
|
||||
connectedUsers.set(connectionId, user);
|
||||
console.log(`User identified: ${user.displayName} (${user.oderId})`);
|
||||
|
||||
const voiceSnapshot = Array.from(connectedUsers.entries()).find(([otherConnectionId, otherUser]) =>
|
||||
otherConnectionId !== connectionId
|
||||
&& otherUser.oderId === newOderId
|
||||
&& otherUser.voiceActive
|
||||
&& otherUser.voiceStateSnapshot
|
||||
)?.[1]?.voiceStateSnapshot;
|
||||
|
||||
if (voiceSnapshot) {
|
||||
sendVoiceStateSnapshotToConnection(user, voiceSnapshot);
|
||||
}
|
||||
|
||||
if (
|
||||
user.displayName === previousDisplayName
|
||||
&& user.description === previousDescription
|
||||
@@ -175,7 +324,7 @@ function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: s
|
||||
...buildPresenceUserPayload(user),
|
||||
serverId
|
||||
},
|
||||
user.oderId
|
||||
{ excludeConnectionId: connectionId }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -222,7 +371,7 @@ async function handleJoinServer(user: ConnectedUser, message: WsMessage, connect
|
||||
...buildPresenceUserPayload(user),
|
||||
serverId: sid
|
||||
},
|
||||
user.oderId
|
||||
{ excludeIdentityOderId: user.oderId }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -273,15 +422,49 @@ function handleLeaveServer(user: ConnectedUser, message: WsMessage, connectionId
|
||||
serverId: leaveSid,
|
||||
serverIds: remainingServerIds
|
||||
},
|
||||
user.oderId
|
||||
{ excludeIdentityOderId: user.oderId }
|
||||
);
|
||||
}
|
||||
|
||||
function forwardRtcMessage(user: ConnectedUser, message: WsMessage): void {
|
||||
async function canForwardRtcMessage(user: ConnectedUser, message: WsMessage, targetUserId: string): Promise<boolean> {
|
||||
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<void> {
|
||||
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) {
|
||||
@@ -295,7 +478,7 @@ function forwardRtcMessage(user: ConnectedUser, message: WsMessage): void {
|
||||
}
|
||||
}
|
||||
|
||||
function handleChatMessage(user: ConnectedUser, message: WsMessage): void {
|
||||
function handleChatMessage(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||
const chatSid = (message['serverId'] as string | undefined) ?? user.viewedServerId;
|
||||
|
||||
if (chatSid && user.serverIds.has(chatSid)) {
|
||||
@@ -305,18 +488,38 @@ function handleChatMessage(user: ConnectedUser, message: WsMessage): void {
|
||||
message: message['message'],
|
||||
senderId: user.oderId,
|
||||
senderName: user.displayName,
|
||||
clientInstanceId: user.clientInstanceId,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}, { excludeConnectionId: connectionId });
|
||||
}
|
||||
}
|
||||
|
||||
function handleVoiceState(user: ConnectedUser, message: WsMessage): void {
|
||||
function handleVoiceState(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||
const serverId = readMessageId(message['serverId']) ?? user.viewedServerId;
|
||||
|
||||
if (!serverId || !user.serverIds.has(serverId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isConnected = readVoiceConnected(message);
|
||||
|
||||
if (isConnected) {
|
||||
clearVoiceActiveForOderId(user.oderId, connectionId);
|
||||
user.voiceActive = true;
|
||||
user.voiceStateSnapshot = {
|
||||
...message,
|
||||
type: 'voice_state',
|
||||
serverId,
|
||||
oderId: user.oderId,
|
||||
displayName: normalizeDisplayName(user.displayName)
|
||||
};
|
||||
} else {
|
||||
user.voiceActive = false;
|
||||
user.voiceStateSnapshot = undefined;
|
||||
}
|
||||
|
||||
connectedUsers.set(connectionId, user);
|
||||
|
||||
broadcastToServer(
|
||||
serverId,
|
||||
{
|
||||
@@ -326,11 +529,19 @@ function handleVoiceState(user: ConnectedUser, message: WsMessage): void {
|
||||
oderId: user.oderId,
|
||||
displayName: normalizeDisplayName(user.displayName)
|
||||
},
|
||||
user.oderId
|
||||
{ excludeConnectionId: connectionId }
|
||||
);
|
||||
}
|
||||
|
||||
function handleTyping(user: ConnectedUser, message: WsMessage): void {
|
||||
function handleVoiceClientTakeover(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||
notifyOtherConnectionsForOderId(user.oderId, {
|
||||
type: 'voice_client_takeover',
|
||||
clientInstanceId: normalizeClientInstanceId(message['clientInstanceId']) ?? user.clientInstanceId,
|
||||
requestedByClientInstanceId: normalizeClientInstanceId(message['clientInstanceId']) ?? user.clientInstanceId
|
||||
}, connectionId);
|
||||
}
|
||||
|
||||
function handleTyping(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||
const typingSid = (message['serverId'] as string | undefined) ?? user.viewedServerId;
|
||||
const channelId = typeof message['channelId'] === 'string' && message['channelId'].trim() ? message['channelId'].trim() : 'general';
|
||||
const isTyping = message['isTyping'] !== false;
|
||||
@@ -344,9 +555,10 @@ function handleTyping(user: ConnectedUser, message: WsMessage): void {
|
||||
channelId,
|
||||
isTyping,
|
||||
oderId: user.oderId,
|
||||
displayName: user.displayName
|
||||
displayName: user.displayName,
|
||||
clientInstanceId: user.clientInstanceId
|
||||
},
|
||||
user.oderId
|
||||
{ excludeConnectionId: connectionId }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -376,7 +588,7 @@ function handleStatusUpdate(user: ConnectedUser, message: WsMessage, connectionI
|
||||
oderId: user.oderId,
|
||||
status
|
||||
},
|
||||
user.oderId
|
||||
{ excludeConnectionId: connectionId }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -421,7 +633,7 @@ function handleServerIconSyncRequest(user: ConnectedUser, message: WsMessage): v
|
||||
user.ws.send(JSON.stringify({ type: 'server_icon_sync_peers', serverId, users }));
|
||||
}
|
||||
|
||||
async function handlePluginEvent(user: ConnectedUser, message: WsMessage): Promise<void> {
|
||||
async function handlePluginEvent(user: ConnectedUser, message: WsMessage, connectionId: string): Promise<void> {
|
||||
const serverId = readMessageId(message['serverId']) ?? user.viewedServerId;
|
||||
const pluginId = readMessageId(message['pluginId']);
|
||||
const eventName = readMessageId(message['eventName']);
|
||||
@@ -466,7 +678,7 @@ async function handlePluginEvent(user: ConnectedUser, message: WsMessage): Promi
|
||||
sourceUserId: user.oderId,
|
||||
emittedAt: Date.now()
|
||||
},
|
||||
user.oderId
|
||||
{ excludeConnectionId: connectionId }
|
||||
);
|
||||
} catch (error) {
|
||||
sendPluginError(user, error, message);
|
||||
@@ -482,13 +694,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,19 +732,23 @@ 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':
|
||||
handleChatMessage(user, message);
|
||||
handleChatMessage(user, message, connectionId);
|
||||
break;
|
||||
|
||||
case 'voice_state':
|
||||
handleVoiceState(user, message);
|
||||
handleVoiceState(user, message, connectionId);
|
||||
break;
|
||||
|
||||
case 'voice_client_takeover':
|
||||
handleVoiceClientTakeover(user, message, connectionId);
|
||||
break;
|
||||
|
||||
case 'typing':
|
||||
handleTyping(user, message);
|
||||
handleTyping(user, message, connectionId);
|
||||
break;
|
||||
|
||||
case 'status_update':
|
||||
@@ -543,7 +764,7 @@ export async function handleWebSocketMessage(connectionId: string, message: WsMe
|
||||
break;
|
||||
|
||||
case 'plugin_event':
|
||||
await handlePluginEvent(user, message);
|
||||
await handlePluginEvent(user, message, connectionId);
|
||||
break;
|
||||
|
||||
default:
|
||||
|
||||
@@ -39,7 +39,7 @@ function removeDeadConnection(connectionId: string): void {
|
||||
displayName: user.displayName,
|
||||
serverId: sid,
|
||||
serverIds: remainingServerIds
|
||||
}, user.oderId);
|
||||
}, { excludeIdentityOderId: user.oderId });
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -80,7 +80,13 @@ export function setupWebSocket(server: Server<typeof IncomingMessage, typeof Ser
|
||||
const connectionId = uuidv4();
|
||||
const now = Date.now();
|
||||
|
||||
connectedUsers.set(connectionId, { oderId: connectionId, ws, serverIds: new Set(), lastPong: now });
|
||||
connectedUsers.set(connectionId, {
|
||||
oderId: connectionId,
|
||||
ws,
|
||||
authenticated: false,
|
||||
serverIds: new Set(),
|
||||
lastPong: now
|
||||
});
|
||||
|
||||
ws.on('pong', () => {
|
||||
const user = connectedUsers.get(connectionId);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { WebSocket } from 'ws';
|
||||
export interface ConnectedUser {
|
||||
oderId: string;
|
||||
ws: WebSocket;
|
||||
authenticated: boolean;
|
||||
serverIds: Set<string>;
|
||||
viewedServerId?: string;
|
||||
displayName?: string;
|
||||
@@ -21,6 +22,12 @@ export interface ConnectedUser {
|
||||
status?: 'online' | 'away' | 'busy' | 'offline';
|
||||
/** Latest server icon timestamp this connection can provide over P2P. */
|
||||
serverIconUpdatedAtByServerId?: Map<string, number>;
|
||||
/** Stable per-install client id sent by the product client. */
|
||||
clientInstanceId?: string;
|
||||
/** Whether this connection currently owns active voice/WebRTC for the user. */
|
||||
voiceActive?: boolean;
|
||||
/** Cached voice state snapshot used to bootstrap newly connected client instances. */
|
||||
voiceStateSnapshot?: Record<string, unknown>;
|
||||
/** Timestamp of the last pong or client message received (used to detect dead connections). */
|
||||
lastPong: number;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ Owns the user-facing Angular 21 desktop chat experience: rendering and orchestra
|
||||
| **Custom emoji** | User-created image emoji assets stored locally, synced peer-to-peer, and referenced from messages/reactions by stable `:emoji[id](name)` tokens. | "sticker", "emote" |
|
||||
| **App locale** | The active UI language for the product client, resolved by `resolveAppLocale()` in `core/i18n/`; only `en` is shipped today. | "language", "i18n locale" |
|
||||
| **Translation catalog** | JSON string tables under `public/i18n/catalog/*.json`, merged to `public/i18n/en.json` via `npm run i18n:sync`, loaded at startup by `AppI18nService`. | "locale file", "messages file" |
|
||||
| **Client instance** | Stable per-tab UUID (`metoyou.clientInstanceId` in `sessionStorage`) sent on WebSocket `identify` and voice-state payloads so the signaling server can route multi-device sessions without evicting other tabs or synced profiles. | "device id", "session id" |
|
||||
| **Voice owner connection** | The single client instance whose `clientInstanceId` matches the user's active `voiceState.clientInstanceId` and therefore owns mic/WebRTC for that identity. | "active voice client" |
|
||||
|
||||
## Relationships
|
||||
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,8 @@
|
||||
"resizeChat": "Resize chat",
|
||||
"yourCamera": "Your camera",
|
||||
"yourScreen": "Your screen",
|
||||
"waiting": "Waiting"
|
||||
"waiting": "Waiting",
|
||||
"voiceOnOtherDevice": "Active on another device"
|
||||
},
|
||||
"notifications": {
|
||||
"inProgress": "Call in progress"
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"dismiss": "Dismiss",
|
||||
"you": "You",
|
||||
"live": "LIVE",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"unknown": "Unknown",
|
||||
"muted": "Muted",
|
||||
|
||||
@@ -33,6 +33,8 @@
|
||||
"latencyMs": "{{ms}} ms",
|
||||
"playing": "Playing {{game}}",
|
||||
"inVoice": "In voice",
|
||||
"voiceOnOtherDevice": "In voice on another device",
|
||||
"takeOverVoice": "Join",
|
||||
"plugins": "Plugins",
|
||||
"viewPlugins": "View plugins",
|
||||
"you": "You",
|
||||
|
||||
@@ -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": {
|
||||
@@ -96,7 +97,8 @@
|
||||
"resizeChat": "Resize chat",
|
||||
"yourCamera": "Your camera",
|
||||
"yourScreen": "Your screen",
|
||||
"waiting": "Waiting"
|
||||
"waiting": "Waiting",
|
||||
"voiceOnOtherDevice": "Active on another device"
|
||||
},
|
||||
"notifications": {
|
||||
"inProgress": "Call in progress"
|
||||
@@ -290,6 +292,7 @@
|
||||
"dismiss": "Dismiss",
|
||||
"you": "You",
|
||||
"live": "LIVE",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"unknown": "Unknown",
|
||||
"muted": "Muted",
|
||||
@@ -766,6 +769,8 @@
|
||||
"latencyMs": "{{ms}} ms",
|
||||
"playing": "Playing {{game}}",
|
||||
"inVoice": "In voice",
|
||||
"voiceOnOtherDevice": "In voice on another device",
|
||||
"takeOverVoice": "Join",
|
||||
"plugins": "Plugins",
|
||||
"viewPlugins": "View plugins",
|
||||
"you": "You",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -58,6 +58,7 @@ import { RoomsActions } from './store/rooms/rooms.actions';
|
||||
import { selectCurrentRoom } from './store/rooms/rooms.selectors';
|
||||
import { ROOM_URL_PATTERN } from './core/constants';
|
||||
import { clearStoredCurrentUserId, getStoredCurrentUserId } from './core/storage/current-user-storage';
|
||||
import { buildLoginReturnQueryParams } from './domains/authentication/domain/logic/auth-navigation.rules';
|
||||
import { runWhenIdle } from './shared/rxjs';
|
||||
import {
|
||||
ThemeNodeDirective,
|
||||
@@ -319,9 +320,7 @@ export class App implements OnInit, OnDestroy {
|
||||
this.router.navigate(['/dashboard'], { replaceUrl: true }).catch(() => {});
|
||||
} else {
|
||||
this.router.navigate(['/login'], {
|
||||
queryParams: {
|
||||
returnUrl: currentUrl
|
||||
}
|
||||
queryParams: buildLoginReturnQueryParams(currentUrl)
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
import { ClientInstanceService } from './client-instance.service';
|
||||
|
||||
const SESSION_STORAGE_KEY = 'metoyou.clientInstanceId';
|
||||
const LEGACY_LOCAL_STORAGE_KEY = 'metoyou.clientInstanceId';
|
||||
|
||||
describe('ClientInstanceService', () => {
|
||||
const sessionStorage = new Map<string, string>();
|
||||
const localStorage = new Map<string, string>();
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
localStorage.clear();
|
||||
|
||||
vi.stubGlobal('sessionStorage', {
|
||||
getItem: (key: string) => sessionStorage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => { sessionStorage.set(key, value); },
|
||||
removeItem: (key: string) => { sessionStorage.delete(key); }
|
||||
});
|
||||
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (key: string) => localStorage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => { localStorage.set(key, value); },
|
||||
removeItem: (key: string) => { localStorage.delete(key); }
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('creates and persists a stable id for the same tab session', () => {
|
||||
const service = new ClientInstanceService();
|
||||
const first = service.getClientInstanceId();
|
||||
const second = new ClientInstanceService().getClientInstanceId();
|
||||
|
||||
expect(first).toMatch(/^[0-9a-f-]{36}$/i);
|
||||
expect(second).toBe(first);
|
||||
expect(sessionStorage.get(SESSION_STORAGE_KEY)).toBe(first);
|
||||
});
|
||||
|
||||
it('uses independent ids across separate tab sessions', () => {
|
||||
sessionStorage.set(SESSION_STORAGE_KEY, 'tab-a');
|
||||
|
||||
const tabA = new ClientInstanceService().getClientInstanceId();
|
||||
|
||||
sessionStorage.clear();
|
||||
sessionStorage.set(SESSION_STORAGE_KEY, 'tab-b');
|
||||
|
||||
const tabB = new ClientInstanceService().getClientInstanceId();
|
||||
|
||||
expect(tabA).toBe('tab-a');
|
||||
expect(tabB).toBe('tab-b');
|
||||
});
|
||||
|
||||
it('does not reuse legacy localStorage ids that collide across tabs or synced browsers', () => {
|
||||
localStorage.set(LEGACY_LOCAL_STORAGE_KEY, 'synced-device-id');
|
||||
|
||||
const id = new ClientInstanceService().getClientInstanceId();
|
||||
|
||||
expect(id).not.toBe('synced-device-id');
|
||||
expect(sessionStorage.get(SESSION_STORAGE_KEY)).toBe(id);
|
||||
expect(localStorage.has(LEGACY_LOCAL_STORAGE_KEY)).toBe(false);
|
||||
});
|
||||
});
|
||||
63
toju-app/src/app/core/platform/client-instance.service.ts
Normal file
63
toju-app/src/app/core/platform/client-instance.service.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
const SESSION_STORAGE_KEY = 'metoyou.clientInstanceId';
|
||||
const LEGACY_LOCAL_STORAGE_KEY = 'metoyou.clientInstanceId';
|
||||
|
||||
/**
|
||||
* Stable id for this browser tab/window session.
|
||||
*
|
||||
* Stored in sessionStorage so multiple tabs or synced browser profiles do not
|
||||
* share the same id and evict each other on the signaling server.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ClientInstanceService {
|
||||
private cachedId: string | null = null;
|
||||
|
||||
getClientInstanceId(): string {
|
||||
if (this.cachedId) {
|
||||
return this.cachedId;
|
||||
}
|
||||
|
||||
const stored = this.readStoredId();
|
||||
|
||||
if (stored) {
|
||||
this.cachedId = stored;
|
||||
return stored;
|
||||
}
|
||||
|
||||
this.clearLegacyLocalStorageId();
|
||||
|
||||
const created = crypto.randomUUID();
|
||||
|
||||
this.writeStoredId(created);
|
||||
this.cachedId = created;
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
private readStoredId(): string | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(SESSION_STORAGE_KEY)?.trim();
|
||||
|
||||
return raw || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private writeStoredId(id: string): void {
|
||||
try {
|
||||
sessionStorage.setItem(SESSION_STORAGE_KEY, id);
|
||||
} catch {
|
||||
// Ignore quota / private-mode failures; in-memory cache still works for this session.
|
||||
}
|
||||
}
|
||||
|
||||
private clearLegacyLocalStorageId(): void {
|
||||
try {
|
||||
localStorage.removeItem(LEGACY_LOCAL_STORAGE_KEY);
|
||||
} catch {
|
||||
// Ignore storage access failures.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -244,6 +244,13 @@ export interface ElectronAppMetricsSnapshot {
|
||||
processes: ElectronAppMetricsProcess[];
|
||||
}
|
||||
|
||||
export interface ElectronPerfDiagEntry {
|
||||
collectedAt: number;
|
||||
source: 'main' | 'renderer';
|
||||
type: string;
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ElectronApi {
|
||||
linuxDisplayServer: string;
|
||||
minimizeWindow: () => void;
|
||||
@@ -263,6 +270,8 @@ export interface ElectronApi {
|
||||
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
|
||||
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
|
||||
getAppMetrics: () => Promise<ElectronAppMetricsSnapshot>;
|
||||
isPerfDiagEnabled?: () => Promise<boolean>;
|
||||
reportPerfDiagSample?: (entry: ElectronPerfDiagEntry) => Promise<boolean>;
|
||||
getAppDataPath: () => Promise<string>;
|
||||
openCurrentDataFolder: () => Promise<boolean>;
|
||||
exportUserData: () => Promise<ExportUserDataResult>;
|
||||
@@ -294,9 +303,10 @@ export interface ElectronApi {
|
||||
relaunchApp: () => Promise<boolean>;
|
||||
onDeepLinkReceived: (listener: (url: string) => void) => () => void;
|
||||
readClipboardFiles: () => Promise<ClipboardFilePayload[]>;
|
||||
readFile: (filePath: string) => Promise<string>;
|
||||
readFileChunk: (filePath: string, start: number, end: number) => Promise<string>;
|
||||
getFileSize: (filePath: string) => Promise<number>;
|
||||
readFile: (filePath: string) => Promise<string | null>;
|
||||
readFileChunk: (filePath: string, start: number, end: number) => Promise<string | null>;
|
||||
getFileSize: (filePath: string) => Promise<number | null>;
|
||||
grantPluginReadRoot?: (rootPath: string) => Promise<boolean>;
|
||||
writeFile: (filePath: string, data: string) => Promise<boolean>;
|
||||
appendFile: (filePath: string, data: string) => Promise<boolean>;
|
||||
saveFileAs: (defaultFileName: string, data: string) => Promise<{ saved: boolean; cancelled: boolean }>;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user