chore: add system docs, specs, and misc updates from other sessions
- Add complete docs/systems/ reference (18 system docs) - Add federation relay status doc and prior spec/plan docs - Remove superseded docs/federation-dm-s2s.md (replaced by docs/systems/federation.md) - CLAUDE.md updates - Minor fixes in social.ts, types.ts, AddDmMemberModal, NewDmModal, UserSettings
This commit is contained in:
@@ -1,297 +0,0 @@
|
|||||||
# Federated DM Server-to-Server (S2S) Protocol
|
|
||||||
|
|
||||||
> Internal reference for agents working on Backspace federation. Covers the complete lifecycle of federated DMs: data model, relay pipeline, event processing, and known pitfalls.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Each Backspace instance maintains its own copy of DM channels and messages. Users interact with the copy on their **home instance** (the instance where their account was created). Federation relay synchronizes events between instances so all participants see the same conversation.
|
|
||||||
|
|
||||||
**Key principle:** A user should only see ONE copy of any DM channel — the one on their home instance. Cross-instance broadcasts must be filtered to local-only members.
|
|
||||||
|
|
||||||
## Data Model
|
|
||||||
|
|
||||||
### Tables
|
|
||||||
|
|
||||||
```
|
|
||||||
dm_channels
|
|
||||||
├── id TEXT PK — Snowflake, instance-local
|
|
||||||
├── owner_id TEXT NULL — NULL = 1-on-1, non-NULL = group DM (creator's local user ID)
|
|
||||||
├── federated_id TEXT NULL — Cross-instance channel identity (set when any member is remote)
|
|
||||||
├── owner_home_user_id TEXT NULL — Owner's ID on their home instance
|
|
||||||
├── owner_home_instance TEXT NULL — Owner's home instance origin
|
|
||||||
├── deleted_at INTEGER NULL — Soft-delete timestamp (GC after last member leaves)
|
|
||||||
└── created_at INTEGER NOT NULL
|
|
||||||
|
|
||||||
dm_members
|
|
||||||
├── dm_channel_id TEXT NOT NULL → dm_channels.id
|
|
||||||
├── user_id TEXT NOT NULL → users.id (local user ID on this instance)
|
|
||||||
├── closed INTEGER DEFAULT 0 (soft-close, per-user)
|
|
||||||
└── PK(dm_channel_id, user_id)
|
|
||||||
|
|
||||||
dm_messages
|
|
||||||
├── id TEXT PK
|
|
||||||
├── dm_channel_id TEXT NOT NULL → dm_channels.id
|
|
||||||
├── user_id TEXT NOT NULL → users.id (actor)
|
|
||||||
├── content TEXT NULL
|
|
||||||
├── type TEXT NOT NULL DEFAULT 'user' — 'user' | 'system'
|
|
||||||
├── reply_to_id TEXT NULL
|
|
||||||
├── edited_at INTEGER NULL
|
|
||||||
├── source_instance TEXT NULL — origin instance for relayed messages
|
|
||||||
├── source_message_id TEXT NULL — original message ID on source instance
|
|
||||||
└── created_at INTEGER NOT NULL
|
|
||||||
```
|
|
||||||
|
|
||||||
### Channel Type Identification
|
|
||||||
|
|
||||||
| Field | 1-on-1 DM | Group DM |
|
|
||||||
|-------|-----------|----------|
|
|
||||||
| `owner_id` | `NULL` | Creator's local user ID |
|
|
||||||
| `federated_id` format | Deterministic SHA-256 hash | Random UUID |
|
|
||||||
| Mutable membership | No (immutable pair) | Yes (owner can add, anyone can leave) |
|
|
||||||
| Max members | 2 | 10 |
|
|
||||||
|
|
||||||
**Critical invariant:** `owner_id` must NEVER be set to NULL on a group DM. This would make it indistinguishable from a 1-on-1 and corrupt the channel's type identity.
|
|
||||||
|
|
||||||
### Federated ID Generation
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 1-on-1: deterministic from the pair's home user IDs (same result on any instance)
|
|
||||||
const sorted = [homeUserIdA, homeUserIdB].sort();
|
|
||||||
const federatedId = sha256(sorted.join(':')).slice(0, 32); // 32-char hex
|
|
||||||
|
|
||||||
// Group: random UUID assigned by the creating instance
|
|
||||||
const federatedId = crypto.randomUUID(); // 36-char UUID with dashes
|
|
||||||
```
|
|
||||||
|
|
||||||
The format difference (32-char hash vs 36-char UUID) can be used to detect channel type independently of `owner_id`.
|
|
||||||
|
|
||||||
## User Identity Resolution
|
|
||||||
|
|
||||||
Users exist on their **home instance** as native records (`home_instance = NULL`). On other instances, they appear as **replicated user stubs** with `home_instance` and `home_user_id` set.
|
|
||||||
|
|
||||||
### Resolution Functions
|
|
||||||
|
|
||||||
| Function | Behavior | Use When |
|
|
||||||
|----------|----------|----------|
|
|
||||||
| `resolveOrCreateReplicatedUser(homeUserId, homeInstance, db)` | Finds existing user or creates a stub. **Always returns a valid user.** | You MUST have a valid user ID (e.g., setting `ownerId`, inserting system messages) |
|
|
||||||
| `resolveLocalUser(homeUserId, db)` | Read-only lookup. Returns `null` if not found. | Optional lookups where null is acceptable |
|
|
||||||
|
|
||||||
**Rule:** Any code path that sets `ownerId`, creates a `dm_members` row, or inserts a message MUST use `resolveOrCreateReplicatedUser`. Using `resolveLocalUser` with a `?? null` fallback has caused data corruption.
|
|
||||||
|
|
||||||
### Origin Normalization
|
|
||||||
|
|
||||||
**Critical pitfall:** Two different formats exist in the database:
|
|
||||||
|
|
||||||
| Location | Format | Example |
|
|
||||||
|----------|--------|---------|
|
|
||||||
| `users.home_instance` | Bare domain | `nova.ddns.net` |
|
|
||||||
| `federation_peers.origin` | Full URL | `https://nova.ddns.net` |
|
|
||||||
| `getOurOrigin()` return value | Full URL | `https://orbit.ddns.net` |
|
|
||||||
|
|
||||||
When comparing `homeInstance` against peer origins or `getOurOrigin()`, always normalize:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const normalized = homeInstance.startsWith('http') ? homeInstance : `https://${homeInstance}`;
|
|
||||||
```
|
|
||||||
|
|
||||||
Failure to normalize causes silent failures where `queueOutboxEvent` finds zero matching peers and drops events without error.
|
|
||||||
|
|
||||||
## Relay Pipeline
|
|
||||||
|
|
||||||
### Outbound Flow (Origin Instance)
|
|
||||||
|
|
||||||
```
|
|
||||||
1. API endpoint creates/modifies DM data
|
|
||||||
2. appendMutationLog() — permanent audit record in federation_mutation_log
|
|
||||||
3. queueOutboxEvent(messageId, contextId, eventType, payload, targetOrigins)
|
|
||||||
├── Fetches active peers from federation_peers
|
|
||||||
├── Filters to targetOrigins (normalized homeInstance → peer.origin match)
|
|
||||||
├── Inserts one federation_outbox row per target peer
|
|
||||||
└── If targetOrigins produces zero peers → event is silently dropped
|
|
||||||
4. Outbox worker (10-second interval) batches pending events per peer
|
|
||||||
5. POST /api/federation/relay to each peer with signed payload
|
|
||||||
6. Peer responds with accepted/rejected arrays
|
|
||||||
7. Accepted events deleted from outbox; rejected events logged
|
|
||||||
```
|
|
||||||
|
|
||||||
### Target Origin Resolution
|
|
||||||
|
|
||||||
For group DMs, `getGroupDmTargetOrigins(channelId)` determines which peers receive events:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
function getGroupDmTargetOrigins(channelId: string): string[] {
|
|
||||||
// Query all members' homeInstances
|
|
||||||
// Normalize to full URL format
|
|
||||||
// Filter out our own origin
|
|
||||||
// Return unique peer origins that have members in this group
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
For 1-on-1 DMs, `targetOrigins` is `undefined` → broadcasts to ALL active peers.
|
|
||||||
|
|
||||||
### Inbound Flow (Receiving Instance)
|
|
||||||
|
|
||||||
```
|
|
||||||
1. POST /api/federation/relay arrives with signed events array
|
|
||||||
2. Verify request signature against peer's public key
|
|
||||||
3. For each event, dispatch to type-specific processor:
|
|
||||||
├── create/update/delete → processCreateEvent / processUpdateEvent / processDeleteEvent
|
|
||||||
├── member_add → processMemberAddEvent
|
|
||||||
├── member_remove → processMemberRemoveEvent
|
|
||||||
├── ownership_transfer → processOwnershipTransferEvent
|
|
||||||
├── reaction_add/remove → processReactionEvent
|
|
||||||
└── friend_add/remove → processFriendEvent
|
|
||||||
4. Return accepted/rejected arrays
|
|
||||||
```
|
|
||||||
|
|
||||||
## Event Processing — Group DM Lifecycle
|
|
||||||
|
|
||||||
### member_add (processMemberAddEvent)
|
|
||||||
|
|
||||||
**Two paths:**
|
|
||||||
|
|
||||||
**Bootstrap path** (channel doesn't exist locally):
|
|
||||||
1. Channel not found by `federatedId` → create from `event.group` metadata
|
|
||||||
2. Resolve owner via `resolveOrCreateReplicatedUser`
|
|
||||||
3. Create `dm_channels` row with `ownerId`, `federatedId`, owner federation fields
|
|
||||||
4. Add ALL roster members from `event.group.members` (resolve each via `resolveOrCreateReplicatedUser`)
|
|
||||||
5. Send `dm_channel_created` to **local-only members** (home instance matches this server)
|
|
||||||
6. Set `bootstrapped = true` to skip redundant broadcasts below
|
|
||||||
7. Insert system message for member addition (local-only, inside `!bootstrapped` guard)
|
|
||||||
|
|
||||||
**Incremental path** (channel already exists):
|
|
||||||
1. Channel found by `federatedId`
|
|
||||||
2. Validate authority: only owner's instance can add members
|
|
||||||
3. Resolve added user via `resolveOrCreateReplicatedUser`
|
|
||||||
4. Insert `dm_members` row (idempotent — skip if already exists)
|
|
||||||
5. Insert system message for the addition
|
|
||||||
6. Send `dm_member_added` to local WebSocket clients
|
|
||||||
|
|
||||||
### member_remove (processMemberRemoveEvent)
|
|
||||||
|
|
||||||
1. Find channel by `federatedId`
|
|
||||||
2. Validate authority: owner's instance for kicks, any instance for self-leave
|
|
||||||
3. Resolve user via `resolveLocalUser` (they should already exist)
|
|
||||||
4. Insert system message (before deletion, so broadcast includes the leaving user)
|
|
||||||
5. Delete `dm_members` row
|
|
||||||
6. Clean up `read_states`
|
|
||||||
7. Send `dm_member_removed` to remaining local members
|
|
||||||
8. If zero members remain → soft-delete channel (`deleted_at = now`)
|
|
||||||
|
|
||||||
### ownership_transfer (processOwnershipTransferEvent)
|
|
||||||
|
|
||||||
1. Find channel by `federatedId`
|
|
||||||
2. Validate authority: only current owner's instance can transfer
|
|
||||||
3. Resolve new owner via `resolveOrCreateReplicatedUser` (**never** `resolveLocalUser` — must guarantee valid ID)
|
|
||||||
4. Update `dm_channels`: `ownerId`, `ownerHomeUserId`, `ownerHomeInstance`
|
|
||||||
5. Send `dm_owner_updated` WebSocket event to local members
|
|
||||||
6. Insert system message for the transfer
|
|
||||||
|
|
||||||
## Event Processing — DM Messages
|
|
||||||
|
|
||||||
### create (DM message relay)
|
|
||||||
|
|
||||||
**Group DMs** (`event.federatedId` present):
|
|
||||||
1. Find channel by `federatedId`
|
|
||||||
2. If not found → skip (channel should be bootstrapped by `member_add` first)
|
|
||||||
3. Insert message with `sourceInstance` and `sourceMessageId` for dedup
|
|
||||||
4. Broadcast `dm_message_created` to local members
|
|
||||||
|
|
||||||
**1-on-1 DMs** (no `federatedId`):
|
|
||||||
1. Compute deterministic `federatedId` from sender + recipient home IDs
|
|
||||||
2. `findOrCreateDmChannel()` — find by `federatedId` or create with `ownerId = NULL`
|
|
||||||
3. Insert message, broadcast to local members
|
|
||||||
|
|
||||||
## Local-Only Broadcast Principle
|
|
||||||
|
|
||||||
Users connected to multiple instances must see each DM channel exactly once (from their home instance). All `dm_channel_created` broadcasts and system message broadcasts filter to **local members only**:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const isLocalMember = (u: { homeInstance?: string | null }) =>
|
|
||||||
!u.homeInstance || !domainOrigin ||
|
|
||||||
u.homeInstance === domainOrigin ||
|
|
||||||
`https://${u.homeInstance}` === domainOrigin;
|
|
||||||
```
|
|
||||||
|
|
||||||
- Origin instance: broadcasts only to local members after creating the group DM
|
|
||||||
- Receiving instance (bootstrap): broadcasts only to members whose home instance matches
|
|
||||||
- Remote members receive notification through federation relay → bootstrap on their home instance
|
|
||||||
|
|
||||||
**Does NOT apply to:** Regular DM messages (`dm_message_created` for user messages). These broadcast to all local `dm_members` regardless of home instance, because the message relay ensures eventual delivery to all instances. Faster delivery to users connected to the origin is acceptable since the message ID deduplicates on the receiving instance.
|
|
||||||
|
|
||||||
## System Messages
|
|
||||||
|
|
||||||
System messages (`type = 'system'` in `dm_messages`) record group lifecycle events in the chat timeline.
|
|
||||||
|
|
||||||
### Events
|
|
||||||
|
|
||||||
| Event | Content JSON | Actor (`userId`) |
|
|
||||||
|-------|-------------|-----------------|
|
|
||||||
| `member_added` | `{event, targetUserId, targetDisplayName}` | User who added them |
|
|
||||||
| `member_removed` | `{event, targetUserId, targetDisplayName, reason}` | User who left/was removed |
|
|
||||||
| `owner_changed` | `{event, newOwnerId, newOwnerDisplayName}` | Previous owner |
|
|
||||||
|
|
||||||
### Creation Pattern
|
|
||||||
|
|
||||||
System messages are **instance-local** — they are NOT relayed via federation. Each instance creates its own system messages independently when processing federation events:
|
|
||||||
|
|
||||||
- **Origin instance**: Creates system messages in the REST endpoint (e.g., `POST /api/dm/group`), broadcasts to local members only
|
|
||||||
- **Receiving instance**: Creates system messages in the federation event processor (e.g., `processMemberAddEvent`), broadcasts to local members
|
|
||||||
|
|
||||||
This avoids duplicate system messages for users connected to multiple instances.
|
|
||||||
|
|
||||||
### Rendering
|
|
||||||
|
|
||||||
Frontend detects `message.type === 'system'`, parses JSON content, renders as compact centered text with icons (→ added, ← left, ♛ owner change). No avatar, no context menu, no reactions.
|
|
||||||
|
|
||||||
## WebSocket Events
|
|
||||||
|
|
||||||
### State-change events (structural)
|
|
||||||
| Event | Purpose | When |
|
|
||||||
|-------|---------|------|
|
|
||||||
| `dm_channel_created` | New DM appears in sidebar | Group DM bootstrap, or new 1-on-1 |
|
|
||||||
| `dm_member_added` | Member added to existing group | Incremental member_add (not bootstrap) |
|
|
||||||
| `dm_member_removed` | Member left/removed from group | member_remove processing |
|
|
||||||
| `dm_owner_updated` | Group ownership changed | ownership_transfer processing |
|
|
||||||
| `dm_channel_closed` | DM removed from sidebar for leaving user | User leaves group |
|
|
||||||
|
|
||||||
### Content events
|
|
||||||
| Event | Purpose |
|
|
||||||
|-------|---------|
|
|
||||||
| `dm_message_created` | New message (user or system) |
|
|
||||||
| `dm_message_updated` | Message edited |
|
|
||||||
| `dm_message_deleted` | Message deleted |
|
|
||||||
|
|
||||||
## Known Pitfalls & Historical Bugs
|
|
||||||
|
|
||||||
### 1. ownerId nulling (FIXED)
|
|
||||||
`processOwnershipTransferEvent` used `resolveLocalUser` with `?? null` fallback. When resolution failed (even transiently), it set `ownerId = NULL`, converting the group DM into a 1-on-1. Fix: use `resolveOrCreateReplicatedUser` which always returns a valid user.
|
|
||||||
|
|
||||||
**Self-healing migration** in `migrate.ts` detects group DMs with UUID-format `federated_id` but `NULL owner_id` and restores the owner from the first remaining member.
|
|
||||||
|
|
||||||
### 2. Origin normalization (FIXED)
|
|
||||||
`getGroupDmTargetOrigins()` returned bare domains from `users.home_instance`, but `queueOutboxEvent()` compared them against `federation_peers.origin` (full URLs). No peers matched → events silently dropped. Fix: normalize to full URL before comparison.
|
|
||||||
|
|
||||||
### 3. Missing federatedId in outbox reconstruction (FIXED)
|
|
||||||
The outbox worker (`federationWorker.ts`) reconstructed relay events from stored payloads but never copied `federatedId`. Receiving instances check this field and rejected all `member_add/remove/ownership_transfer` events. Fix: copy `parsed.federatedId` during reconstruction.
|
|
||||||
|
|
||||||
### 4. Cross-instance duplicate channels (FIXED)
|
|
||||||
`dm_channel_created` was broadcast to ALL members including remote replicas. Users connected to multiple instances received the event twice (different channel IDs), creating duplicate sidebar entries. Fix: local-only broadcast principle.
|
|
||||||
|
|
||||||
### 5. Bootstrap vs incremental confusion
|
|
||||||
The `bootstrapped` flag in `processMemberAddEvent` is a local variable — each function invocation starts fresh. When multiple `member_add` events arrive in a batch (common for group creation), only the FIRST triggers bootstrap. Subsequent events see the channel exists and take the incremental path. This is correct — the bootstrap adds ALL roster members, so the incremental events are idempotent.
|
|
||||||
|
|
||||||
## File Map
|
|
||||||
|
|
||||||
| File | Responsibility |
|
|
||||||
|------|---------------|
|
|
||||||
| `packages/server/src/routes/dm.ts` | DM REST endpoints, system message creation, federation event queueing |
|
|
||||||
| `packages/server/src/routes/federation.ts` | Inbound event processing: `processMemberAddEvent`, `processMemberRemoveEvent`, `processOwnershipTransferEvent`, `resolveOrCreateReplicatedUser`, `resolveLocalUser` |
|
|
||||||
| `packages/server/src/utils/federationOutbox.ts` | `queueOutboxEvent`, `appendMutationLog`, `getDmParticipants`, `getGroupDmTargetOrigins`, `computeFederatedId` |
|
|
||||||
| `packages/server/src/utils/federationWorker.ts` | Outbox flush worker (10s interval), event reconstruction, delivery to peers |
|
|
||||||
| `packages/server/src/utils/federationAuth.ts` | `getOurOrigin()`, request signing, peer verification |
|
|
||||||
| `packages/server/src/ws/handler.ts` | `connectionManager.sendToUser()`, `sendToDmMembers()` — WebSocket broadcast |
|
|
||||||
| `packages/server/src/db/schema.ts` | Drizzle table definitions including `dm_channels`, `dm_members`, `dm_messages` |
|
|
||||||
| `packages/server/src/db/migrate.ts` | Schema migrations + self-healing data integrity checks |
|
|
||||||
| `packages/web/src/hooks/useWebSocket.ts` | Frontend WebSocket event handlers for all DM events |
|
|
||||||
| `packages/web/src/stores/spaceStore.ts` | Zustand store: `addDmChannel`, `addDmMember`, `removeDmMember`, `updateDmOwner` |
|
|
||||||
@@ -0,0 +1,404 @@
|
|||||||
|
# Activity & Presence System
|
||||||
|
|
||||||
|
Source files:
|
||||||
|
- `packages/shared/src/types.ts` — Activity, ActivityType, ActivityTimestamps, ActivityAssets type definitions
|
||||||
|
- `packages/shared/src/activities.ts` — ACTIVITY_LIMITS, ACTIVITY_PRIORITY, getPrimaryActivity()
|
||||||
|
- `packages/web/src/stores/activityStore.ts` — Client-side activity state (Zustand), debounced push, visibility toggle
|
||||||
|
- `packages/web/src/platform/activityBridge.ts` — Electron IPC bridge: subscribes to desktop activity events
|
||||||
|
- `packages/web/src/hooks/useWebSocket.ts` — Ready payload handling, presence_update reception, reconnect re-push
|
||||||
|
- `packages/web/src/components/layout/ActivityPanel.tsx` — Friends activity sidebar (DM home view)
|
||||||
|
- `packages/web/src/components/layout/MemberSidebar.tsx` — Space member list with activity display
|
||||||
|
- `packages/web/src/components/ui/ActivityCard.tsx` — Activity display component, accent color helpers
|
||||||
|
- `packages/web/src/components/modals/settingsPanels/PrivacyPanel.tsx` — showActivity toggle UI
|
||||||
|
- `packages/server/src/ws/handler.ts` — ConnectionManager (in-memory activity state, rate limiting, disconnect cleanup)
|
||||||
|
- `packages/server/src/ws/events.ts` — handlePresenceUpdate, handleActivityUpdate, validateActivities
|
||||||
|
- `packages/server/src/routes/users.ts` — REST showActivity toggle with server-side activity clear
|
||||||
|
- `packages/desktop/src/activityDetector.ts` — Process polling, game dictionary matching (boundary: see Desktop section)
|
||||||
|
- `packages/desktop/src/preload.ts` — IPC channel exposure (activity-detected, get-current-activity)
|
||||||
|
- `packages/desktop/src/main.ts` — startActivityDetection call, IPC handler registration
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Type Definitions
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// packages/shared/src/types.ts
|
||||||
|
|
||||||
|
type ActivityType = 'custom' | 'playing' | 'listening' | 'watching' | 'streaming';
|
||||||
|
|
||||||
|
interface ActivityTimestamps {
|
||||||
|
start?: number; // epoch ms
|
||||||
|
end?: number; // epoch ms
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActivityAssets {
|
||||||
|
largeImage?: string;
|
||||||
|
largeText?: string;
|
||||||
|
smallImage?: string;
|
||||||
|
smallText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Activity {
|
||||||
|
type: ActivityType;
|
||||||
|
name: string;
|
||||||
|
details?: string;
|
||||||
|
state?: string;
|
||||||
|
timestamps?: ActivityTimestamps;
|
||||||
|
assets?: ActivityAssets;
|
||||||
|
url?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Field Limits & Validation
|
||||||
|
|
||||||
|
### ACTIVITY_LIMITS (`shared/src/activities.ts`)
|
||||||
|
|
||||||
|
| Constant | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| `MAX_ACTIVITIES_PER_USER` | 5 |
|
||||||
|
| `MAX_NAME_LENGTH` | 128 |
|
||||||
|
| `MAX_DETAILS_LENGTH` | 128 |
|
||||||
|
| `MAX_STATE_LENGTH` | 128 |
|
||||||
|
| `MAX_ASSET_TEXT_LENGTH` | 128 |
|
||||||
|
| `MAX_URL_LENGTH` | 512 |
|
||||||
|
|
||||||
|
### Server-Side Validation (`ws/events.ts:validateActivities()`)
|
||||||
|
|
||||||
|
The server validates every incoming `activity_update` payload:
|
||||||
|
|
||||||
|
1. Must be an array with at most `MAX_ACTIVITIES_PER_USER` items
|
||||||
|
2. Each item must be an object with a valid `type` (one of: `custom`, `playing`, `listening`, `watching`, `streaming`)
|
||||||
|
3. `name` is required, must be a non-empty string within `MAX_NAME_LENGTH`; trimmed on accept
|
||||||
|
4. Optional fields (`details`, `state`) accepted if string and within length limits; trimmed
|
||||||
|
5. `url` accepted only if it starts with `https://` or `http://` and is within `MAX_URL_LENGTH`
|
||||||
|
6. `timestamps.start` and `timestamps.end` accepted if numbers in range `[0, 4102444800000]` (epoch ms cap ~2100)
|
||||||
|
7. `assets` fields (`largeImage`, `smallImage`) validated against `MAX_URL_LENGTH`; text fields against `MAX_ASSET_TEXT_LENGTH`
|
||||||
|
8. If any item fails validation, the entire payload is rejected (returns `null`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Activity Priority & Primary Selection
|
||||||
|
|
||||||
|
### Priority Ranking (`shared/src/activities.ts`)
|
||||||
|
|
||||||
|
| Activity Type | Priority |
|
||||||
|
|---------------|----------|
|
||||||
|
| `streaming` | 5 (highest) |
|
||||||
|
| `playing` | 4 |
|
||||||
|
| `listening` | 3 |
|
||||||
|
| `watching` | 2 |
|
||||||
|
| `custom` | 1 (lowest) |
|
||||||
|
|
||||||
|
### `getPrimaryActivity(activities)` Algorithm
|
||||||
|
|
||||||
|
Returns the single activity with the highest priority from the array. Uses `Array.reduce` — on ties, the first-encountered activity wins (leftmost in array). Returns `null` for empty arrays.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// shared/src/activities.ts
|
||||||
|
function getPrimaryActivity(activities: Activity[]): Activity | null {
|
||||||
|
if (!activities.length) return null;
|
||||||
|
return activities.reduce((best, current) =>
|
||||||
|
ACTIVITY_PRIORITY[current.type] > ACTIVITY_PRIORITY[best.type] ? current : best
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Presence States
|
||||||
|
|
||||||
|
### Status Values
|
||||||
|
|
||||||
|
| Status | Meaning |
|
||||||
|
|--------|---------|
|
||||||
|
| `online` | Active connection |
|
||||||
|
| `idle` | User-set idle |
|
||||||
|
| `dnd` | Do not disturb |
|
||||||
|
| `offline` | No active connections |
|
||||||
|
|
||||||
|
### DB Persistence
|
||||||
|
|
||||||
|
The `users.status` column (see database.md) stores the current presence status. Default: `'offline'`.
|
||||||
|
|
||||||
|
- **On connect:** Server sets `status = 'online'` in DB (`ws/handler.ts:1344`)
|
||||||
|
- **On manual change:** Client sends `presence_update` with `status` field; server persists to DB (`ws/events.ts:483`)
|
||||||
|
- **On disconnect:** After 5s grace period, server sets `status = 'offline'` in DB (`ws/handler.ts:225`)
|
||||||
|
|
||||||
|
### Connect/Disconnect Flow
|
||||||
|
|
||||||
|
1. **Auth succeeds** → `status` set to `'online'` in DB → `presence_update` broadcast to all user's spaces (excludes self; self gets `ready` payload)
|
||||||
|
2. **Last socket closes** → 5-second grace period (`scheduleDisconnect`) to allow tab refresh/reconnect
|
||||||
|
3. **Grace period expires** → `finalizeDisconnect`: sets DB status to `'offline'`, clears in-memory activities, broadcasts `presence_update` with `status: 'offline'` and `activities: []` to all spaces
|
||||||
|
4. **Reconnect during grace** → `cancelDisconnect` prevents offline broadcast; new connection proceeds normally
|
||||||
|
|
||||||
|
### Presence Broadcast Scope
|
||||||
|
|
||||||
|
`presence_update` events are broadcast via `connectionManager.sendToSpace()` to all spaces the user belongs to, plus `sendToUser()` to the user's own connections (multi-tab sync). The user is excluded from the space broadcast to avoid duplicate delivery.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Activity Lifecycle
|
||||||
|
|
||||||
|
Activities are **ephemeral** — stored only in server memory (`ConnectionManager.userActivities: Map<string, Activity[]>`), never persisted to the database. They are cleared on disconnect.
|
||||||
|
|
||||||
|
### Data Flow: Detection to Display
|
||||||
|
|
||||||
|
```
|
||||||
|
Desktop Process Scanner (15s poll)
|
||||||
|
→ IPC 'activity-detected' → preload bridge
|
||||||
|
→ activityBridge.ts → activityStore.pushActivities()
|
||||||
|
→ 5s debounce → wsSendAll('activity_update')
|
||||||
|
→ Server validates, rate-limits (3s)
|
||||||
|
→ Stores in ConnectionManager.userActivities
|
||||||
|
→ Broadcasts 'presence_update' to all user's spaces
|
||||||
|
→ Client useWebSocket handler
|
||||||
|
→ activityStore.setUserActivities()
|
||||||
|
→ UI re-renders (ActivityCard, MemberSidebar, ActivityPanel)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Server-Side In-Memory State (`ws/handler.ts:ConnectionManager`)
|
||||||
|
|
||||||
|
| Map | Key | Value | Lifecycle |
|
||||||
|
|-----|-----|-------|-----------|
|
||||||
|
| `userActivities` | userId | `Activity[]` | Set on `activity_update`, cleared on disconnect or `showActivity=false` |
|
||||||
|
| `userShowActivity` | userId | boolean | Cached from DB at auth, updated via REST `PATCH /users/me` |
|
||||||
|
| `userStatuses` | userId | string | Cached from DB at auth, updated on `presence_update` |
|
||||||
|
| `lastActivityUpdate` | userId | timestamp (ms) | Used for 3s rate limiting |
|
||||||
|
|
||||||
|
### Rate Limiting
|
||||||
|
|
||||||
|
Two independent throttling mechanisms prevent activity spam:
|
||||||
|
|
||||||
|
| Layer | Mechanism | Interval | Location |
|
||||||
|
|-------|-----------|----------|----------|
|
||||||
|
| Client | Debounce timer in `activityStore.pushActivities()` | 5 seconds | `activityStore.ts:72` |
|
||||||
|
| Server | `checkActivityRateLimit()` — rejects if `< 3000ms` since last update | 3 seconds | `ws/handler.ts:349-355` |
|
||||||
|
|
||||||
|
The client debounce is a trailing-edge timer: each new `pushActivities()` call resets the 5s timer, and only the final state is sent. The server rate limit is a hard gate: updates arriving within 3s of the last accepted update are rejected with an error message.
|
||||||
|
|
||||||
|
### Ready Payload — Initial Activity Snapshot
|
||||||
|
|
||||||
|
On WebSocket auth, `buildReadyPayload()` constructs a `userActivities` map for all visible users (space members + DM members). It auto-injects a synthetic `custom` activity for users who have a `customStatus` set but no ephemeral activities:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ws/handler.ts:collectUserActivities
|
||||||
|
function collectUserActivities(uid: string, customStatus: string | null) {
|
||||||
|
if (seenUserIds.has(uid)) return;
|
||||||
|
seenUserIds.add(uid);
|
||||||
|
let acts = connectionManager.getUserActivities(uid);
|
||||||
|
if (acts.length === 0 && customStatus) {
|
||||||
|
acts = [{ type: 'custom', name: customStatus }];
|
||||||
|
}
|
||||||
|
if (acts.length > 0) {
|
||||||
|
userActivities[uid] = acts;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This synthetic injection only occurs in the ready payload snapshot, not in live `presence_update` broadcasts.
|
||||||
|
|
||||||
|
### Reconnect Re-Push
|
||||||
|
|
||||||
|
After receiving a `ready` event, the client performs two re-push operations (`useWebSocket.ts:217-236`):
|
||||||
|
|
||||||
|
1. **Electron re-query:** If running in desktop and this is the home connection, calls `window.backspace.getCurrentActivity()` and pushes the result. This handles sleep/wake scenarios where the process scanner didn't fire a change event.
|
||||||
|
|
||||||
|
2. **Multi-instance fan-out:** Reads `myActivities` from the activity store and sends `activity_update` to the newly connected instance via `wsSend(event, origin)`. This ensures remote instances have the user's current activities in their in-memory store immediately.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Visibility Control (`showActivity`)
|
||||||
|
|
||||||
|
### DB Column
|
||||||
|
|
||||||
|
`users.showActivity` — integer, NOT NULL, default `1`. See database.md.
|
||||||
|
|
||||||
|
### Toggle Flow
|
||||||
|
|
||||||
|
1. User toggles in Privacy panel → `api.users.update({ showActivity: enabled })` (REST PATCH)
|
||||||
|
2. Server persists `showActivity` to DB (`routes/users.ts:324`)
|
||||||
|
3. Server updates `ConnectionManager.userShowActivity` cache (`routes/users.ts:357`)
|
||||||
|
4. If toggled **off**, server immediately:
|
||||||
|
- Clears `ConnectionManager.userActivities` for the user
|
||||||
|
- Broadcasts `presence_update` with `activities: []` to all user's spaces
|
||||||
|
- Sends same to user's own connections
|
||||||
|
5. Client calls `activityStore.setShowActivity(enabled)` (`PrivacyPanel.tsx:73`)
|
||||||
|
6. If toggled **off**, client immediately:
|
||||||
|
- Cancels any pending debounce timer
|
||||||
|
- Sends `activity_update` with `activities: []` to all connected instances via `wsSendAll`
|
||||||
|
- Sets `myActivities` to `null`
|
||||||
|
|
||||||
|
### Server-Side Guard
|
||||||
|
|
||||||
|
When `showActivity` is false, the server silently drops incoming `activity_update` events (`ws/events.ts:505`):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function handleActivityUpdate(event, userId) {
|
||||||
|
if (!connectionManager.getUserShowActivity(userId)) return;
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Client-Side Guard
|
||||||
|
|
||||||
|
`activityStore.pushActivities()` checks `showActivity` and returns early if false (`activityStore.ts:69`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Desktop Activity Detection (Boundary)
|
||||||
|
|
||||||
|
This spec covers how detected activities enter the broadcast pipeline. The detection internals (process scanning, game dictionary matching, dictionary sync) belong to a future `desktop.md` spec.
|
||||||
|
|
||||||
|
### Summary of Detection Interface
|
||||||
|
|
||||||
|
| Component | Role |
|
||||||
|
|-----------|------|
|
||||||
|
| `activityDetector.ts:startActivityDetection(callback)` | Starts 15s polling loop; calls `callback` with `Activity \| null` on change |
|
||||||
|
| `activityDetector.ts:getCurrentActivity()` | Returns current detected `Activity` or `null` (synchronous) |
|
||||||
|
| `main.ts:810-812` | Starts detection on app ready; forwards changes via IPC `activity-detected` |
|
||||||
|
| `main.ts:814` | Registers `get-current-activity` IPC handler |
|
||||||
|
| `preload.ts:73-78` | Exposes `onActivityDetected` (subscription) and `getCurrentActivity` (invoke) to renderer |
|
||||||
|
|
||||||
|
### Bridge to Activity Store
|
||||||
|
|
||||||
|
`activityBridge.ts` is initialized once in `AppLayout` via `useEffect`:
|
||||||
|
|
||||||
|
1. Calls `initActivityBridge()` → subscribes to `window.backspace.onActivityDetected`
|
||||||
|
2. On activity change: calls `pushActivities([activity])` or `pushActivities([])` (null means no activity)
|
||||||
|
3. On init: also queries `getCurrentActivity()` for immediate state
|
||||||
|
4. Cleanup: `teardownActivityBridge()` removes the IPC listener
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Client-Side State: `activityStore` (Zustand)
|
||||||
|
|
||||||
|
### State Shape
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ActivityState {
|
||||||
|
userActivities: Map<string, Activity[]>; // All users' activities, keyed by userId
|
||||||
|
showActivity: boolean; // Current user's visibility preference
|
||||||
|
myActivities: Activity[] | null; // Current user's own activities (cached locally)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Methods
|
||||||
|
|
||||||
|
| Method | Behavior |
|
||||||
|
|--------|----------|
|
||||||
|
| `setUserActivities(userId, activities)` | Updates map; deletes entry if empty array |
|
||||||
|
| `clearUserActivities(userId)` | Removes entry from map |
|
||||||
|
| `initActivities(activityMap)` | Bulk-set from ready payload (merges into existing map) |
|
||||||
|
| `setShowActivity(show)` | Sets flag; if `false`: cancels debounce, sends empty `activity_update` via `wsSendAll`, clears `myActivities` |
|
||||||
|
| `pushActivities(activities)` | Guards on `showActivity`; sets `myActivities` immediately; starts/resets 5s debounce timer; on fire: sends `activity_update` via `wsSendAll` |
|
||||||
|
| `reset()` | Cancels timer, clears all state |
|
||||||
|
|
||||||
|
### Module-Level State
|
||||||
|
|
||||||
|
The 5s debounce timer is stored as a module-level `let pushTimer` variable (not in Zustand state), ensuring it survives React re-renders but is properly cleared on `reset()` or `setShowActivity(false)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Activity Display Components
|
||||||
|
|
||||||
|
### ActivityCard (`ui/ActivityCard.tsx`)
|
||||||
|
|
||||||
|
Renders the primary activity for a user. Used inside both `ActivityPanel` and `MemberSidebar`.
|
||||||
|
|
||||||
|
**Props:** `{ activities: Activity[], fallbackCustomStatus?: string | null }`
|
||||||
|
|
||||||
|
**Rendering logic:**
|
||||||
|
1. Get primary activity via `getPrimaryActivity(activities)`
|
||||||
|
2. If no primary and `fallbackCustomStatus` exists → render custom status as plain text
|
||||||
|
3. If primary is `custom` → render `primary.name` as plain text
|
||||||
|
4. If primary is rich (non-custom) → render `primary.name` + elapsed time (if `timestamps.start` set)
|
||||||
|
|
||||||
|
**Elapsed time format** (`formatElapsed`): `"Xh Ym"` if hours > 0, otherwise `"Xm"`.
|
||||||
|
|
||||||
|
### Helper Functions (exported from `ActivityCard.tsx`)
|
||||||
|
|
||||||
|
| Function | Returns | Purpose |
|
||||||
|
|----------|---------|---------|
|
||||||
|
| `getActivityAccentClass(type)` | Tailwind border class | Left-border accent color for glass pill rows |
|
||||||
|
| `hasRichActivity(activities)` | boolean | True if primary activity is non-custom |
|
||||||
|
|
||||||
|
### Accent Colors by Activity Type
|
||||||
|
|
||||||
|
| Type | Border Class | Color |
|
||||||
|
|------|-------------|-------|
|
||||||
|
| `playing` | `border-l-accent-mint` | Mint |
|
||||||
|
| `listening` | `border-l-accent-sky` | Sky |
|
||||||
|
| `watching` | `border-l-accent-lavender` | Lavender |
|
||||||
|
| `streaming` | `border-l-accent-rose` | Rose |
|
||||||
|
| `custom` | (none) | No accent |
|
||||||
|
|
||||||
|
### Row Rendering Pattern
|
||||||
|
|
||||||
|
Both `ActivityPanel` and `MemberSidebar` use the same row rendering logic:
|
||||||
|
- **Rich activity** (non-custom primary): `glass-pill` container with `border-l-2` accent + rounded corners (10px)
|
||||||
|
- **No rich activity**: Standard flat row with hover state
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ActivityPanel (`layout/ActivityPanel.tsx`)
|
||||||
|
|
||||||
|
Displayed in the DM home view (right sidebar, 240px wide). Shows friends grouped by activity status.
|
||||||
|
|
||||||
|
### Friend Categorization
|
||||||
|
|
||||||
|
Friends are sorted into three groups using `useMemo`:
|
||||||
|
|
||||||
|
| Group | Criteria | Display |
|
||||||
|
|-------|----------|---------|
|
||||||
|
| `activeFriends` | Not offline AND primary activity is non-custom | Shown first, no header |
|
||||||
|
| `onlineFriends` | Not offline AND (no primary OR primary is custom) | Header: "ONLINE -- {count}" |
|
||||||
|
| `offlineFriends` | Status is `offline` | Header: "OFFLINE -- {count}" |
|
||||||
|
|
||||||
|
### User ID Resolution
|
||||||
|
|
||||||
|
Activities are looked up by `friend.homeUserId ?? friend.id` — this handles federated users whose local ID differs from their home instance ID.
|
||||||
|
|
||||||
|
### Empty State
|
||||||
|
|
||||||
|
When all three groups are empty, displays: "It's quiet for now..." with explanatory text.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MemberSidebar (`layout/MemberSidebar.tsx`)
|
||||||
|
|
||||||
|
Displayed in space views (right sidebar, 240px wide). Shows space members grouped by role, with activity display.
|
||||||
|
|
||||||
|
### Activity Integration
|
||||||
|
|
||||||
|
Activities are looked up by `member.userId` from the `userActivities` map. Each member row renders an `ActivityCard` with `fallbackCustomStatus` from `member.user.customStatus`. Offline members do not display activities.
|
||||||
|
|
||||||
|
### Role Grouping
|
||||||
|
|
||||||
|
Members are grouped by highest-positioned role (see `getMemberGroup`). The owner always sorts first. Activity display is orthogonal to role grouping.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## WebSocket Events (Cross-Reference)
|
||||||
|
|
||||||
|
See websocket.md for full wire format. Summary of activity-related events:
|
||||||
|
|
||||||
|
### Client to Server
|
||||||
|
|
||||||
|
| Event | Fields | Notes |
|
||||||
|
|-------|--------|-------|
|
||||||
|
| `presence_update` | `status: 'online' \| 'idle' \| 'dnd'` | Persisted to DB |
|
||||||
|
| `activity_update` | `activities: Activity[]` | Rate-limited 3s server-side; rejected if `showActivity=false` |
|
||||||
|
|
||||||
|
### Server to Client
|
||||||
|
|
||||||
|
| Event | Fields | Scope |
|
||||||
|
|-------|--------|-------|
|
||||||
|
| `presence_update` | `userId, status, activities?` | All spaces the user belongs to + self |
|
||||||
|
|
||||||
|
Note: `activities` field is present only when non-empty. Both `presence_update` (status change) and `activity_update` (activity change) result in outbound `presence_update` events to clients — the server coalesces them into a single event type.
|
||||||
|
|
||||||
|
### Ready Payload
|
||||||
|
|
||||||
|
The `ready` event includes `userActivities: Record<userId, Activity[]>` containing activities for all visible users (space members + DM members), with synthetic `custom` activities injected for users with `customStatus` but no ephemeral activities.
|
||||||
@@ -0,0 +1,475 @@
|
|||||||
|
# Admin & Instance Configuration System
|
||||||
|
|
||||||
|
Source files:
|
||||||
|
- `packages/server/src/routes/admin.ts` -- User management, storage management endpoints
|
||||||
|
- `packages/server/src/routes/instance.ts` -- Public instance info endpoint
|
||||||
|
- `packages/server/src/routes/settings.ts` -- Instance settings and streaming limits endpoints
|
||||||
|
- `packages/server/src/utils/auth.ts` -- `requireAdmin` middleware
|
||||||
|
- `packages/server/src/utils/userDeletion.ts` -- `tombstoneUser()` deletion logic
|
||||||
|
- `packages/server/src/utils/storageJanitor.ts` -- Storage stats, orphan detection, cleanup
|
||||||
|
- `packages/web/src/stores/settingsStore.ts` -- Zustand store for instance/streaming settings
|
||||||
|
- `packages/web/src/components/modals/instanceSettingsPanels/GeneralPanel.tsx` -- General settings UI
|
||||||
|
- `packages/web/src/components/modals/instanceSettingsPanels/StoragePanel.tsx` -- Storage management UI
|
||||||
|
- `packages/web/src/components/modals/instanceSettingsPanels/StreamingPanel.tsx` -- Streaming config UI
|
||||||
|
- `packages/web/src/components/modals/instanceSettingsPanels/UsersPanel.tsx` -- User management UI
|
||||||
|
- `packages/shared/src/types.ts` -- Shared type interfaces
|
||||||
|
- `packages/shared/src/constants.ts` -- Streaming constants (resolutions, framerates, bitrate matrix)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authentication & Authorization
|
||||||
|
|
||||||
|
All admin endpoints use two Fastify preHandlers chained in order:
|
||||||
|
|
||||||
|
1. **`authenticate`** -- Verifies JWT from `Authorization: Bearer <token>` header, sets `request.userId`
|
||||||
|
2. **`requireAdmin`** -- Queries `users` table, verifies `isAdmin === 1`. Returns 403 if not admin.
|
||||||
|
|
||||||
|
```
|
||||||
|
auth.ts:requireAdmin()
|
||||||
|
→ db.select().from(users).where(id = request.userId)
|
||||||
|
→ if !caller || caller.isAdmin !== 1 → 403 "Only instance admins can perform this action"
|
||||||
|
```
|
||||||
|
|
||||||
|
The instance info endpoint (`GET /api/instance/info`) is fully public with no auth.
|
||||||
|
The streaming limits read endpoint (`GET /api/settings/streaming`) requires only `authenticate` (any logged-in user).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Instance Settings Table
|
||||||
|
|
||||||
|
Singleton row in `instance_settings` (id=1). See [database.md](database.md) for full schema.
|
||||||
|
|
||||||
|
Settings are split into two API surfaces:
|
||||||
|
|
||||||
|
| Setting Group | Read Endpoint | Write Endpoint |
|
||||||
|
|---------------|--------------|----------------|
|
||||||
|
| General/Admin | `GET /api/settings/instance` (admin) | `PATCH /api/settings/instance` (admin) |
|
||||||
|
| Streaming | `GET /api/settings/streaming` (auth) | `PATCH /api/settings/streaming` (admin) |
|
||||||
|
| Public info | `GET /api/instance/info` (public) | N/A (derived from settings) |
|
||||||
|
|
||||||
|
### General Settings Schema (InstanceAdminSettings)
|
||||||
|
|
||||||
|
| Field | Type | DB Column | Validation | Notes |
|
||||||
|
|-------|------|-----------|------------|-------|
|
||||||
|
| instanceName | string | instanceName | 1-32 chars, trimmed | Default: `'Backspace'` |
|
||||||
|
| registrationOpen | boolean | registrationOpen | boolean | DB null = use env `REGISTRATION_OPEN` (default true) |
|
||||||
|
| discoveryEnabled | boolean | discoveryEnabled | boolean | Controls space Explore page |
|
||||||
|
| gifApiKey | string? | gifApiKey | string or empty to clear | Returned masked as `****{last4}` for security |
|
||||||
|
| gifEnabled | boolean? | (derived) | -- | Read-only; true when gifApiKey is non-null |
|
||||||
|
| maxUploadSizeMb | number | maxUploadSizeBytes | 1-5120 MB | Stored as bytes; converted on read/write. DB null = use env `MAX_UPLOAD_SIZE` (default 100MB) |
|
||||||
|
| federationRelayEnabled | boolean | federationRelayEnabled | boolean | Default: 1 (enabled) |
|
||||||
|
| federationRelayTtlDays | number | federationRelayTtlDays | integer 1-365 | Default: 30 days |
|
||||||
|
|
||||||
|
### Streaming Settings Schema (InstanceStreamingLimits)
|
||||||
|
|
||||||
|
| Field | Type | DB Column | Validation | Default |
|
||||||
|
|-------|------|-----------|------------|---------|
|
||||||
|
| maxBitrateKbps | number | maxBitrateKbps | 500-1000000 | 20000 |
|
||||||
|
| minBitrateKbps | number | minBitrateKbps | 100-1000000, must be < max | 500 |
|
||||||
|
| bitrateStepKbps | number | bitrateStepKbps | 50-5000 | 500 |
|
||||||
|
| allowedResolutions | (number\|'native')[] | allowedResolutions | Non-empty, values from STANDARD_RESOLUTIONS or 'native' | [540,720,1080] |
|
||||||
|
| allowedFramerates | number[] | allowedFramerates | Non-empty, values from STANDARD_FRAMERATES | [30,45,60] |
|
||||||
|
| maxResolution | number | maxResolution | Must be in STANDARD_RESOLUTIONS | 1080 |
|
||||||
|
| maxFramerate | number | maxFramerate | Must be in STANDARD_FRAMERATES | 60 |
|
||||||
|
| discoveryEnabled | boolean | discoveryEnabled | boolean | true |
|
||||||
|
| bitrateMatrixOverrides | Record<string,number>\|null | bitrateMatrixOverrides | Keys: `{res}_{fps}`, values: 1-1000000 | null |
|
||||||
|
| allowCustomBitrate | boolean | allowCustomBitrate | boolean | true |
|
||||||
|
|
||||||
|
**Streaming constants** (`packages/shared/src/constants.ts`):
|
||||||
|
```
|
||||||
|
STANDARD_RESOLUTIONS = [540, 720, 1080, 1440, 2160]
|
||||||
|
STANDARD_FRAMERATES = [30, 45, 60, 75, 90, 120]
|
||||||
|
HIGH_END_RESOLUTION_THRESHOLD = 1440
|
||||||
|
HIGH_END_FRAMERATE_THRESHOLD = 75
|
||||||
|
```
|
||||||
|
|
||||||
|
**Default bitrate matrix** (kbps, VP9 screen share):
|
||||||
|
```
|
||||||
|
30 45 60 75 90 120
|
||||||
|
540 1500 2000 2500 2800 3200 4000
|
||||||
|
720 3000 3500 4000 4500 5000 6000
|
||||||
|
1080 6000 7000 8000 9000 10000 12000
|
||||||
|
1440 10000 12000 14000 16000 18000 22000
|
||||||
|
2160 20000 24000 28000 32000 38000 45000
|
||||||
|
```
|
||||||
|
|
||||||
|
See [voice.md](voice.md) for how clients enforce these limits at the WebRTC encoding boundary.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Serialization Details
|
||||||
|
|
||||||
|
### Resolution/Framerate Storage
|
||||||
|
|
||||||
|
Stored as CSV strings in DB, parsed on read:
|
||||||
|
|
||||||
|
```
|
||||||
|
settings.ts:rowToLimits()
|
||||||
|
allowedResolutions: row.allowedResolutions.split(',')
|
||||||
|
→ .map(s => s === 'native' ? 'native' : Number(s))
|
||||||
|
→ .filter(v => v === 'native' || STANDARD_RESOLUTIONS.includes(v))
|
||||||
|
|
||||||
|
allowedFramerates: row.allowedFramerates.split(',')
|
||||||
|
→ .map(Number)
|
||||||
|
→ .filter(n => STANDARD_FRAMERATES.includes(n))
|
||||||
|
```
|
||||||
|
|
||||||
|
On write, numbers sorted ascending with 'native' always last:
|
||||||
|
|
||||||
|
```
|
||||||
|
settings.ts:PATCH /api/settings/streaming
|
||||||
|
nums = allowedResolutions.filter(r => r !== 'native').sort(asc)
|
||||||
|
updateData.allowedResolutions = [...nums, ...(hasNative ? ['native'] : [])].join(',')
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bitrate Matrix Overrides
|
||||||
|
|
||||||
|
Stored as JSON string in DB. Sparse representation: only cells differing from defaults.
|
||||||
|
|
||||||
|
```
|
||||||
|
settings.ts:rowToLimits()
|
||||||
|
raw = row.bitrateMatrixOverrides (string|null)
|
||||||
|
→ JSON.parse → validate is non-null non-array object → return if non-empty, else null
|
||||||
|
```
|
||||||
|
|
||||||
|
Valid keys: `{resolution}_{framerate}` (e.g., `"1080_60"`). Validated against all combinations of STANDARD_RESOLUTIONS x STANDARD_FRAMERATES.
|
||||||
|
|
||||||
|
### GIF API Key Masking
|
||||||
|
|
||||||
|
The `gifApiKey` is never returned in full. The GET response masks it:
|
||||||
|
|
||||||
|
```
|
||||||
|
settings.ts:GET /api/settings/instance
|
||||||
|
gifApiKey: gifKey ? `****${gifKey.slice(-4)}` : undefined
|
||||||
|
```
|
||||||
|
|
||||||
|
On PATCH, if the client sends back a value starting with `****`, it is ignored (prevents overwriting the real key with the mask). Empty string clears the key.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Public Instance Info
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/instance/info
|
||||||
|
```
|
||||||
|
|
||||||
|
No authentication. Returns:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
name: string; // instanceSettings.instanceName ?? 'Backspace'
|
||||||
|
version: string; // Hardcoded '1.0.0' in instance.ts
|
||||||
|
registrationOpen: boolean; // DB setting overrides env if non-null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Registration resolution order: `instance_settings.registrationOpen` (if not null) > `config.registrationOpen` (from `REGISTRATION_OPEN` env, default true).
|
||||||
|
|
||||||
|
### General Instance Settings
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/settings/instance — admin only → InstanceAdminSettings
|
||||||
|
PATCH /api/settings/instance — admin only → InstanceAdminSettings
|
||||||
|
```
|
||||||
|
|
||||||
|
See field table above for validation rules. Cross-field: `discoveryEnabled` changes here are also synced to `streamingLimits` in the frontend store (`settingsStore.ts:updateInstanceSettings`).
|
||||||
|
|
||||||
|
### Streaming Settings
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/settings/streaming — any authenticated user → InstanceStreamingLimits
|
||||||
|
PATCH /api/settings/streaming — admin only → InstanceStreamingLimits
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cross-field validation** on PATCH:
|
||||||
|
- `minBitrateKbps` must be strictly less than `maxBitrateKbps` (checked against effective values after merge with current DB row).
|
||||||
|
|
||||||
|
### Storage Management
|
||||||
|
|
||||||
|
All admin-only.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/admin/storage/stats → StorageStats
|
||||||
|
GET /api/admin/storage/orphans → { orphans: OrphanedFile[] }
|
||||||
|
POST /api/admin/storage/cleanup { dryRun?: boolean } → CleanupResult
|
||||||
|
POST /api/admin/storage/cleanup-media { maxAgeDays: number, dryRun?: boolean } → CleanupResult
|
||||||
|
```
|
||||||
|
|
||||||
|
**StorageStats shape:**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
totalFiles: number;
|
||||||
|
totalSize: number; // bytes
|
||||||
|
referencedFiles: number;
|
||||||
|
referencedSize: number; // bytes
|
||||||
|
orphanedFiles: number; // Files on disk not referenced by DB
|
||||||
|
orphanedSize: number;
|
||||||
|
unlinkedAttachments: number; // Attachment records with no message
|
||||||
|
unlinkedSize: number;
|
||||||
|
danglingAttachments: number; // Attachment records pointing to missing files
|
||||||
|
danglingSize: number;
|
||||||
|
breakdown: { type: string; count: number; size: number }[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**CleanupResult shape:**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
dryRun: boolean;
|
||||||
|
deletedFiles: number;
|
||||||
|
freedBytes: number;
|
||||||
|
deletedAttachmentRecords: number;
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Storage functions (`getStorageStats`, `getOrphanedFiles`, `cleanupStorage`, `cleanupOldMedia`) are implemented in `utils/storageJanitor.ts`. Out of scope here -- if an uploads.md spec is created, document there.
|
||||||
|
|
||||||
|
**Cleanup flow (UI):**
|
||||||
|
1. Admin clicks "Preview Cleanup" -- calls `cleanupStorage(dryRun=true)` or `cleanupOldMedia(days, dryRun=true)`
|
||||||
|
2. Preview result shown with count/size
|
||||||
|
3. "Clean Up Now" / "Delete Now" button enabled only after preview completes
|
||||||
|
4. Live cleanup calls same endpoint with `dryRun=false`
|
||||||
|
5. Stats refreshed after live cleanup
|
||||||
|
|
||||||
|
**Media cleanup validation:** `maxAgeDays` must be a positive integer >= 1. Returns 400 otherwise.
|
||||||
|
|
||||||
|
### User Management
|
||||||
|
|
||||||
|
All admin-only.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/admin/users → AdminUserListResponse
|
||||||
|
GET /api/admin/users/instances → { instances: string[] }
|
||||||
|
PATCH /api/admin/users/:id/role { isAdmin: boolean } → AdminUser
|
||||||
|
POST /api/admin/users/:id/reset-password → AdminResetPasswordResponse
|
||||||
|
DELETE /api/admin/users/:id → { success: boolean }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## User List: Filters, Search, Sort, Pagination
|
||||||
|
|
||||||
|
### Query Parameters
|
||||||
|
|
||||||
|
| Param | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| q | string | '' | Fuzzy search on username and displayName (SQL LIKE `%q%`) |
|
||||||
|
| page | number | 1 | Page number, min 1 |
|
||||||
|
| pageSize | number | 50 | Results per page, clamped to 1-100 |
|
||||||
|
| showDeleted | 'true' | false | Include tombstoned users |
|
||||||
|
| homeInstance | string | -- | `'local'` for null homeInstance; otherwise exact domain match |
|
||||||
|
| role | string | -- | `'admin'` or `'non-admin'` |
|
||||||
|
| joinedAfter | date string | -- | Parsed as `new Date(value).getTime()` |
|
||||||
|
| joinedBefore | date string | -- | Parsed as `new Date(value + 'T23:59:59.999Z').getTime()` (inclusive end-of-day) |
|
||||||
|
| sort | string | 'newest' | One of: `newest`, `oldest`, `az`, `za` |
|
||||||
|
|
||||||
|
### Sort Options
|
||||||
|
|
||||||
|
| Value | SQL | Description |
|
||||||
|
|-------|-----|-------------|
|
||||||
|
| newest | `desc(users.createdAt)` | Most recently created first |
|
||||||
|
| oldest | `asc(users.createdAt)` | Oldest first |
|
||||||
|
| az | `asc(users.username)` | Alphabetical A-Z |
|
||||||
|
| za | `desc(users.username)` | Reverse alphabetical |
|
||||||
|
|
||||||
|
### Filter Composition
|
||||||
|
|
||||||
|
Filters are combined with AND. When `showDeleted` is false (default), `isDeleted = 0` is always added. Search `q` creates an OR condition across `username LIKE` and `displayName LIKE`.
|
||||||
|
|
||||||
|
### Instances Endpoint
|
||||||
|
|
||||||
|
`GET /api/admin/users/instances` returns all distinct non-null `homeInstance` values from the users table. Used by the frontend to populate the instance filter dropdown.
|
||||||
|
|
||||||
|
### AdminUser Shape
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
displayName: string | null;
|
||||||
|
avatar: string | null;
|
||||||
|
avatarColor: string | null;
|
||||||
|
status: string; // 'online'|'idle'|'dnd'|'offline', defaults to 'offline' if null
|
||||||
|
isAdmin: boolean;
|
||||||
|
isDeleted: boolean;
|
||||||
|
homeInstance: string | null;
|
||||||
|
createdAt: number; // epoch ms
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Produced by `admin.ts:toAdminUser()` -- maps integer DB columns to booleans, coalesces null status.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Admin Actions: Safety Rules
|
||||||
|
|
||||||
|
### Promote/Demote Admin (PATCH /api/admin/users/:id/role)
|
||||||
|
|
||||||
|
```
|
||||||
|
Request: { isAdmin: boolean }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Safety checks:**
|
||||||
|
1. `isAdmin` must be a boolean (400)
|
||||||
|
2. Target user must exist (404)
|
||||||
|
3. Target must not be deleted (400)
|
||||||
|
4. **Promote**: target must NOT have a `homeInstance` (403 "Federated users cannot be promoted to admin")
|
||||||
|
5. **Demote**: if target is currently admin, count all non-deleted admins. If count <= 1, reject (400 "Cannot demote the last admin")
|
||||||
|
|
||||||
|
**Side effects on success:**
|
||||||
|
- Updates `users.isAdmin` to 1 or 0
|
||||||
|
- Sends `user_updated` WebSocket event to target user via `connectionManager.sendToUser()` so their UI reflects the change immediately
|
||||||
|
|
||||||
|
### Reset Password (POST /api/admin/users/:id/reset-password)
|
||||||
|
|
||||||
|
**Safety checks:**
|
||||||
|
1. Target must exist (404)
|
||||||
|
2. Target must not be deleted (400)
|
||||||
|
3. Target must NOT have a `homeInstance` (400 "Federated users authenticate via their home instance")
|
||||||
|
|
||||||
|
**Process:**
|
||||||
|
1. Generate 12 random bytes, encode as `base64url` -- this is the temporary password
|
||||||
|
2. Hash with bcrypt via `hashPassword()`
|
||||||
|
3. Update `users.passwordHash` and `users.passwordChangedAt = Date.now()`
|
||||||
|
4. Force-disconnect all of target's WebSocket sessions via `connectionManager.forceDisconnectUser()`
|
||||||
|
5. Return `{ temporaryPassword }` in response
|
||||||
|
|
||||||
|
The `passwordChangedAt` update ensures all existing JWTs for the user are invalidated (tokens issued before this timestamp are rejected by the auth middleware).
|
||||||
|
|
||||||
|
The temporary password is shown exactly once in the admin UI -- the UsersPanel displays it inline below the user row with a copy button and a "Shown once" warning.
|
||||||
|
|
||||||
|
### Delete User (DELETE /api/admin/users/:id)
|
||||||
|
|
||||||
|
**Safety checks:**
|
||||||
|
1. Cannot delete yourself (400 "Use account settings to delete your own account")
|
||||||
|
2. Target must exist (404)
|
||||||
|
3. Target must not be already deleted (400)
|
||||||
|
4. Target must not own any spaces (400 "User owns spaces -- transfer ownership first", response includes `ownedSpaces` array with id/name)
|
||||||
|
|
||||||
|
**Process:**
|
||||||
|
1. Call `tombstoneUser(targetId)` -- returns list of files to delete (avatar, banner)
|
||||||
|
- Tombstone sets `isDeleted=1`, clears personal data, removes from spaces/friends/DMs/reactions/read-states/folders in a transaction
|
||||||
|
- Transfers group DM ownership to next member
|
||||||
|
2. Delete returned files from disk via `deleteUploadFile()`
|
||||||
|
3. Force-disconnect all WebSocket sessions
|
||||||
|
4. Return `{ success: true }`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend Architecture
|
||||||
|
|
||||||
|
### Settings Store (settingsStore.ts)
|
||||||
|
|
||||||
|
Zustand store managing two data objects:
|
||||||
|
|
||||||
|
| State | Type | Fetched Via | Updated Via |
|
||||||
|
|-------|------|-------------|-------------|
|
||||||
|
| streamingLimits | InstanceStreamingLimits \| null | `fetchStreamingLimits()` | `updateStreamingLimits()` |
|
||||||
|
| instanceSettings | InstanceAdminSettings \| null | `fetchInstanceSettings()` | `updateInstanceSettings()` |
|
||||||
|
| isAdmin | boolean | Set externally via `setIsAdmin()` | -- |
|
||||||
|
| gifEnabled | boolean | `fetchGifEnabled()` | -- |
|
||||||
|
|
||||||
|
**Default fallback:** If streaming limits fail to fetch, the store falls back to `DEFAULT_LIMITS`:
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
maxBitrateKbps: 20000,
|
||||||
|
minBitrateKbps: 500,
|
||||||
|
bitrateStepKbps: 500,
|
||||||
|
allowedResolutions: [540, 720, 1080],
|
||||||
|
allowedFramerates: [30, 45, 60],
|
||||||
|
maxResolution: 1080,
|
||||||
|
maxFramerate: 60,
|
||||||
|
discoveryEnabled: true,
|
||||||
|
bitrateMatrixOverrides: null,
|
||||||
|
allowCustomBitrate: true,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cross-field sync:** When `updateInstanceSettings()` changes `discoveryEnabled`, it also patches `streamingLimits.discoveryEnabled` to keep the streaming panel's DiscoveryPanel warning banner in sync.
|
||||||
|
|
||||||
|
**Exported helper:** `getStreamingLimits()` returns current limits or defaults -- used by voice/streaming code outside React.
|
||||||
|
|
||||||
|
### Admin UI Panels
|
||||||
|
|
||||||
|
All panels live under `packages/web/src/components/modals/instanceSettingsPanels/`. Each operates as a controlled form with a local `draft` state, detecting changes against the store's server-synced values. Unsaved changes show a sticky glass-bubble save/reset bar at the bottom.
|
||||||
|
|
||||||
|
#### GeneralPanel
|
||||||
|
|
||||||
|
Manages: instance name, registration toggle, discovery toggle, GIF API key, federation relay toggle/TTL, peered instances list.
|
||||||
|
|
||||||
|
- Instance name input: max 32 chars, enforced client-side via `slice(0, 32)`
|
||||||
|
- GIF key: password input, separate dirty tracking (`gifKeyDirty`). Only sent on save if modified. "Clear key" button sets empty string.
|
||||||
|
- Federation peers: fetched via `api.federation.peers()`, displayed as a list with status badges (active/pending/unreachable), last-seen/synced times, revoke button
|
||||||
|
- Peers with status `'revoked'` are filtered out of the visible list
|
||||||
|
- Revoke calls `api.federation.revokePeer(peerId)` and removes from local list
|
||||||
|
|
||||||
|
#### StoragePanel
|
||||||
|
|
||||||
|
Manages: storage overview, file type breakdown, upload limit, orphan cleanup, media retention cleanup.
|
||||||
|
|
||||||
|
- **Stats grid:** 5 cards (total files, referenced, orphaned, unlinked uploads, dangling records) with byte formatting
|
||||||
|
- **File type breakdown:** List of categories (image/video/audio/document/other) with file counts and sizes
|
||||||
|
- **Upload limit:** Number input (1-5120 MB) with save button, persisted via `updateInstanceSettings({ maxUploadSizeMb })`
|
||||||
|
- **Orphan cleanup:** Two-step (preview dry-run, then live). "Clean Up Now" disabled until preview completes
|
||||||
|
- **Media retention:** Age-based cleanup with configurable days input. Same two-step preview/execute pattern.
|
||||||
|
- "Refresh Stats" link at bottom re-fetches all stats
|
||||||
|
|
||||||
|
#### StreamingPanel
|
||||||
|
|
||||||
|
Manages: bitrate range (min/max/step), custom bitrate toggle, resolution/framerate allowlists, bitrate matrix.
|
||||||
|
|
||||||
|
- **Bandwidth section:** Range sliders + number inputs for min/max bitrate. Step size via preset pills (100, 250, 500, 1000, 2500, 5000 kbps) + custom number input.
|
||||||
|
- **Custom Bitrate toggle:** Controls whether users can set their own bitrate vs using matrix defaults
|
||||||
|
- **Quality section:** Toggle pills for each resolution (540p, 720p, 1080p, 1440p, 4K, Native) and framerate (30, 45, 60, 75, 90, 120 fps). At least one of each must remain enabled.
|
||||||
|
- **High-end warning:** Shown when resolutions >= 1440 or framerates >= 75 are enabled. Warns about CPU/GPU/bandwidth requirements.
|
||||||
|
- **Bitrate matrix:** Interactive grid of resolution x framerate cells. Displays in Mbps (stored as kbps). Click to edit. Overridden cells highlighted in accent-primary. Cells exceeding maxBitrateKbps highlighted in amber.
|
||||||
|
- **Scale slider:** Multiplies all matrix values by 0.5x-2.0x. Captures snapshot on drag start, applies factor during drag, releases on pointer up.
|
||||||
|
- **Save payload:** Only cells differing from defaults are sent as `bitrateMatrixOverrides`; identical-to-default cells are omitted (sparse representation). If no overrides, `null` is sent.
|
||||||
|
|
||||||
|
#### UsersPanel
|
||||||
|
|
||||||
|
Manages: user list with search/filter/sort/pagination, admin promotion/demotion, password reset, account deletion.
|
||||||
|
|
||||||
|
- **Search:** Debounced (300ms) text input, resets to page 1 on change
|
||||||
|
- **Filters:** Instance dropdown (local/specific domain), role (admin/non-admin), joined-after/joined-before date pickers, sort dropdown
|
||||||
|
- **"Clear filters" link:** Visible when any filter/sort/search is active. Resets all to defaults.
|
||||||
|
- **Page size:** Fixed at 50 (not user-configurable)
|
||||||
|
- **Pagination:** Previous/Next buttons, "Page X of Y (N users)" label
|
||||||
|
- **User rows:** Avatar, username, display name, badges (Admin amber, federated instance sky, Deleted rose), join date
|
||||||
|
- **Action buttons** (per user, hidden if deleted):
|
||||||
|
- Shield icon: promote/demote admin. Disabled for federated users. Demotion requires ConfirmDialog.
|
||||||
|
- Key icon: reset password. Disabled for federated users. Requires ConfirmDialog. Shows temporary password inline.
|
||||||
|
- Trash icon: delete user. Disabled for self. Requires ConfirmDialog (danger variant).
|
||||||
|
- **Temp password display:** Appears inline below the user row after successful reset. Includes copy-to-clipboard button and "Shown once" notice.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Flow: Settings Update Lifecycle
|
||||||
|
|
||||||
|
```
|
||||||
|
[Admin UI Panel]
|
||||||
|
→ local draft state (useState)
|
||||||
|
→ user clicks Save
|
||||||
|
→ settingsStore.updateStreamingLimits() / updateInstanceSettings()
|
||||||
|
→ api.settings.updateStreaming() / updateInstance()
|
||||||
|
→ PATCH /api/settings/streaming or /api/settings/instance
|
||||||
|
→ Server validates each field
|
||||||
|
→ Cross-field validation (min < max for bitrates)
|
||||||
|
→ db.update(instanceSettings).set(updateData).where(id=1)
|
||||||
|
→ db.select fresh row → serialize → return
|
||||||
|
→ store.set({ streamingLimits: updated }) / set({ instanceSettings: updated })
|
||||||
|
→ UI re-renders from store, draft resets to match
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cross-References
|
||||||
|
|
||||||
|
- **Database schema:** [database.md](database.md) -- `instance_settings`, `users`, `spaces` tables
|
||||||
|
- **API endpoints:** [api.md](api.md) -- Full endpoint listing for admin, settings, instance routes
|
||||||
|
- **Federation relay:** [federation.md](federation.md) -- Relay toggle/TTL mechanics, peer management, outbox delivery
|
||||||
|
- **Voice/streaming:** [voice.md](voice.md) -- Client-side enforcement of streaming limits
|
||||||
|
- **Permissions:** [permissions.md](permissions.md) -- Admin flag is separate from RBAC; `isAdmin` is a user-level column, not a permission bit
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
# REST API Reference
|
||||||
|
|
||||||
|
Base: `/api`. Auth via `Authorization: Bearer <jwt>`. All responses JSON.
|
||||||
|
Source files: `packages/server/src/routes/*.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Auth (`routes/auth.ts`) — public, rate-limited
|
||||||
|
```
|
||||||
|
POST /auth/register { username, password, displayName?, avatarColor?, homeInstance?, homeUserId? } → { token, user }
|
||||||
|
GET /auth/check-username ?username= → { available, reason? }
|
||||||
|
POST /auth/login { username, password } → { token, user }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Users (`routes/users.ts`) — auth required
|
||||||
|
```
|
||||||
|
GET /users/@me → { user }
|
||||||
|
PATCH /users/@me { displayName?, avatar?, banner?, accentColor?, avatarColor?,
|
||||||
|
bio?, customStatus?, status?, replicatedInstances?, homeUserId?,
|
||||||
|
profileUpdatedAt?, discoverable?, showActivity? } → { user }
|
||||||
|
POST /users/@me/verify-password { password } → { valid }
|
||||||
|
POST /users/@me/change-password { currentPassword?, newPassword } → { token }
|
||||||
|
DELETE /users/@me { password, username } → { success }
|
||||||
|
PUT /users/@me/space-layout { items, folders, updatedAt? } → { items, folders, updatedAt }
|
||||||
|
GET /users/:id → { user }
|
||||||
|
GET /users/:id/mutuals ?homeUserId= → { mutualFriends[], mutualSpaces[] }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Spaces (`routes/spaces.ts`) — auth required
|
||||||
|
```
|
||||||
|
GET /spaces → { spaces[] }
|
||||||
|
POST /spaces { name, icon?, description? } → { space }
|
||||||
|
GET /spaces/:id → { space, channels[], members[], roles[] }
|
||||||
|
PATCH /spaces/:id { name?, icon?, banner?, description?, visibility?, avatarColor? } → { space } [MANAGE_SPACE]
|
||||||
|
DELETE /spaces/:id → { success } [owner]
|
||||||
|
POST /spaces/:id/invite → { inviteCode } [CREATE_INVITE]
|
||||||
|
POST /spaces/:id/join { inviteCode } → { space }
|
||||||
|
POST /spaces/join { inviteCode } → { space }
|
||||||
|
GET /spaces/invite/:code/preview → invite preview
|
||||||
|
PATCH /spaces/:id/transfer-ownership { newOwnerId } → { space } [owner]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Members
|
||||||
|
```
|
||||||
|
GET /spaces/:id/members → { members[] }
|
||||||
|
PATCH /spaces/:id/members/:uid { nickname?, roles? } → { member } [MANAGE_ROLES]
|
||||||
|
DELETE /spaces/:id/members/:uid → { success } [KICK_MEMBERS|self]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bans
|
||||||
|
```
|
||||||
|
GET /spaces/:id/bans → { bans[] } [BAN_MEMBERS]
|
||||||
|
POST /spaces/:id/bans { userId, reason? } → { success } [BAN_MEMBERS]
|
||||||
|
DELETE /spaces/:id/bans/:uid → { success } [BAN_MEMBERS]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Roles
|
||||||
|
```
|
||||||
|
POST /spaces/:id/roles { name, color?, permissions? } → { role } [MANAGE_ROLES]
|
||||||
|
PATCH /spaces/:id/roles/:rid { name?, color?, position?, permissions? } → { role } [MANAGE_ROLES]
|
||||||
|
DELETE /spaces/:id/roles/:rid → { success } [MANAGE_ROLES]
|
||||||
|
POST /spaces/:id/members/:uid/roles { roleId } → { success } [MANAGE_ROLES]
|
||||||
|
DELETE /spaces/:id/members/:uid/roles/:rid → { success } [MANAGE_ROLES]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Channels (`routes/channels.ts`) — auth required
|
||||||
|
```
|
||||||
|
GET /spaces/:id/channels → { channels[] } [VIEW_CHANNEL]
|
||||||
|
POST /spaces/:id/channels { name, type?, topic?, categoryId? } → { channel } [MANAGE_CHANNELS]
|
||||||
|
PATCH /channels/:id { name?, type?, topic?, categoryId? } → { channel } [MANAGE_CHANNELS]
|
||||||
|
DELETE /channels/:id → { success } [MANAGE_CHANNELS]
|
||||||
|
PATCH /spaces/:id/channels/reorder { order } → reordered [MANAGE_CHANNELS]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Channel Overrides
|
||||||
|
```
|
||||||
|
GET /channels/:id/overrides → { overrides[] } [MANAGE_CHANNELS]
|
||||||
|
PUT /channels/:id/overrides { targetType, targetId, permissions } → { override } [MANAGE_CHANNELS]
|
||||||
|
DELETE /channels/:id/overrides/:targetType/:targetId → { success } [MANAGE_CHANNELS]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Categories
|
||||||
|
```
|
||||||
|
POST /spaces/:id/categories { name } → { category } [MANAGE_CHANNELS]
|
||||||
|
PATCH /categories/:id { name?, position? } → { category } [MANAGE_CHANNELS]
|
||||||
|
DELETE /categories/:id → { success } [MANAGE_CHANNELS]
|
||||||
|
GET /categories/:id/overrides → { overrides[] } [MANAGE_ROLES]
|
||||||
|
PUT /categories/:id/overrides { targetType, targetId, permissions } → { success } [MANAGE_ROLES]
|
||||||
|
DELETE /categories/:id/overrides/:tt/:tid → { success } [MANAGE_ROLES]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Messages (`routes/messages.ts`) — auth required
|
||||||
|
```
|
||||||
|
GET /channels/:id/messages ?before=&limit=50 → { messages[] } [VIEW_CHANNEL+READ_MESSAGE_HISTORY]
|
||||||
|
POST /channels/:id/messages { content, attachments?, replyToId? } → { message } [SEND_MESSAGES, +ATTACH_FILES]
|
||||||
|
PATCH /messages/:id { content } → { message } [author]
|
||||||
|
DELETE /messages/:id → { success } [author|MANAGE_MESSAGES]
|
||||||
|
```
|
||||||
|
|
||||||
|
## DMs (`routes/dm.ts`) — auth required
|
||||||
|
```
|
||||||
|
POST /dm { targetUserId, targetUsername? } → { dmChannel }
|
||||||
|
POST /dm/group { name, memberUserIds[] } → { dmChannel }
|
||||||
|
DELETE /dm/:id → { success } (soft-close)
|
||||||
|
POST /dm/:id/members { userIds[] } → { dmChannel } [owner, max 10]
|
||||||
|
DELETE /dm/:id/members → { success } (leave)
|
||||||
|
GET /dm/:id/messages ?before=&limit=50 → { messages[] }
|
||||||
|
POST /dm/:id/messages { content, attachments?, replyToId? } → { message }
|
||||||
|
PATCH /dm/messages/:id { content } → { message } [author]
|
||||||
|
DELETE /dm/messages/:id → { success } [author]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Social (`routes/social.ts`) — auth required
|
||||||
|
```
|
||||||
|
GET /social/friends → { friends[] }
|
||||||
|
GET /social/requests → { requests[] }
|
||||||
|
POST /social/requests { username } → { request }
|
||||||
|
PATCH /social/requests/:id { status: 'accepted'|'declined' } → { request }
|
||||||
|
DELETE /social/requests/:id → { success } (cancel, sender-only)
|
||||||
|
DELETE /social/friends/:id → { success }
|
||||||
|
GET /social/discover ?q=&limit=&offset= → { users[], total }
|
||||||
|
GET /social/search ?q= → { users[] }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Search (`routes/search.ts`) — auth required
|
||||||
|
```
|
||||||
|
GET /channels/:id/search ?q=&from=&has=&before=&after=&offset=&limit= → { results[], totalCount } [VIEW_CHANNEL]
|
||||||
|
GET /channels/:id/messages/around ?messageId=&limit= → { messages[] }
|
||||||
|
GET /dm/:id/search ?q=&from=&has=&before=&after=&offset=&limit= → { results[], totalCount }
|
||||||
|
GET /dm/:id/messages/around ?messageId=&limit= → { messages[] }
|
||||||
|
```
|
||||||
|
has: `file`|`image`|`link`
|
||||||
|
|
||||||
|
## Explore (`routes/explore.ts`) — auth required
|
||||||
|
```
|
||||||
|
GET /spaces/explore ?q=&limit=&offset= → { spaces[], total, totalAll, discoveryEnabled }
|
||||||
|
POST /spaces/:id/public-join → { space }
|
||||||
|
POST /spaces/:id/request-join { message? } → { request }
|
||||||
|
GET /spaces/:id/join-requests ?status= → { requests[] } [MANAGE_SPACE]
|
||||||
|
PATCH /spaces/:id/join-requests/:rid { action } → { request } [MANAGE_SPACE]
|
||||||
|
GET /users/@me/join-requests ?status= → { requests[] }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Uploads (`routes/uploads.ts`)
|
||||||
|
```
|
||||||
|
POST /uploads (auth, multipart, rate-limited) → { attachment }
|
||||||
|
GET /uploads/:filename (public, supports Range) → file stream
|
||||||
|
```
|
||||||
|
|
||||||
|
## GIF (`routes/gif.ts`) — auth required
|
||||||
|
```
|
||||||
|
GET /gif/enabled → { enabled }
|
||||||
|
GET /gif/trending ?limit=&pos= → { results[], next }
|
||||||
|
GET /gif/search ?q=&limit=&pos= → { results[], next }
|
||||||
|
```
|
||||||
|
Backend: Klipy API (requires `gifApiKey` in instance_settings)
|
||||||
|
|
||||||
|
## Voice (`routes/livekit.ts`) — auth required
|
||||||
|
```
|
||||||
|
POST /livekit/token { channelId | dmChannelId } → { token, url }
|
||||||
|
```
|
||||||
|
Permissions checked: CONNECT, SPEAK, STREAM (space channels). DM calls: always full grants.
|
||||||
|
|
||||||
|
## Instance (`routes/instance.ts`) — public
|
||||||
|
```
|
||||||
|
GET /instance/info → { name, version, registrationOpen }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Settings (`routes/settings.ts`)
|
||||||
|
```
|
||||||
|
GET /settings/streaming (auth) → { streamingLimits }
|
||||||
|
PATCH /settings/streaming (admin) → { streamingLimits }
|
||||||
|
GET /settings/instance (admin) → { instanceName, registrationOpen, discoveryEnabled, ... }
|
||||||
|
PATCH /settings/instance (admin) { instanceName?, registrationOpen?, discoveryEnabled?,
|
||||||
|
gifApiKey?, maxUploadSizeMb?, federationRelayEnabled?,
|
||||||
|
federationRelayTtlDays? } → { settings }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Admin (`routes/admin.ts`) — admin required
|
||||||
|
```
|
||||||
|
GET /admin/storage/stats → StorageStats
|
||||||
|
GET /admin/storage/orphans → { orphans[] }
|
||||||
|
POST /admin/storage/cleanup { dryRun? } → CleanupResult
|
||||||
|
POST /admin/storage/cleanup-media { maxAgeDays, dryRun? } → CleanupResult
|
||||||
|
GET /admin/users ?q=&page=&pageSize=&showDeleted=&homeInstance=&role=&joinedAfter=&joinedBefore=&sort= → AdminUserListResponse
|
||||||
|
GET /admin/users/instances → distinct home instance domains
|
||||||
|
PATCH /admin/users/:id/role { isAdmin } → AdminUser
|
||||||
|
POST /admin/users/:id/reset-password → { temporaryPassword }
|
||||||
|
DELETE /admin/users/:id → { success }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Federation (`routes/federation.ts`)
|
||||||
|
```
|
||||||
|
POST /federation/peer/initiate (admin) { remoteOrigin } → peer created
|
||||||
|
POST /federation/peer/accept (public, IP rate-limited 10/min) { sourceOrigin, challenge, hmacSecret } → accepted
|
||||||
|
GET /federation/peers (admin) → { peers[] } (no secrets)
|
||||||
|
DELETE /federation/peers/:id (admin) → { success } + outbox cleanup
|
||||||
|
POST /federation/relay (HMAC-signed S2S) FederationRelayRequest → { accepted[], rejected[] }
|
||||||
|
POST /federation/sync (HMAC-signed S2S) { sinceTimestamp, limit?, dmChannelId?, federatedId?, contextType? } → { events[], hasMore, checkpoint }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Utilities (`routes/utils.ts`) — auth required
|
||||||
|
```
|
||||||
|
GET /utils/metadata ?url= → { title?, description?, image?, siteName? }
|
||||||
|
GET /health (public) → { status: 'ok', timestamp }
|
||||||
|
```
|
||||||
@@ -0,0 +1,513 @@
|
|||||||
|
# Authentication & Session System
|
||||||
|
|
||||||
|
Source files:
|
||||||
|
- `packages/server/src/routes/auth.ts` -- Registration, login, username availability endpoints
|
||||||
|
- `packages/server/src/routes/users.ts` -- Password change, account deletion endpoints (lines 58-156)
|
||||||
|
- `packages/server/src/routes/admin.ts` -- Admin password reset endpoint (lines 232-265)
|
||||||
|
- `packages/server/src/utils/auth.ts` -- Password hashing, JWT sign/verify, `authenticate` preHandler, `requireAdmin`
|
||||||
|
- `packages/server/src/utils/userDeletion.ts` -- `tombstoneUser()` transactional account erasure
|
||||||
|
- `packages/server/src/utils/sanitize.ts` -- `sanitizeUser()` strips internal fields, anonymizes deleted users
|
||||||
|
- `packages/server/src/ws/handler.ts` -- WebSocket auth handshake (lines 1282-1374)
|
||||||
|
- `packages/web/src/stores/authStore.ts` -- Client session state, login/register/logout/password/delete actions
|
||||||
|
- `packages/web/src/hooks/useAuth.ts` -- Route guard hook (redirect to `/login` when no token)
|
||||||
|
- `packages/web/src/App.tsx` -- `ProtectedRoute` and `AuthRedirect` route wrappers
|
||||||
|
- `packages/web/src/utils/identity.ts` -- Federation-aware identity helpers (`parseFederatedUsername`, `isSelf`, `canonicalUserMatch`)
|
||||||
|
- `packages/web/src/utils/federationOps.ts` -- Cross-instance password sync and account deletion propagation
|
||||||
|
- `packages/server/src/config.ts` -- `jwtSecret`, `jwtExpiresIn`, `registrationOpen` config
|
||||||
|
|
||||||
|
DB tables: `users`, `instanceSettings`. See `database.md` for full schemas.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Password Hashing
|
||||||
|
|
||||||
|
**Library:** `bcryptjs`
|
||||||
|
**Salt rounds:** 12 (constant `SALT_ROUNDS` in `auth.ts`)
|
||||||
|
|
||||||
|
```
|
||||||
|
hashPassword(password: string): Promise<string> -- bcrypt.hash(password, 12)
|
||||||
|
verifyPassword(password: string, hash: string): Promise<boolean> -- bcrypt.compare
|
||||||
|
```
|
||||||
|
|
||||||
|
**Federation stub marker:** Replicated user stubs have `passwordHash = '!federation-replicated'`. Since bcrypt never produces this value, login is impossible for stubs. See `federation.md` for identity resolution.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. JWT Management
|
||||||
|
|
||||||
|
### Signing
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface JwtPayload {
|
||||||
|
userId: string;
|
||||||
|
username: string;
|
||||||
|
iat?: number; // Auto-set by jsonwebtoken library (seconds since epoch)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Algorithm:** HS256 (enforced on verify via `{ algorithms: ['HS256'] }`)
|
||||||
|
- **Secret:** `config.jwtSecret` (env `JWT_SECRET`, minimum 32 characters -- startup crash if shorter)
|
||||||
|
- **Expiry:** `config.jwtExpiresIn` (env `JWT_EXPIRES_IN`, default `'30d'`)
|
||||||
|
- **Library:** `jsonwebtoken`
|
||||||
|
|
||||||
|
`signJwt(payload)` creates a token with `{ expiresIn }` option. The `iat` field is auto-injected by the library.
|
||||||
|
|
||||||
|
### Validation (`authenticate` preHandler)
|
||||||
|
|
||||||
|
Applied as `preHandler` on all authenticated routes. Flow:
|
||||||
|
|
||||||
|
1. Extract `Bearer <token>` from `Authorization` header
|
||||||
|
2. `verifyJwt(token)` -- checks signature (HS256) and expiry
|
||||||
|
3. DB lookup: fetch `id`, `isDeleted`, `passwordChangedAt` from `users` table
|
||||||
|
4. Reject if user not found or `isDeleted === 1`
|
||||||
|
5. **Token revocation check:** if `passwordChangedAt` is set and `payload.iat` exists, reject if `iat < Math.floor(passwordChangedAt / 1000)` (JWT `iat` is seconds, `passwordChangedAt` is milliseconds)
|
||||||
|
6. Attach `userId` and `username` to `request` object
|
||||||
|
|
||||||
|
### Token Revocation
|
||||||
|
|
||||||
|
There is **no token blocklist**. The only revocation mechanism is the `passwordChangedAt` timestamp:
|
||||||
|
|
||||||
|
- When a user changes their password (or an admin resets it), `passwordChangedAt` is set to `Date.now()`
|
||||||
|
- All tokens issued before that timestamp (`iat < passwordChangedAt/1000`) are rejected
|
||||||
|
- A fresh token is issued after password change
|
||||||
|
|
||||||
|
**Exception:** Federation password self-healing (see section 4) does NOT set `passwordChangedAt` -- it is a state correction, not a password change, so existing valid JWTs remain valid.
|
||||||
|
|
||||||
|
### WebSocket Auth
|
||||||
|
|
||||||
|
`ws/handler.ts:registerWebSocket()` -- WS connection at `/ws`:
|
||||||
|
|
||||||
|
1. Client connects, 10-second auth timeout starts
|
||||||
|
2. First message must be `{ type: 'auth', token: '<jwt>' }`
|
||||||
|
3. `verifyJwt(token)` validates signature and expiry
|
||||||
|
4. DB check: reject if user deleted or token revoked (same `passwordChangedAt` logic as REST)
|
||||||
|
5. On success: clears timeout, sets status to `'online'`, registers connection, sends `ready` payload, broadcasts presence
|
||||||
|
6. On failure: sends error message and closes socket
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Registration Flow
|
||||||
|
|
||||||
|
**Endpoint:** `POST /api/auth/register`
|
||||||
|
**Rate limit:** 10 requests / 2 minutes per IP
|
||||||
|
**Auth:** None
|
||||||
|
|
||||||
|
### Input Validation
|
||||||
|
|
||||||
|
| Field | Rules |
|
||||||
|
|-------|-------|
|
||||||
|
| `username` | Required string. Trimmed, lowercased. |
|
||||||
|
| `password` | Required string. Minimum 8 characters. |
|
||||||
|
| `displayName` | Optional. Trimmed or null. |
|
||||||
|
| `avatarColor` | Optional. Must be in `AVATAR_COLORS` array, else random. |
|
||||||
|
| `homeInstance` | Optional (federation only). Max 253 chars, alphanumeric + `.` `-` `_`. |
|
||||||
|
| `homeUserId` | Optional (federation only). Stored if `homeInstance` is present. |
|
||||||
|
|
||||||
|
### Username Validation (Two Paths)
|
||||||
|
|
||||||
|
**Local registration** (`homeInstance` absent):
|
||||||
|
- Length: 3-32 characters
|
||||||
|
- Pattern: `/^[a-z0-9_]+$/` (lowercase alphanumeric + underscore)
|
||||||
|
- No `@` allowed
|
||||||
|
|
||||||
|
**Federated/replicated registration** (`homeInstance` present):
|
||||||
|
- MUST use `username@domain` format (plain usernames reserved for native users)
|
||||||
|
- Local part: 3-32 chars, `/^[a-z0-9_]+$/`
|
||||||
|
- Domain part: 1-253 chars, `/^[a-zA-Z0-9._-]+$/`
|
||||||
|
- Total: max 100 characters
|
||||||
|
|
||||||
|
### Registration Gate
|
||||||
|
|
||||||
|
Registration open/closed is determined by:
|
||||||
|
1. `instanceSettings.registrationOpen` (DB, id=1) -- if not null, this takes priority
|
||||||
|
2. `config.registrationOpen` (env `REGISTRATION_OPEN`, default `true`) -- fallback
|
||||||
|
|
||||||
|
Both are checked. DB value overrides env when explicitly set by admin.
|
||||||
|
|
||||||
|
### First-User Admin Promotion
|
||||||
|
|
||||||
|
```
|
||||||
|
const userCount = db.select().from(schema.users).all().length;
|
||||||
|
const isFirstUser = userCount === 0 && !homeInstance;
|
||||||
|
```
|
||||||
|
|
||||||
|
The very first user registered on the instance (and only if local, not replicated) gets `isAdmin = 1`.
|
||||||
|
|
||||||
|
### Avatar Color Assignment
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const AVATAR_COLORS = ['mint', 'sky', 'lavender', 'coral', 'rose', 'teal', 'amber'] as const;
|
||||||
|
```
|
||||||
|
|
||||||
|
If `requestedAvatarColor` is provided and is in `AVATAR_COLORS`, use it. Otherwise, pick randomly from the array.
|
||||||
|
|
||||||
|
### Registration Steps
|
||||||
|
|
||||||
|
1. Validate inputs (username format, password length)
|
||||||
|
2. Check registration is open
|
||||||
|
3. Check username uniqueness (exact match on lowercased username)
|
||||||
|
4. Hash password (bcrypt, 12 rounds)
|
||||||
|
5. Generate Snowflake ID
|
||||||
|
6. Insert user row with `status: 'online'`, admin flag if first user
|
||||||
|
7. Sign JWT with `{ userId, username }`
|
||||||
|
8. Return `{ token, user }` (user sanitized via `sanitizeUser(user, true)`)
|
||||||
|
|
||||||
|
### Username Availability Check
|
||||||
|
|
||||||
|
**Endpoint:** `GET /api/auth/check-username?username=<name>`
|
||||||
|
**Rate limit:** 30 requests / 1 minute per IP
|
||||||
|
**Auth:** None
|
||||||
|
|
||||||
|
Validates format (same rules as local registration: 3-32 chars, `/^[a-z0-9_]+$/`), checks registration gate, then queries `users` table for existence. Returns `{ available: boolean, reason?: string }`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Login Flow
|
||||||
|
|
||||||
|
**Endpoint:** `POST /api/auth/login`
|
||||||
|
**Rate limit:** 15 requests / 2 minutes per IP
|
||||||
|
**Auth:** None
|
||||||
|
|
||||||
|
### Steps
|
||||||
|
|
||||||
|
1. Validate `username` and `password` are present strings
|
||||||
|
2. Look up user by `username` (trimmed, lowercased)
|
||||||
|
3. Reject if not found (generic "Invalid username or password")
|
||||||
|
4. Reject if `isDeleted === 1` ("This account has been deleted")
|
||||||
|
5. Verify password via bcrypt
|
||||||
|
6. **If password invalid AND user is federated:** attempt self-healing (see below)
|
||||||
|
7. **If password invalid AND user is local:** reject
|
||||||
|
8. Set user status to `'online'`
|
||||||
|
9. Sign JWT, return `{ token, user }`
|
||||||
|
|
||||||
|
### Federation Password Self-Healing
|
||||||
|
|
||||||
|
When local bcrypt verification fails for a user with `homeInstance` set:
|
||||||
|
|
||||||
|
1. Extract base username (strip `@domain` if present)
|
||||||
|
2. POST to `https://{homeInstance}/api/auth/login` with base username and provided password
|
||||||
|
3. Timeout: 10 seconds (`AbortController`)
|
||||||
|
4. **If home instance accepts (200):**
|
||||||
|
- Re-hash password locally: `hashPassword(password)`
|
||||||
|
- Update local `passwordHash` -- but **do NOT set `passwordChangedAt`** (this is a state correction, not a password change; setting it would invalidate existing valid JWTs on this instance)
|
||||||
|
- Log the self-healing event
|
||||||
|
- Continue with login success
|
||||||
|
5. **If home instance rejects:** return "Invalid username or password"
|
||||||
|
6. **If home instance unreachable (network error/timeout):** return "Invalid username or password" (fall back to local-only rejection)
|
||||||
|
|
||||||
|
This flow ensures that when a federated user changes their password on their home instance, they can still log in on remote instances even if the remote's hash is stale.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Password Change
|
||||||
|
|
||||||
|
### User Password Change
|
||||||
|
|
||||||
|
**Endpoint:** `POST /api/users/@me/change-password`
|
||||||
|
**Rate limit:** 5 requests / 15 minutes
|
||||||
|
**Auth:** JWT (`authenticate` preHandler)
|
||||||
|
|
||||||
|
**Request body:** `{ currentPassword?: string, newPassword: string }`
|
||||||
|
|
||||||
|
| User type | `currentPassword` | Behavior |
|
||||||
|
|-----------|-------------------|----------|
|
||||||
|
| Local (`homeInstance` is null) | Required | Verified via bcrypt against stored hash |
|
||||||
|
| Federated (`homeInstance` set) | Not required | JWT auth is sufficient (home instance already verified the change) |
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. Validate `newPassword` is string, min 8 chars
|
||||||
|
2. Load user from DB
|
||||||
|
3. If local: require and verify `currentPassword`
|
||||||
|
4. Hash new password
|
||||||
|
5. Update `passwordHash` AND `passwordChangedAt = Date.now()` -- this invalidates all prior tokens
|
||||||
|
6. Sign fresh JWT, return `{ token }`
|
||||||
|
|
||||||
|
### Admin Password Reset
|
||||||
|
|
||||||
|
**Endpoint:** `POST /api/admin/users/:id/reset-password`
|
||||||
|
**Auth:** JWT + `requireAdmin` (instance admin only)
|
||||||
|
|
||||||
|
**Guards:**
|
||||||
|
- Target user must exist
|
||||||
|
- Target must not be deleted
|
||||||
|
- Target must not be federated (`homeInstance` must be null -- "Federated users authenticate via their home instance")
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. Generate temporary password: `crypto.randomBytes(12).toString('base64url')` (16 chars)
|
||||||
|
2. Hash it and update `passwordHash` + `passwordChangedAt = Date.now()`
|
||||||
|
3. `connectionManager.forceDisconnectUser(targetId)` -- closes all WS connections, forcing re-auth
|
||||||
|
4. Return `{ temporaryPassword }` -- admin must relay this to the user out-of-band
|
||||||
|
|
||||||
|
### Cross-Instance Password Propagation (Client-Side)
|
||||||
|
|
||||||
|
When a user changes their password on their home instance, `authStore.changePassword()`:
|
||||||
|
|
||||||
|
1. Changes password on home instance via API
|
||||||
|
2. Updates local token in localStorage and Zustand state
|
||||||
|
3. Calls `changePasswordOnRemotes(newPassword)` from `federationOps.ts`
|
||||||
|
|
||||||
|
`changePasswordOnRemotes()` flow:
|
||||||
|
|
||||||
|
1. Gets all connected remote instances from `instanceStore`
|
||||||
|
2. Cancels any existing retry timers for those origins
|
||||||
|
3. For each connected instance, calls `inst.api.users.changePassword({ newPassword })` with retry:
|
||||||
|
- **Initial retry:** `retryWithBackoff()` -- 3 attempts, exponential backoff starting at 2000ms (2s, 4s, 8s)
|
||||||
|
- On success: updates cached token for that instance, clears pending sync flag
|
||||||
|
4. If initial retries fail, starts `scheduleBackgroundRetry()`:
|
||||||
|
- Schedule: 10 attempts at 30s intervals (5 min), then 12 attempts at 5min intervals (60 min)
|
||||||
|
- Each attempt looks up current instance from store (avoids stale references)
|
||||||
|
- Stops if instance disconnected or removed
|
||||||
|
- On exhaustion: sets `pendingPasswordSync` flag on the instance (UI indicator)
|
||||||
|
|
||||||
|
**Timer management:**
|
||||||
|
- `activeRetryTimers` map tracks per-origin retry timers
|
||||||
|
- `clearPasswordSyncTimers()` cancels all active retries (called on logout)
|
||||||
|
- New password change cancels existing retry loops for affected origins
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Account Deletion
|
||||||
|
|
||||||
|
### Self-Deletion
|
||||||
|
|
||||||
|
**Endpoint:** `DELETE /api/users/@me`
|
||||||
|
**Rate limit:** 3 requests / 15 minutes
|
||||||
|
**Auth:** JWT (`authenticate` preHandler)
|
||||||
|
|
||||||
|
**Request body:** `{ password: string, username: string }`
|
||||||
|
|
||||||
|
**Pre-checks:**
|
||||||
|
1. `username` must match stored username (confirmation safeguard)
|
||||||
|
2. Local users must provide and verify `password`; federated users rely on JWT auth
|
||||||
|
3. Must not own any spaces (returns 400 with `ownedSpaces` list)
|
||||||
|
|
||||||
|
**Client-side flow** (`authStore.deleteAccount()`):
|
||||||
|
1. Call `deleteAccountOnRemotes()` first (best-effort, see below)
|
||||||
|
2. Call `api.users.deleteAccount()` on home instance
|
||||||
|
3. Clear localStorage token, reset all user-scoped stores
|
||||||
|
|
||||||
|
### Federation Account Deletion
|
||||||
|
|
||||||
|
`deleteAccountOnRemotes()` runs before home deletion:
|
||||||
|
- For each connected remote instance, calls `inst.api.users.deleteAccount({ password: '', username: inst.username })`
|
||||||
|
- Password is empty string (not needed for federated users on remotes)
|
||||||
|
- Best-effort: failures are caught and returned as `FederationOpResult[]` but do not block home deletion
|
||||||
|
|
||||||
|
### Tombstoning (`tombstoneUser()`)
|
||||||
|
|
||||||
|
All cleanup runs in a single SQLite transaction:
|
||||||
|
|
||||||
|
**Relationship cleanup (deletes):**
|
||||||
|
- `spaceMembers` -- removes from all spaces
|
||||||
|
- `memberRoles` -- removes all role assignments
|
||||||
|
- `friends` -- removes all friendships (both directions)
|
||||||
|
- `friendRequests` -- removes all friend requests (both directions)
|
||||||
|
- `dmMembers` -- removes from all DM channels
|
||||||
|
- `readStates` -- removes all read state records
|
||||||
|
- `reactions` -- removes all message reactions
|
||||||
|
- `dmReactions` -- removes all DM reactions
|
||||||
|
- `spaceFolders` -- removes all space folders
|
||||||
|
- `bans` -- removes bans where user is target (try/catch for table existence)
|
||||||
|
- `joinRequests` -- removes join requests (try/catch)
|
||||||
|
- `voiceRestrictions` -- removes voice restrictions (try/catch)
|
||||||
|
|
||||||
|
**Moderator reference cleanup (nullifies):**
|
||||||
|
- `bans.bannedBy` -- nullified where points to deleted user
|
||||||
|
- `voiceRestrictions.moderatorId` -- nullified
|
||||||
|
- `joinRequests.decidedBy` -- nullified
|
||||||
|
|
||||||
|
**Channel override cleanup:**
|
||||||
|
- Deletes `channelOverrides` where `targetType = 'member'` and `targetId = uid`
|
||||||
|
|
||||||
|
**Group DM ownership transfer:**
|
||||||
|
- For each group DM owned by the user, transfers to next remaining member
|
||||||
|
- If no remaining members, DM becomes orphaned
|
||||||
|
|
||||||
|
**Orphaned DM cleanup:**
|
||||||
|
- Finds DM channels with zero members after removal
|
||||||
|
- For each: collects attachment filenames, deletes attachments, reactions, messages, and the channel
|
||||||
|
|
||||||
|
**User row anonymization:**
|
||||||
|
```
|
||||||
|
username: '!deleted:{uid}' -- frees original username for reuse
|
||||||
|
passwordHash: crypto.randomBytes(32).toString('hex') -- random, unverifiable
|
||||||
|
displayName: null
|
||||||
|
avatar: null
|
||||||
|
banner: null
|
||||||
|
bio: null
|
||||||
|
customStatus: null
|
||||||
|
accentColor: null
|
||||||
|
avatarColor: null
|
||||||
|
replicatedInstances: '[]'
|
||||||
|
isDeleted: 1
|
||||||
|
status: 'offline'
|
||||||
|
isAdmin: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
**Return value:** Array of filenames to delete from disk (avatar, banner, orphaned DM attachments). Caller handles disk cleanup.
|
||||||
|
|
||||||
|
**Post-transaction (in route handler):**
|
||||||
|
- Delete files from disk via `deleteUploadFile()`
|
||||||
|
- `connectionManager.forceDisconnectUser()` -- closes all WS connections, leaves voice rooms, broadcasts presence
|
||||||
|
|
||||||
|
### `sanitizeUser()` for Deleted Users
|
||||||
|
|
||||||
|
When `isDeleted === 1`, returns an anonymized profile:
|
||||||
|
- `username: 'Deleted User'`
|
||||||
|
- All profile fields null/empty/false
|
||||||
|
- `status: 'offline'`
|
||||||
|
- Only `id` and `createdAt` preserved
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Client-Side Session Lifecycle
|
||||||
|
|
||||||
|
### State (`authStore`)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface AuthState {
|
||||||
|
token: string | null; // Persisted in localStorage as 'backspace_token'
|
||||||
|
user: User | null; // Current user object
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Initialization:** `token` is read from `localStorage.getItem('backspace_token')` on store creation.
|
||||||
|
|
||||||
|
### `initSession(token, user)`
|
||||||
|
|
||||||
|
Called after successful login or registration:
|
||||||
|
1. `resetUserStores()` -- clears all user-scoped stores (chat, space, social, voice, instance, activity) and `clearSelfIds()` from identity registry
|
||||||
|
2. Saves token to localStorage
|
||||||
|
3. Sets token + user in Zustand state
|
||||||
|
4. Fires `useInstanceStore.autoConnectAll()` (fire-and-forget) for federation
|
||||||
|
|
||||||
|
### `loadUser()`
|
||||||
|
|
||||||
|
Called by `useAuth()` hook when token exists but user object is null:
|
||||||
|
1. Calls `api.users.me()` to fetch current user
|
||||||
|
2. On success: sets user, triggers `autoConnectAll()`
|
||||||
|
3. On failure: removes token from localStorage, clears state (forces redirect to login)
|
||||||
|
|
||||||
|
### `logout()`
|
||||||
|
|
||||||
|
1. Removes token from localStorage
|
||||||
|
2. Calls `resetUserStores()` (clears all stores + self IDs)
|
||||||
|
3. Sets token and user to null
|
||||||
|
|
||||||
|
### Route Guards
|
||||||
|
|
||||||
|
**`ProtectedRoute`** (in `App.tsx`):
|
||||||
|
- Reads `token` from authStore
|
||||||
|
- If no token: `<Navigate to="/login" replace />`
|
||||||
|
- Used for `/channels/:spaceId/:channelId?` and `/explore`
|
||||||
|
|
||||||
|
**`AuthRedirect`** (in `App.tsx`):
|
||||||
|
- Reads `token` from authStore
|
||||||
|
- If token present: redirects to `?redirect` param or `/channels/@me`
|
||||||
|
- Used for `/login` and `/register` routes
|
||||||
|
- Prevents authenticated users from seeing auth pages
|
||||||
|
|
||||||
|
**`useAuth()` hook:**
|
||||||
|
- Watches `token`, `user`, `isLoading`
|
||||||
|
- If no token: navigates to `/login`
|
||||||
|
- If token but no user and not loading: calls `loadUser()`
|
||||||
|
- Returns `{ user, isLoading, isAuthenticated }`
|
||||||
|
|
||||||
|
### Login Page
|
||||||
|
|
||||||
|
- Fields: username, password
|
||||||
|
- Redirect support: reads `?redirect` param, navigates there on success (validated: must start with `/`, not `//`)
|
||||||
|
- Rate limit handling: catches `RateLimitError`, shows countdown timer
|
||||||
|
- Links to register page (preserves redirect param)
|
||||||
|
|
||||||
|
### Registration Page (Two-Step)
|
||||||
|
|
||||||
|
**Step 1 -- Credentials:**
|
||||||
|
- Fields: username, password, confirm password
|
||||||
|
- Client-side validation: 3-32 chars, `/^[a-z0-9_]+$/`, passwords match, min 6 chars
|
||||||
|
- Debounced username availability check (500ms delay, abort on new input)
|
||||||
|
- Continue button disabled if username taken or invalid
|
||||||
|
|
||||||
|
**Step 2 -- Personalization:**
|
||||||
|
- Fields: display name (optional), avatar color picker, avatar upload (with crop modal)
|
||||||
|
- "Get Started" button: registers with personalization
|
||||||
|
- "Skip for now" button: registers without personalization
|
||||||
|
- Registration flow:
|
||||||
|
1. Call `api.auth.register()` -- saves token to localStorage but NOT to Zustand (prevents premature `AuthRedirect`)
|
||||||
|
2. If avatar file selected: upload file, then `api.users.update({ avatar })` (failure is non-fatal)
|
||||||
|
3. `initSession(token, finalUser)` -- activates Zustand state, triggers redirect
|
||||||
|
4. Navigate to redirect param or `/channels/@me`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Federation-Aware Identity Utilities
|
||||||
|
|
||||||
|
### `parseFederatedUsername(username)`
|
||||||
|
|
||||||
|
Splits a potentially federated username:
|
||||||
|
```
|
||||||
|
"youruser@nova.ddns.net" -> { baseName: "youruser", domain: "nova.ddns.net" }
|
||||||
|
"youruser" -> { baseName: "youruser", domain: null }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Self-ID Registry
|
||||||
|
|
||||||
|
Module-level `Set<string>` tracking all Snowflake IDs belonging to the current user across connected instances:
|
||||||
|
|
||||||
|
```
|
||||||
|
registerSelfId(id: string) -- adds ID (called from WS ready events)
|
||||||
|
clearSelfIds() -- clears all (called on logout/session reset)
|
||||||
|
```
|
||||||
|
|
||||||
|
### `isSelf(user, homeUser)`
|
||||||
|
|
||||||
|
Determines if a user object represents the current user. Cascading checks:
|
||||||
|
1. Same `id` (same instance, trivial)
|
||||||
|
2. `_knownSelfIds.has(user.id)` (cross-instance via registry)
|
||||||
|
3. `user.homeInstance === window.location.host` AND base usernames match
|
||||||
|
|
||||||
|
### `canonicalUserMatch(a, b)`
|
||||||
|
|
||||||
|
Federation-safe comparison of two user-like objects. Cascading strategies:
|
||||||
|
1. Same `id` -- trivial match
|
||||||
|
2. `homeUserId` cross-matching (both have it, or one matches the other's `id`)
|
||||||
|
3. Username + home instance fallback: parse base names, derive home from `homeInstance` or domain part of username, compare
|
||||||
|
|
||||||
|
### `resolveDisplayIdentity(user, homeUser)`
|
||||||
|
|
||||||
|
If `user` is a replicated alias of `homeUser` (via `isSelf`), returns `homeUser` for display purposes. Otherwise returns `user` unchanged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Rate Limits Summary
|
||||||
|
|
||||||
|
| Endpoint | Max | Window |
|
||||||
|
|----------|-----|--------|
|
||||||
|
| `POST /api/auth/register` | 10 | 2 min |
|
||||||
|
| `GET /api/auth/check-username` | 30 | 1 min |
|
||||||
|
| `POST /api/auth/login` | 15 | 2 min |
|
||||||
|
| `POST /api/users/@me/change-password` | 5 | 15 min |
|
||||||
|
| `DELETE /api/users/@me` | 3 | 15 min |
|
||||||
|
|
||||||
|
All keyed by `request.ip`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Configuration Reference
|
||||||
|
|
||||||
|
| Config key | Env var | Default | Notes |
|
||||||
|
|------------|---------|---------|-------|
|
||||||
|
| `jwtSecret` | `JWT_SECRET` | (required) | Min 32 chars, startup crash if shorter |
|
||||||
|
| `jwtExpiresIn` | `JWT_EXPIRES_IN` | `'30d'` | Passed to `jsonwebtoken` `expiresIn` option |
|
||||||
|
| `registrationOpen` | `REGISTRATION_OPEN` | `true` | Overridden by `instanceSettings.registrationOpen` in DB |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. `requireAdmin` Guard
|
||||||
|
|
||||||
|
`auth.ts:requireAdmin()` -- used as preHandler alongside `authenticate`:
|
||||||
|
1. Loads full user from DB by `request.userId`
|
||||||
|
2. Rejects with 403 if user not found or `isAdmin !== 1`
|
||||||
|
3. Used by admin routes (user management, password reset, federation peer management)
|
||||||
@@ -0,0 +1,411 @@
|
|||||||
|
# Database Schema Reference
|
||||||
|
|
||||||
|
Source of truth: `packages/server/src/db/schema.ts` (Drizzle ORM)
|
||||||
|
Migrations: `packages/server/src/db/migrate.ts` (runs on startup via `runMigrations()`)
|
||||||
|
Engine: SQLite via `better-sqlite3`
|
||||||
|
IDs: Snowflake text, permissions: bigint decimal strings
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core Tables
|
||||||
|
|
||||||
|
### users
|
||||||
|
| Column | Type | Default | Notes |
|
||||||
|
|--------|------|---------|-------|
|
||||||
|
| id | text PK | | Snowflake |
|
||||||
|
| username | text UNIQUE NOT NULL | | Login name |
|
||||||
|
| displayName | text | | |
|
||||||
|
| passwordHash | text NOT NULL | | bcrypt; `'!federation-replicated'` for stubs |
|
||||||
|
| avatar | text | | Upload filename |
|
||||||
|
| status | text | `'offline'` | online/idle/dnd/offline |
|
||||||
|
| customStatus | text | | |
|
||||||
|
| isAdmin | integer | 0 | First registered user = 1 |
|
||||||
|
| homeInstance | text | | Federation origin URL (null = local) |
|
||||||
|
| homeUserId | text | | Canonical ID on home instance |
|
||||||
|
| replicatedInstances | text | `'[]'` | JSON array of instance URLs |
|
||||||
|
| banner | text | | Upload filename |
|
||||||
|
| accentColor | text | | Hex color |
|
||||||
|
| avatarColor | text | | Hex color |
|
||||||
|
| bio | text | | |
|
||||||
|
| isDeleted | integer | 0 | Soft-delete flag |
|
||||||
|
| discoverable | integer | 1 | Visible in user directory |
|
||||||
|
| profileUpdatedAt | integer | | Epoch ms |
|
||||||
|
| passwordChangedAt | integer | | Token revocation: tokens before this rejected |
|
||||||
|
| showActivity | integer NOT NULL | 1 | Rich presence visibility |
|
||||||
|
| createdAt | integer NOT NULL | | Epoch ms |
|
||||||
|
|
||||||
|
### spaces
|
||||||
|
| Column | Type | Default | Notes |
|
||||||
|
|--------|------|---------|-------|
|
||||||
|
| id | text PK | | |
|
||||||
|
| name | text NOT NULL | | |
|
||||||
|
| icon | text | | Upload filename |
|
||||||
|
| banner | text | | Upload filename |
|
||||||
|
| avatarColor | text | | Hex color |
|
||||||
|
| ownerId | text NOT NULL | | FK → users.id |
|
||||||
|
| inviteCode | text UNIQUE | | |
|
||||||
|
| visibility | text | `'private'` | public/request/private |
|
||||||
|
| description | text | | |
|
||||||
|
| createdAt | integer NOT NULL | | |
|
||||||
|
|
||||||
|
### space_members
|
||||||
|
PK: (spaceId, userId)
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| spaceId | text NOT NULL | FK → spaces.id CASCADE |
|
||||||
|
| userId | text NOT NULL | FK → users.id CASCADE |
|
||||||
|
| nickname | text | Per-space display name |
|
||||||
|
| joinedAt | integer NOT NULL | |
|
||||||
|
|
||||||
|
### channel_categories
|
||||||
|
| Column | Type | Default | Notes |
|
||||||
|
|--------|------|---------|-------|
|
||||||
|
| id | text PK | | |
|
||||||
|
| spaceId | text NOT NULL | | FK → spaces.id CASCADE |
|
||||||
|
| name | text NOT NULL | | |
|
||||||
|
| position | integer | 0 | |
|
||||||
|
| createdAt | integer NOT NULL | | |
|
||||||
|
|
||||||
|
### channels
|
||||||
|
| Column | Type | Default | Notes |
|
||||||
|
|--------|------|---------|-------|
|
||||||
|
| id | text PK | | |
|
||||||
|
| spaceId | text NOT NULL | | FK → spaces.id CASCADE |
|
||||||
|
| name | text NOT NULL | | |
|
||||||
|
| type | text NOT NULL | | text/voice |
|
||||||
|
| topic | text | | |
|
||||||
|
| position | integer | 0 | |
|
||||||
|
| categoryId | text | | Soft FK → channel_categories |
|
||||||
|
| createdAt | integer NOT NULL | | |
|
||||||
|
|
||||||
|
### messages
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| id | text PK | |
|
||||||
|
| channelId | text NOT NULL | FK → channels.id CASCADE |
|
||||||
|
| userId | text NOT NULL | FK → users.id |
|
||||||
|
| replyToId | text | FK → messages.id SET NULL |
|
||||||
|
| content | text | |
|
||||||
|
| editedAt | integer | |
|
||||||
|
| createdAt | integer NOT NULL | |
|
||||||
|
|
||||||
|
### attachments
|
||||||
|
| Column | Type | Default | Notes |
|
||||||
|
|--------|------|---------|-------|
|
||||||
|
| id | text PK | | |
|
||||||
|
| messageId | text | | FK → messages.id CASCADE |
|
||||||
|
| dmMessageId | text | | FK → dm_messages.id CASCADE |
|
||||||
|
| uploaderId | text | | User who uploaded |
|
||||||
|
| filename | text NOT NULL | | Stored filename |
|
||||||
|
| originalName | text NOT NULL | | User-facing name |
|
||||||
|
| mimetype | text NOT NULL | | |
|
||||||
|
| size | integer NOT NULL | | Bytes |
|
||||||
|
| thumbnailFilename | text | | Generated thumbnail |
|
||||||
|
| width | integer | | Image/video pixel width |
|
||||||
|
| height | integer | | Image/video pixel height |
|
||||||
|
| duration | real | | Audio/video seconds |
|
||||||
|
| sourceUrl | text | | Remote URL (federation) |
|
||||||
|
| federationStatus | text | | local/remote/remote_partial |
|
||||||
|
| federationMeta | text | | JSON rejection info |
|
||||||
|
| createdAt | integer NOT NULL | | |
|
||||||
|
CHECK: exactly one of messageId/dmMessageId is set
|
||||||
|
|
||||||
|
### embeds
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| id | text PK | |
|
||||||
|
| messageId | text | FK → messages.id CASCADE |
|
||||||
|
| dmMessageId | text | FK → dm_messages.id CASCADE |
|
||||||
|
| url | text NOT NULL | |
|
||||||
|
| embedType | text NOT NULL | generic/video/image/audio/rich |
|
||||||
|
| provider | text | youtube/vimeo/spotify/null |
|
||||||
|
| title | text | |
|
||||||
|
| description | text | |
|
||||||
|
| image | text | Thumbnail/og:image URL |
|
||||||
|
| embedUrl | text | iframe-safe URL |
|
||||||
|
| width | integer | |
|
||||||
|
| height | integer | |
|
||||||
|
| color | text | |
|
||||||
|
| createdAt | integer NOT NULL | |
|
||||||
|
CHECK: exactly one of messageId/dmMessageId is set
|
||||||
|
|
||||||
|
### reactions
|
||||||
|
PK: id
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| id | text PK | |
|
||||||
|
| messageId | text NOT NULL | FK → messages.id CASCADE |
|
||||||
|
| userId | text NOT NULL | FK → users.id CASCADE |
|
||||||
|
| emoji | text NOT NULL | |
|
||||||
|
| createdAt | integer NOT NULL | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DM Tables
|
||||||
|
|
||||||
|
### dm_channels
|
||||||
|
| Column | Type | Default | Notes |
|
||||||
|
|--------|------|---------|-------|
|
||||||
|
| id | text PK | | |
|
||||||
|
| ownerId | text | | NULL for 1-on-1, set for group |
|
||||||
|
| federatedId | text | | Cross-instance identifier |
|
||||||
|
| ownerHomeUserId | text | | Owner's canonical home ID |
|
||||||
|
| ownerHomeInstance | text | | Owner's home instance URL |
|
||||||
|
| deletedAt | integer | | Soft-delete (GC after 24h if no local members) |
|
||||||
|
| createdAt | integer NOT NULL | | |
|
||||||
|
|
||||||
|
### dm_members
|
||||||
|
PK: (dmChannelId, userId)
|
||||||
|
| Column | Type | Default | Notes |
|
||||||
|
|--------|------|---------|-------|
|
||||||
|
| dmChannelId | text NOT NULL | | FK → dm_channels.id CASCADE |
|
||||||
|
| userId | text NOT NULL | | FK → users.id CASCADE |
|
||||||
|
| closed | integer | 0 | Soft-close flag |
|
||||||
|
|
||||||
|
### dm_messages
|
||||||
|
| Column | Type | Default | Notes |
|
||||||
|
|--------|------|---------|-------|
|
||||||
|
| id | text PK | | |
|
||||||
|
| dmChannelId | text NOT NULL | | FK → dm_channels.id CASCADE |
|
||||||
|
| userId | text NOT NULL | | FK → users.id |
|
||||||
|
| replyToId | text | | FK → dm_messages.id SET NULL |
|
||||||
|
| content | text | | |
|
||||||
|
| type | text NOT NULL | `'user'` | user/system |
|
||||||
|
| editedAt | integer | | |
|
||||||
|
| sourceInstance | text | | Federation source origin |
|
||||||
|
| sourceMessageId | text | | Original ID on source instance |
|
||||||
|
| encryptionVersion | integer | 0 | |
|
||||||
|
| createdAt | integer NOT NULL | | |
|
||||||
|
|
||||||
|
### dm_reactions
|
||||||
|
PK: id
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| id | text PK | |
|
||||||
|
| dmMessageId | text NOT NULL | FK → dm_messages.id CASCADE |
|
||||||
|
| userId | text NOT NULL | FK → users.id CASCADE |
|
||||||
|
| emoji | text NOT NULL | |
|
||||||
|
| createdAt | integer NOT NULL | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Social Tables
|
||||||
|
|
||||||
|
### friends
|
||||||
|
PK: (userId, friendId)
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| userId | text NOT NULL | FK → users.id CASCADE |
|
||||||
|
| friendId | text NOT NULL | FK → users.id CASCADE |
|
||||||
|
| createdAt | integer NOT NULL | |
|
||||||
|
|
||||||
|
### friend_requests
|
||||||
|
| Column | Type | Default | Notes |
|
||||||
|
|--------|------|---------|-------|
|
||||||
|
| id | text PK | | |
|
||||||
|
| fromId | text NOT NULL | | FK → users.id CASCADE |
|
||||||
|
| toId | text NOT NULL | | FK → users.id CASCADE |
|
||||||
|
| status | text | `'pending'` | pending/accepted/declined |
|
||||||
|
| createdAt | integer NOT NULL | | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## RBAC Tables
|
||||||
|
|
||||||
|
### roles
|
||||||
|
| Column | Type | Default | Notes |
|
||||||
|
|--------|------|---------|-------|
|
||||||
|
| id | text PK | | |
|
||||||
|
| spaceId | text NOT NULL | | FK → spaces.id CASCADE |
|
||||||
|
| name | text NOT NULL | | |
|
||||||
|
| color | text | `'#b9bbbe'` | Hex |
|
||||||
|
| position | integer | 0 | Hierarchy position |
|
||||||
|
| permissions | text | | Bigint decimal string |
|
||||||
|
| createdAt | integer NOT NULL | | |
|
||||||
|
|
||||||
|
### member_roles
|
||||||
|
PK: (spaceId, userId, roleId)
|
||||||
|
All columns FK CASCADE to their respective tables.
|
||||||
|
|
||||||
|
### channel_overrides
|
||||||
|
PK: (channelId, targetType, targetId)
|
||||||
|
| Column | Type | Default | Notes |
|
||||||
|
|--------|------|---------|-------|
|
||||||
|
| channelId | text NOT NULL | | FK → channels.id CASCADE |
|
||||||
|
| targetType | text NOT NULL | | role/member |
|
||||||
|
| targetId | text NOT NULL | | Role ID or user ID |
|
||||||
|
| allow | text NOT NULL | `'0'` | Bigint decimal string |
|
||||||
|
| deny | text NOT NULL | `'0'` | Bigint decimal string |
|
||||||
|
|
||||||
|
### category_overrides
|
||||||
|
PK: (categoryId, targetType, targetId)
|
||||||
|
Same structure as channel_overrides, with categoryId FK → channel_categories.id CASCADE.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## State Tables
|
||||||
|
|
||||||
|
### read_states
|
||||||
|
PK: (userId, channelId)
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| userId | text NOT NULL | FK → users.id CASCADE |
|
||||||
|
| channelId | text NOT NULL | Channel or DM channel ID |
|
||||||
|
| lastReadMessageId | text NOT NULL | |
|
||||||
|
| updatedAt | integer NOT NULL | |
|
||||||
|
|
||||||
|
### space_folders
|
||||||
|
| Column | Type | Default | Notes |
|
||||||
|
|--------|------|---------|-------|
|
||||||
|
| id | text PK | | |
|
||||||
|
| userId | text NOT NULL | | FK → users.id CASCADE |
|
||||||
|
| name | text | | |
|
||||||
|
| color | text | | |
|
||||||
|
| position | integer | 0 | |
|
||||||
|
| createdAt | integer NOT NULL | | |
|
||||||
|
|
||||||
|
### space_folder_members
|
||||||
|
PK: (folderId, spaceId)
|
||||||
|
| Column | Type | Default | Notes |
|
||||||
|
|--------|------|---------|-------|
|
||||||
|
| folderId | text NOT NULL | | FK → space_folders.id CASCADE |
|
||||||
|
| spaceId | text NOT NULL | | May be federated (no local FK) |
|
||||||
|
| position | integer | 0 | |
|
||||||
|
|
||||||
|
### user_space_layout
|
||||||
|
PK: userId
|
||||||
|
| Column | Type | Default | Notes |
|
||||||
|
|--------|------|---------|-------|
|
||||||
|
| userId | text PK | | FK → users.id CASCADE |
|
||||||
|
| layout | text NOT NULL | `'[]'` | JSON array of {t:'s',id} | {t:'f',id} |
|
||||||
|
| updatedAt | integer NOT NULL | | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Moderation Tables
|
||||||
|
|
||||||
|
### bans
|
||||||
|
PK: (spaceId, userId)
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| spaceId | text NOT NULL | FK → spaces.id CASCADE |
|
||||||
|
| userId | text NOT NULL | FK → users.id CASCADE |
|
||||||
|
| reason | text | |
|
||||||
|
| bannedBy | text | FK → users.id |
|
||||||
|
| createdAt | integer NOT NULL | |
|
||||||
|
|
||||||
|
### join_requests
|
||||||
|
| Column | Type | Default | Notes |
|
||||||
|
|--------|------|---------|-------|
|
||||||
|
| id | text PK | | |
|
||||||
|
| spaceId | text NOT NULL | | FK → spaces.id CASCADE |
|
||||||
|
| userId | text NOT NULL | | FK → users.id CASCADE |
|
||||||
|
| message | text | | |
|
||||||
|
| status | text NOT NULL | `'pending'` | pending/accepted/declined |
|
||||||
|
| decidedBy | text | | FK → users.id |
|
||||||
|
| createdAt | integer NOT NULL | | |
|
||||||
|
| decidedAt | integer | | |
|
||||||
|
|
||||||
|
### voice_restrictions
|
||||||
|
PK: (spaceId, userId, restrictionType)
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| spaceId | text NOT NULL | FK → spaces.id CASCADE |
|
||||||
|
| userId | text NOT NULL | FK → users.id CASCADE |
|
||||||
|
| restrictionType | text NOT NULL | mute/deafen |
|
||||||
|
| moderatorId | text | FK → users.id |
|
||||||
|
| createdAt | integer NOT NULL | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Instance Settings (singleton, id=1)
|
||||||
|
|
||||||
|
| Column | Type | Default | Notes |
|
||||||
|
|--------|------|---------|-------|
|
||||||
|
| id | integer PK | 1 | |
|
||||||
|
| instanceName | text | `'Backspace'` | |
|
||||||
|
| workerId | integer | | Snowflake worker ID |
|
||||||
|
| discoveryEnabled | integer NOT NULL | 1 | |
|
||||||
|
| maxBitrateKbps | integer NOT NULL | 20000 | |
|
||||||
|
| minBitrateKbps | integer NOT NULL | 500 | |
|
||||||
|
| bitrateStepKbps | integer NOT NULL | 500 | |
|
||||||
|
| allowedResolutions | text NOT NULL | `'540,720,1080'` | CSV |
|
||||||
|
| allowedFramerates | text NOT NULL | `'30,45,60'` | CSV |
|
||||||
|
| maxResolution | integer NOT NULL | 1080 | |
|
||||||
|
| maxFramerate | integer NOT NULL | 60 | |
|
||||||
|
| registrationOpen | integer | | null = use env |
|
||||||
|
| gifApiKey | text | | Klipy API key |
|
||||||
|
| bitrateMatrixOverrides | text | | JSON sparse overrides |
|
||||||
|
| allowCustomBitrate | integer NOT NULL | 1 | |
|
||||||
|
| maxUploadSizeBytes | integer | | null = use env |
|
||||||
|
| federationRelayEnabled | integer NOT NULL | 1 | |
|
||||||
|
| federationRelayTtlDays | integer NOT NULL | 30 | |
|
||||||
|
| updatedAt | integer NOT NULL | | |
|
||||||
|
|
||||||
|
Migration flags (internal): `voice_bit_migrated`, `profile_attachments_cleaned`, `thumbnails_backfilled`, `media_dimensions_backfilled`, `legacy_dm_sync_done`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Federation Tables
|
||||||
|
|
||||||
|
### federation_peers
|
||||||
|
| Column | Type | Default | Notes |
|
||||||
|
|--------|------|---------|-------|
|
||||||
|
| id | text PK | | |
|
||||||
|
| origin | text NOT NULL UNIQUE | | `https://domain.tld` |
|
||||||
|
| instanceName | text | | |
|
||||||
|
| hmacSecret | text NOT NULL | | 256-bit hex |
|
||||||
|
| status | text NOT NULL | `'active'` | active/pending/unreachable/revoked |
|
||||||
|
| lastSeenAt | integer | | |
|
||||||
|
| lastFailureAt | integer | | |
|
||||||
|
| consecutiveFailures | integer | 0 | >=10 → unreachable |
|
||||||
|
| lastSyncedAt | integer | 0 | |
|
||||||
|
| remoteMaxUploadSize | integer | | Bytes, from peer |
|
||||||
|
| createdAt | integer NOT NULL | | |
|
||||||
|
|
||||||
|
### federation_outbox
|
||||||
|
UNIQUE: (peerId, entityId)
|
||||||
|
| Column | Type | Default | Notes |
|
||||||
|
|--------|------|---------|-------|
|
||||||
|
| id | text PK | | |
|
||||||
|
| peerId | text NOT NULL | | FK → federation_peers.id CASCADE |
|
||||||
|
| contextId | text NOT NULL | | DM channel / friend context |
|
||||||
|
| entityId | text NOT NULL | | Message / reaction / request ID |
|
||||||
|
| contextType | text NOT NULL | `'dm'` | dm/friend |
|
||||||
|
| eventType | text NOT NULL | | create/update/delete/reaction_add/etc |
|
||||||
|
| payload | text NOT NULL | | JSON event data |
|
||||||
|
| encryptionVersion | integer | 0 | |
|
||||||
|
| attempts | integer | 0 | |
|
||||||
|
| nextRetryAt | integer NOT NULL | | |
|
||||||
|
| expiresAt | integer NOT NULL | | TTL-based |
|
||||||
|
| createdAt | integer NOT NULL | | |
|
||||||
|
|
||||||
|
### federation_file_queue
|
||||||
|
| Column | Type | Default | Notes |
|
||||||
|
|--------|------|---------|-------|
|
||||||
|
| id | text PK | | |
|
||||||
|
| peerOrigin | text NOT NULL | | |
|
||||||
|
| dmMessageId | text NOT NULL | | |
|
||||||
|
| sourceUrl | text NOT NULL | | Remote download URL |
|
||||||
|
| targetFilename | text | | Local stored filename |
|
||||||
|
| originalName | text NOT NULL | | |
|
||||||
|
| mimetype | text NOT NULL | | |
|
||||||
|
| size | integer NOT NULL | | |
|
||||||
|
| status | text NOT NULL | `'pending'` | pending/completed/rejected/failed |
|
||||||
|
| rejectionReason | text | | |
|
||||||
|
| attempts | integer | 0 | Max 10 |
|
||||||
|
| nextRetryAt | integer NOT NULL | | |
|
||||||
|
| expiresAt | integer NOT NULL | | |
|
||||||
|
| createdAt | integer NOT NULL | | |
|
||||||
|
|
||||||
|
### federation_mutation_log
|
||||||
|
| Column | Type | Default | Notes |
|
||||||
|
|--------|------|---------|-------|
|
||||||
|
| id | text PK | | |
|
||||||
|
| entityId | text NOT NULL | | |
|
||||||
|
| contextId | text NOT NULL | | |
|
||||||
|
| contextType | text NOT NULL | `'dm'` | dm/friend |
|
||||||
|
| mutationType | text NOT NULL | | create/update/delete |
|
||||||
|
| mutatedAt | integer NOT NULL | | Checkpoint for sync |
|
||||||
|
| payload | text | | JSON |
|
||||||
|
Retention: 90 days (cleaned by federation janitor)
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
# Design System — "Aether Drift"
|
||||||
|
|
||||||
|
Prototype (source of truth): `Backspace-design-prototype.html` (open in browser)
|
||||||
|
Styles: `packages/web/src/styles/globals.css`
|
||||||
|
Theme: `packages/web/tailwind.config.js`
|
||||||
|
Font: DM Sans (primary) with system fallbacks
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Principles
|
||||||
|
|
||||||
|
- Calm over flashy. Warm over cool.
|
||||||
|
- Quiet glass (felt, not seen). No decorative gradients. Minimal shadows.
|
||||||
|
- Two-material system: solid matte panels for content (75%), frosted glass bubbles for persistent controls (25%)
|
||||||
|
- `prefers-reduced-transparency` → fall back to solid surfaces
|
||||||
|
- NOT a Discord clone — Backspace has its own visual identity
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Color Palette
|
||||||
|
|
||||||
|
### Matte Surfaces (CSS vars, RGB channels)
|
||||||
|
| Var | Role |
|
||||||
|
|-----|------|
|
||||||
|
| `--bg-base` | App background |
|
||||||
|
| `--bg-channel` | Channel sidebar (#1a1a23) |
|
||||||
|
| `--bg-chat` | Chat area (#13131a) |
|
||||||
|
| `--bg-members` | Member list |
|
||||||
|
| `--bg-elevated` | Static structural panels only |
|
||||||
|
| `--bg-input` | Input backgrounds (sunken) |
|
||||||
|
| `--bg-overlay` | Overlay backgrounds |
|
||||||
|
|
||||||
|
### Pastel Accents
|
||||||
|
`--accent-mint`, `--accent-peach`, `--accent-lavender`, `--accent-sky`, `--accent-amber`, `--accent-rose`, `--accent-coral`
|
||||||
|
|
||||||
|
### Primary Action
|
||||||
|
`--accent-primary`, `--accent-primary-hover`, `--accent-primary-active`
|
||||||
|
|
||||||
|
### Text Hierarchy
|
||||||
|
`--text-primary`, `--text-secondary`, `--text-tertiary`, `--text-category`, `--text-message`, `--text-link`, `--text-positive`, `--text-warning`, `--text-danger`
|
||||||
|
|
||||||
|
### Interactive States
|
||||||
|
`--interactive-hover`, `--interactive-active`, `--interactive-selected`, `--interactive-muted`
|
||||||
|
|
||||||
|
### Status
|
||||||
|
`--status-online`, `--status-idle`, `--status-dnd`, `--status-offline`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Surface Material Tiers
|
||||||
|
|
||||||
|
| Tier | Class | When to Use |
|
||||||
|
|------|-------|-------------|
|
||||||
|
| Structural | `bg-surface-*` | Permanent layout (sidebars, chat, member list) |
|
||||||
|
| Strip | `.glass-strip` | Persistent edge chrome (space sidebar) |
|
||||||
|
| Bubble | `.glass-bubble` | Persistent floating controls (voice bar, input pill) |
|
||||||
|
| Popover | `.glass` | Small floating surfaces (context menus, popovers, tooltips) |
|
||||||
|
| Modal | `.glass-modal` | Large center-screen dialogs |
|
||||||
|
| Pill | `.glass-pill` | Inline decorations (reactions, tags) |
|
||||||
|
| Pill (own) | `.glass-pill-mine` | User's own reaction (mint-tinted) |
|
||||||
|
|
||||||
|
**Rule:** If it floats above the content plane, it's glass. Never use `bg-surface-elevated` for floating/overlay elements.
|
||||||
|
|
||||||
|
**Modal backdrops:** `bg-black/50` — light enough for glass blur to show through.
|
||||||
|
|
||||||
|
### Glass Material Properties
|
||||||
|
```css
|
||||||
|
.glass {
|
||||||
|
backdrop-filter: blur(20px) saturate(120%);
|
||||||
|
background: rgba(20, 20, 26, 0.52); /* --glass-bg */
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.07); /* --glass-border */
|
||||||
|
}
|
||||||
|
.glass-modal {
|
||||||
|
/* Higher opacity: 82%, stronger shadow */
|
||||||
|
}
|
||||||
|
.glass-pill {
|
||||||
|
backdrop-filter: blur(12px) saturate(110%);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Input Tiers
|
||||||
|
|
||||||
|
All defined in `globals.css`. No resting border — sunken `surface-input` background provides differentiation.
|
||||||
|
|
||||||
|
| Tier | Class | When to Use | Focus |
|
||||||
|
|------|-------|-------------|-------|
|
||||||
|
| Standard | `.input-standard` | Form fields in modals, settings, auth | `ring-2` primary |
|
||||||
|
| Search | `.input-search` | Search bars, filter inputs | `ring-1` primary |
|
||||||
|
| Embedded | `.input-embedded` | Inside glass (chat input, search popover) | none |
|
||||||
|
| Danger | `.input-danger` | Destructive confirmations | `ring-2` rose |
|
||||||
|
|
||||||
|
Override padding/size with utilities: `input-standard w-full py-2.5`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
3-column grid: 312px channel sidebar | main content | 240px members sidebar
|
||||||
|
Glass server strip overlays left 72px of channel sidebar.
|
||||||
|
Channel sidebar fully opaque with gradient at left edge feeding glass.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Shadows
|
||||||
|
|
||||||
|
| Name | Use |
|
||||||
|
|------|-----|
|
||||||
|
| `header` | Top bars |
|
||||||
|
| `elevation-low` | Subtle lift |
|
||||||
|
| `elevation-high` | Dropdowns, popovers |
|
||||||
|
| `glass` | Glass surfaces |
|
||||||
|
| `input` | Input fields |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Animations
|
||||||
|
|
||||||
|
### Core
|
||||||
|
`fadeIn`, `slideUp`, `slideDown`, `typingFadeIn`, `gradientPulse`, `shimmer` (skeleton loading)
|
||||||
|
|
||||||
|
### Search
|
||||||
|
`search-flash`, `stepForward`, `stepBack`
|
||||||
|
|
||||||
|
### Call
|
||||||
|
`callRippleLiquid`, `callGlowSoft`, `callRefraction`, `callButtonBreath`
|
||||||
|
|
||||||
|
### Mobile
|
||||||
|
`mobile-screen-enter`, `mobile-screen-enter-active`, `mobile-screen-exit-active`, `slide-up-sheet`
|
||||||
|
|
||||||
|
### Skeleton Loading
|
||||||
|
`.skeleton`, `.skeleton-circle`, `.skeleton-bar`, `.skeleton-block`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Utility Classes
|
||||||
|
|
||||||
|
- `.no-scrollbar` — Hides scrollbars
|
||||||
|
- `.scrollbar-thin` — 4px thin scrollbars
|
||||||
|
- `.rounded-inherit` — Inherits border radius
|
||||||
|
- `.titlebar-drag` / `.titlebar-no-drag` — Electron window drag
|
||||||
|
- `.call-refraction` — Light shimmer overlay for call UI
|
||||||
@@ -0,0 +1,775 @@
|
|||||||
|
# Desktop & Electron System
|
||||||
|
|
||||||
|
Source files:
|
||||||
|
- `packages/desktop/src/main.ts` — Main process: window management, tray, IPC handlers, auto-update, deep links, app lifecycle
|
||||||
|
- `packages/desktop/src/preload.ts` — Context bridge: exposes `window.backspace` API to renderer
|
||||||
|
- `packages/desktop/src/activityDetector.ts` — Process polling, game dictionary loading/sync, activity change detection
|
||||||
|
- `packages/desktop/src/keybindManager.ts` — Global keybinds via uIOhook, native keycode mapping, press/release tracking
|
||||||
|
- `packages/web/src/stores/keybindStore.ts` — Client-side keybind persistence (Zustand + localStorage)
|
||||||
|
- `packages/web/src/hooks/useKeybinds.ts` — Keybind dispatch: Electron IPC bridge + web capture-phase fallback
|
||||||
|
- `packages/web/src/platform/electron.d.ts` — TypeScript declarations for `window.backspace`
|
||||||
|
- `packages/web/src/platform/platform.ts` — `isElectron()` / `isElectronMac()` / `getElectronAPI()` helpers
|
||||||
|
- `packages/desktop/electron-builder.yml` — Build config, protocol registration, afterPack hook
|
||||||
|
- `packages/desktop/scripts/afterPack.js` — Cross-platform native module cleanup (critical for builds)
|
||||||
|
- `packages/desktop/resources/games.json` — Bundled game dictionary seed (versioned)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
The desktop app wraps the Backspace web client in Electron with:
|
||||||
|
- **Main process** (`main.ts`): Window lifecycle, tray icon, IPC handler registry, auto-update, deep linking, activity detection, keybind manager
|
||||||
|
- **Preload bridge** (`preload.ts`): Exposes `window.backspace` API via `contextBridge` with full sandbox isolation (`contextIsolation: true`, `nodeIntegration: false`, `sandbox: true`)
|
||||||
|
- **Renderer**: The standard web client, detecting Electron via `typeof window.backspace !== 'undefined'`
|
||||||
|
|
||||||
|
The desktop package compiles to CommonJS (`module: "commonjs"`) targeting ES2022. Electron version: 40+.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Window Management
|
||||||
|
|
||||||
|
### Creation (`main.ts:createWindow()`)
|
||||||
|
|
||||||
|
```
|
||||||
|
Default size: 1280 x 800
|
||||||
|
Minimum size: 940 x 500
|
||||||
|
Title bar: hiddenInset (macOS), hidden with titleBarOverlay (Windows/Linux)
|
||||||
|
Title bar overlay: bg #0b0b10, symbol #d8d8de, height 32px
|
||||||
|
Background color: #313338
|
||||||
|
```
|
||||||
|
|
||||||
|
### State Persistence
|
||||||
|
|
||||||
|
Window state is saved to `{userData}/window-state.json`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface WindowState {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
x?: number;
|
||||||
|
y?: number;
|
||||||
|
isMaximized: boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Event | Behavior |
|
||||||
|
|-------|----------|
|
||||||
|
| resize / move | Debounced save (300ms) via `saveWindowState()` |
|
||||||
|
| close | Immediate save before hide |
|
||||||
|
| maximize | Saves `isMaximized: true`; position/size stored from `getNormalBounds()` (pre-maximize geometry) |
|
||||||
|
| restore | Validates saved bounds against current displays; strips position if window would be off-screen |
|
||||||
|
|
||||||
|
Bounds validation (`validateWindowBounds`): Uses `screen.getDisplayMatching()` to find the nearest display, then checks if the window rectangle overlaps the display's work area. If not visible, position is stripped and Electron auto-centers.
|
||||||
|
|
||||||
|
### Close Behavior
|
||||||
|
|
||||||
|
Close does **not** quit the app. The `close` event is intercepted; the window is hidden instead. The `isQuitting` flag gates actual destruction. True quit only happens via:
|
||||||
|
- Tray menu "Quit"
|
||||||
|
- `app.quit()` (Cmd+Q on macOS)
|
||||||
|
- `before-quit` lifecycle event
|
||||||
|
|
||||||
|
### URL Loading Priority
|
||||||
|
|
||||||
|
1. `BACKSPACE_URL` environment variable (managed deployments)
|
||||||
|
2. Saved instance URL from `{userData}/instance-url.json`
|
||||||
|
3. No URL: loads `resources/instance-picker.html` (local HTML file)
|
||||||
|
|
||||||
|
Instance URL management functions: `loadInstanceUrl()`, `saveInstanceUrl()`, `clearInstanceUrl()` — all operate on `{userData}/instance-url.json`.
|
||||||
|
|
||||||
|
### Focus Tracking
|
||||||
|
|
||||||
|
Window `focus`/`blur` events send `window-focus-changed` (boolean) to the renderer via IPC. The web client uses this for notification suppression (no desktop notifications when the window is focused).
|
||||||
|
|
||||||
|
### External Links
|
||||||
|
|
||||||
|
`setWindowOpenHandler` intercepts all `window.open()` calls. HTTP/HTTPS URLs are opened in the default browser via `shell.openExternal()`. All popup windows are denied (`action: 'deny'`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Instance Picker
|
||||||
|
|
||||||
|
When no instance URL is configured, the app loads `resources/instance-picker.html` — a self-contained HTML page where the user enters their Backspace instance URL. The renderer communicates the chosen URL back via the `set-instance-url` IPC handler.
|
||||||
|
|
||||||
|
After navigation (both to an instance URL and back to the picker), the main process forces Electron to re-evaluate drag regions by momentarily resizing the window (+1px then back).
|
||||||
|
|
||||||
|
The tray menu and macOS app menu both include a "Change Instance" option that clears the saved URL and reloads the picker.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Auto-Launch (Start with OS)
|
||||||
|
|
||||||
|
Settings stored in `{userData}/auto-launch.json`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface AutoLaunchSettings {
|
||||||
|
openAtLogin: boolean; // default: false
|
||||||
|
startMinimized: boolean; // default: true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Platform-Specific Implementation (`applyLoginItemSettings()`)
|
||||||
|
|
||||||
|
| Platform | Method | Minimized Launch |
|
||||||
|
|----------|--------|-----------------|
|
||||||
|
| macOS | `app.setLoginItemSettings({ openAtLogin, openAsHidden })` | `openAsHidden` flag |
|
||||||
|
| Windows | `app.setLoginItemSettings({ openAtLogin, args, name })` | `--hidden` CLI arg |
|
||||||
|
| Linux | `app.setLoginItemSettings({ openAtLogin, path, args })` | `--hidden` CLI arg; `path` set to `$APPIMAGE` for AppImage portability |
|
||||||
|
|
||||||
|
On startup, settings are re-applied to the OS (`applyLoginItemSettings`) to refresh the login item path (important for AppImage updates where the path changes).
|
||||||
|
|
||||||
|
### Launch Detection
|
||||||
|
|
||||||
|
Hidden launch is detected at `ready-to-show` via:
|
||||||
|
- `process.argv.includes('--hidden')` (Windows/Linux)
|
||||||
|
- `app.getLoginItemSettings().wasOpenedAsHidden` (macOS)
|
||||||
|
|
||||||
|
If launched hidden, the window is created but never shown (stays in tray).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tray Icon
|
||||||
|
|
||||||
|
### Icon Loading (`loadTrayIcon()`)
|
||||||
|
|
||||||
|
| Platform | Source | Notes |
|
||||||
|
|----------|--------|-------|
|
||||||
|
| macOS | `resources/tray-iconTemplate.png` | Template image (auto light/dark); Electron resolves `@2x` variant |
|
||||||
|
| Windows/Linux | `resources/tray-icon.png` | Colored icon, resized to 16x16 |
|
||||||
|
| Fallback | Programmatic 16x16 BGRA buffer | Blurple circle (#5865f2) |
|
||||||
|
|
||||||
|
### Context Menu
|
||||||
|
|
||||||
|
| Item | Action |
|
||||||
|
|------|--------|
|
||||||
|
| Show Backspace | `window.show()` + `focus()` |
|
||||||
|
| Hide | `window.hide()` |
|
||||||
|
| Change Instance | Clear saved URL, load picker, show + focus |
|
||||||
|
| Quit | Set `isQuitting = true`, `app.quit()` |
|
||||||
|
|
||||||
|
Tray click toggles window visibility (show/hide).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deep Linking
|
||||||
|
|
||||||
|
Protocol: `backspace://`
|
||||||
|
|
||||||
|
Registered via `app.setAsDefaultProtocolClient('backspace')` and in `electron-builder.yml` under `protocols`.
|
||||||
|
|
||||||
|
### Platform Handling
|
||||||
|
|
||||||
|
| Platform | Mechanism |
|
||||||
|
|----------|-----------|
|
||||||
|
| macOS | `app.on('open-url')` event |
|
||||||
|
| Windows/Linux | `second-instance` event (via single-instance lock); deep link extracted from `commandLine` args |
|
||||||
|
| Cold launch | Deep link arg stored in `pendingDeepLink`, delivered after `ready-to-show` |
|
||||||
|
|
||||||
|
### Flow
|
||||||
|
|
||||||
|
1. `handleDeepLink(url)` receives a `backspace://` URL
|
||||||
|
2. If window exists: sends `deep-link` IPC to renderer, shows + focuses window
|
||||||
|
3. If app not ready: stores in `pendingDeepLink` for delivery after `ready-to-show`
|
||||||
|
|
||||||
|
### Single Instance Lock
|
||||||
|
|
||||||
|
`app.requestSingleInstanceLock()` ensures only one instance runs. Second launch:
|
||||||
|
- Deep link arg is forwarded to the existing instance
|
||||||
|
- Existing window is restored/shown/focused
|
||||||
|
- Second instance quits immediately
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Auto-Update
|
||||||
|
|
||||||
|
Powered by `electron-updater`. Loaded via `require()` (not import) for graceful degradation when not available.
|
||||||
|
|
||||||
|
### Configuration (`initAutoUpdater()`)
|
||||||
|
|
||||||
|
```
|
||||||
|
autoDownload: true
|
||||||
|
autoInstallOnAppQuit: true
|
||||||
|
Publish: GitHub (TheZwiss/backspace)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check Schedule
|
||||||
|
|
||||||
|
| Trigger | Delay |
|
||||||
|
|---------|-------|
|
||||||
|
| Initial check | 10 seconds after app ready |
|
||||||
|
| Periodic check | Every 4 hours |
|
||||||
|
| Manual check | `check-for-updates` IPC from renderer |
|
||||||
|
|
||||||
|
### Event Flow (main -> renderer)
|
||||||
|
|
||||||
|
| Event | IPC Channel | Payload | Condition |
|
||||||
|
|-------|------------|---------|-----------|
|
||||||
|
| Update found | `update-available` | `{ version }` | Always |
|
||||||
|
| Download complete | `update-downloaded` | `{ version }` | Always |
|
||||||
|
| Error | `update-error` | `{ message, releaseUrl }` | Only if `updateConfirmed` is true (download failed after update was confirmed) |
|
||||||
|
|
||||||
|
Check-phase errors (network, auth, 404) are silently ignored — nothing actionable for the user.
|
||||||
|
|
||||||
|
### Install
|
||||||
|
|
||||||
|
`install-update` IPC triggers `autoUpdater.quitAndInstall()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notifications
|
||||||
|
|
||||||
|
`main.ts:showNotification()` — uses Electron's `Notification` API.
|
||||||
|
|
||||||
|
- Checks `Notification.isSupported()` before showing
|
||||||
|
- `silent: false` (plays system sound)
|
||||||
|
- Click handler: shows + focuses the main window
|
||||||
|
|
||||||
|
Badge count: `set-badge-count` IPC calls `app.setBadgeCount()` (macOS dock badge, Windows taskbar overlay).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Application Menu
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
|
||||||
|
Full menu bar with:
|
||||||
|
- App menu: About, Change Instance, Hide/Unhide, Quit
|
||||||
|
- Edit: Undo, Redo, Cut, Copy, Paste, Select All
|
||||||
|
- Window: Minimize, Zoom, Front
|
||||||
|
|
||||||
|
### Windows/Linux
|
||||||
|
|
||||||
|
Hidden menu bar (frameless window), but an Edit menu is still registered so keyboard accelerators (Ctrl+C/V/X/Z/A) work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Screen Share Integration
|
||||||
|
|
||||||
|
The main process intercepts `getDisplayMedia()` via `session.defaultSession.setDisplayMediaRequestHandler()`.
|
||||||
|
|
||||||
|
### Flow
|
||||||
|
|
||||||
|
1. Handler invoked by Chromium when renderer calls `navigator.mediaDevices.getDisplayMedia()`
|
||||||
|
2. Main process enumerates sources via `desktopCapturer.getSources({ types: ['screen', 'window'], thumbnailSize: { width: 320, height: 180 }, fetchWindowIcons: true })`
|
||||||
|
3. Sources serialized (id, name, thumbnail data URL, app icon data URL, isScreen flag) and sent to renderer via `screen-share-sources` IPC
|
||||||
|
4. Renderer shows custom picker UI, user selects a source
|
||||||
|
5. Renderer sends `screen-share-selected` IPC with `sourceId` (or `null` to cancel) and `shareAudio` flag
|
||||||
|
6. Main process calls `callback({ video: selectedSource, audio: 'loopback' })` (audio only if `shareAudio` is true)
|
||||||
|
|
||||||
|
No sources (0 results) typically means Screen Recording permission not granted on macOS.
|
||||||
|
|
||||||
|
For full screen share configuration (resolution, bitrate, codec), see `voice.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cache Clearing
|
||||||
|
|
||||||
|
On every app launch (`app.whenReady`), the main process purges stale caches:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
await session.defaultSession.clearStorageData({ storages: ['serviceworkers'] });
|
||||||
|
await session.defaultSession.clearCache();
|
||||||
|
```
|
||||||
|
|
||||||
|
This ensures the renderer always loads fresh code after updates.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GTK Version Override
|
||||||
|
|
||||||
|
On Linux, Electron 36+ defaults to GTK 4 on GNOME, which crashes if GTK 2/3 libraries are loaded in the same process (common with uiohook-napi). The app forces GTK 3:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
app.commandLine.appendSwitch('gtk-version', '3');
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## IPC Handler Registry
|
||||||
|
|
||||||
|
All handlers registered in `main.ts:registerIpcHandlers()`.
|
||||||
|
|
||||||
|
### Fire-and-Forget (`ipcMain.on`)
|
||||||
|
|
||||||
|
| Channel | Direction | Payload | Action |
|
||||||
|
|---------|-----------|---------|--------|
|
||||||
|
| `show-notification` | R->M | `{ title, body }` | Show native notification |
|
||||||
|
| `set-badge-count` | R->M | `number` | Set dock/taskbar badge |
|
||||||
|
| `minimize-window` | R->M | — | Minimize window |
|
||||||
|
| `maximize-window` | R->M | — | Toggle maximize/unmaximize |
|
||||||
|
| `close-window` | R->M | — | Close (hides to tray) |
|
||||||
|
| `install-update` | R->M | — | `autoUpdater.quitAndInstall()` |
|
||||||
|
| `check-for-updates` | R->M | — | `autoUpdater.checkForUpdates()` |
|
||||||
|
| `screen-share-selected` | R->M | `sourceId, shareAudio?` | Safety net (actual handler is `ipcMain.once` in display media flow) |
|
||||||
|
| `keybinds-sync` | R->M | `KeybindConfig[]` | `keybindManager.updateKeybinds()` |
|
||||||
|
|
||||||
|
### Request/Response (`ipcMain.handle`)
|
||||||
|
|
||||||
|
| Channel | Direction | Returns | Action |
|
||||||
|
|---------|-----------|---------|--------|
|
||||||
|
| `get-instance-url` | R->M | `string \| null` | Load saved instance URL |
|
||||||
|
| `set-instance-url` | R->M | `void` | Save URL, navigate window to it |
|
||||||
|
| `clear-instance-url` | R->M | `void` | Delete saved URL, load picker |
|
||||||
|
| `get-app-version` | R->M | `string` | `app.getVersion()` |
|
||||||
|
| `get-auto-launch-settings` | R->M | `{ openAtLogin, startMinimized }` | Merge OS state with saved prefs |
|
||||||
|
| `set-auto-launch-settings` | R->M | `{ openAtLogin, startMinimized }` | Save + apply to OS |
|
||||||
|
| `get-current-activity` | R->M | `Activity \| null` | Current detected game activity |
|
||||||
|
| `check-accessibility` | R->M | `boolean` | macOS accessibility permission check |
|
||||||
|
|
||||||
|
### Main -> Renderer Events
|
||||||
|
|
||||||
|
| Channel | Payload | Trigger |
|
||||||
|
|---------|---------|---------|
|
||||||
|
| `window-focus-changed` | `boolean` | Window focus/blur |
|
||||||
|
| `deep-link` | `string` (URL) | `backspace://` protocol activation |
|
||||||
|
| `update-available` | `{ version }` | electron-updater |
|
||||||
|
| `update-downloaded` | `{ version }` | electron-updater |
|
||||||
|
| `update-error` | `{ message, releaseUrl }` | electron-updater (only after confirmed update) |
|
||||||
|
| `screen-share-sources` | `ElectronScreenSource[]` | Display media handler |
|
||||||
|
| `activity-detected` | `Activity \| null` | Activity detector poll |
|
||||||
|
| `keybind-action` | `{ actionId, pressed }` | KeybindManager match |
|
||||||
|
| `accessibility-status` | `{ trusted }` | macOS accessibility check result |
|
||||||
|
| `keybind-hook-error` | `{ message }` | uIOhook start failure |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Preload Bridge (`window.backspace`)
|
||||||
|
|
||||||
|
The preload script exposes the `window.backspace` API via `contextBridge.exposeInMainWorld`. TypeScript declarations are in `packages/web/src/platform/electron.d.ts`.
|
||||||
|
|
||||||
|
Detection: `typeof window.backspace !== 'undefined'` (see `platform.ts:isElectron()`).
|
||||||
|
|
||||||
|
### API Surface
|
||||||
|
|
||||||
|
| Method / Property | Type | Direction | Notes |
|
||||||
|
|-------------------|------|-----------|-------|
|
||||||
|
| `platform` | `NodeJS.Platform` | read | `process.platform` value |
|
||||||
|
| `minimize()` | fire | R->M | |
|
||||||
|
| `maximize()` | fire | R->M | Toggles maximize |
|
||||||
|
| `close()` | fire | R->M | Hides to tray |
|
||||||
|
| `showNotification(title, body)` | fire | R->M | |
|
||||||
|
| `setBadgeCount(count)` | fire | R->M | |
|
||||||
|
| `onUpdateAvailable(cb)` | listen | M->R | |
|
||||||
|
| `onUpdateDownloaded(cb)` | listen | M->R | |
|
||||||
|
| `onUpdateError(cb)` | listen | M->R | |
|
||||||
|
| `installUpdate()` | fire | R->M | |
|
||||||
|
| `checkForUpdates()` | fire | R->M | |
|
||||||
|
| `getVersion()` | invoke | R->M | Returns `Promise<string>` |
|
||||||
|
| `onWindowFocusChange(cb)` | listen | M->R | |
|
||||||
|
| `onDeepLink(cb)` | listen | M->R | |
|
||||||
|
| `onScreenShareSources(cb)` | listen | M->R | |
|
||||||
|
| `selectScreenSource(id, audio?)` | fire | R->M | |
|
||||||
|
| `getInstanceUrl()` | invoke | R->M | Returns `Promise<string \| null>` |
|
||||||
|
| `setInstanceUrl(url)` | invoke | R->M | Returns `Promise<void>` |
|
||||||
|
| `clearInstanceUrl()` | invoke | R->M | Returns `Promise<void>` |
|
||||||
|
| `getAutoLaunchSettings()` | invoke | R->M | |
|
||||||
|
| `setAutoLaunchSettings(s)` | invoke | R->M | |
|
||||||
|
| `onActivityDetected(cb)` | listen | M->R | Returns cleanup function `() => void` |
|
||||||
|
| `getCurrentActivity()` | invoke | R->M | Returns `Promise<Activity \| null>` |
|
||||||
|
| `syncKeybinds(keybinds)` | fire | R->M | |
|
||||||
|
| `onKeybindAction(cb)` | listen | M->R | Returns cleanup function |
|
||||||
|
| `onAccessibilityStatus(cb)` | listen | M->R | Returns cleanup function |
|
||||||
|
| `onKeybindHookError(cb)` | listen | M->R | Returns cleanup function |
|
||||||
|
| `checkAccessibility()` | invoke | R->M | Returns `Promise<boolean>` |
|
||||||
|
|
||||||
|
Direction legend: **fire** = `ipcRenderer.send` (no response), **invoke** = `ipcRenderer.invoke` (returns Promise), **listen** = `ipcRenderer.on` (event subscription).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Activity Detection
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
|
||||||
|
Detects running games/applications by polling the OS process list every 15 seconds and matching process names against a game dictionary.
|
||||||
|
|
||||||
|
### Game Dictionary
|
||||||
|
|
||||||
|
Two formats supported:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Legacy bare array (version 0)
|
||||||
|
GameEntry[]
|
||||||
|
|
||||||
|
// Versioned object
|
||||||
|
{ version: number; games: GameEntry[] }
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface GameEntry {
|
||||||
|
id: string; // unique identifier, e.g. "cs2"
|
||||||
|
name: string; // display name, e.g. "Counter-Strike 2"
|
||||||
|
processes: string[]; // executable names, e.g. ["cs2.exe", "cs2"]
|
||||||
|
type?: string; // "playing" | "listening" | "watching" | "streaming" (default: "playing")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dictionary Loading Strategy
|
||||||
|
|
||||||
|
1. **Startup** (`startActivityDetection`): Load best local source — cache file first (`{userData}/games-cache.json`), fall back to bundled seed (`resources/games.json`)
|
||||||
|
2. **Background sync** (`syncDictionary`): Fire-and-forget async fetch from GitHub after startup
|
||||||
|
|
||||||
|
### Remote Sync (`syncDictionary()`)
|
||||||
|
|
||||||
|
**Remote URL:** `https://raw.githubusercontent.com/TheZwiss/backspace/main/packages/desktop/resources/games.json`
|
||||||
|
|
||||||
|
```
|
||||||
|
Step 1: Determine best local version (cache vs seed, whichever has higher version)
|
||||||
|
Step 2: Fetch remote with conditional request (ETag)
|
||||||
|
Step 3: If remote version > local version → atomic write to cache, save ETag, hot-swap
|
||||||
|
```
|
||||||
|
|
||||||
|
**ETag-based conditional fetching:**
|
||||||
|
- ETag stored at `{userData}/games-cache-etag.txt`
|
||||||
|
- Sent as `If-None-Match` header; 304 response = no update needed
|
||||||
|
- Follows single redirects (301/302, common for GitHub raw)
|
||||||
|
- 10-second timeout
|
||||||
|
|
||||||
|
**Atomic file writes** (`atomicWrite()`): Writes to `{path}.tmp` then `fs.renameSync()` into place. Prevents corrupt cache on crash.
|
||||||
|
|
||||||
|
**Hot-swap** (`hotSwapDictionary()`): Replaces `gameEntries` and `processMap` in memory without resetting `currentGameId` or `currentActivity`. Active detection state survives dictionary updates.
|
||||||
|
|
||||||
|
### Process Polling
|
||||||
|
|
||||||
|
**Interval:** 15 seconds (`POLL_INTERVAL_MS`). First poll runs immediately on start.
|
||||||
|
|
||||||
|
**Guard:** `isPolling` flag prevents overlapping polls if a previous `execFile` hasn't returned.
|
||||||
|
|
||||||
|
**Error handling:** First `execFile` failure logs warning and stops detection entirely (`stopActivityDetection()`). The `hasErrored` flag prevents repeated log spam.
|
||||||
|
|
||||||
|
### Platform Commands
|
||||||
|
|
||||||
|
| Platform | Command | Output Format |
|
||||||
|
|----------|---------|---------------|
|
||||||
|
| macOS | `ps -c -A -o comm` | One process name per line (header: `COMM`) |
|
||||||
|
| Linux | `ps -A -o comm` | One process name per line (header: `COMM` or `COMMAND`) |
|
||||||
|
| Windows | `tasklist /fo csv /nh` | CSV: `"ImageName","PID","SessionName","Session#","MemUsage"` |
|
||||||
|
|
||||||
|
Max buffer: 1MB. Process names extracted and lowercased into a `Set<string>`.
|
||||||
|
|
||||||
|
### Matching Algorithm (`poll()`)
|
||||||
|
|
||||||
|
1. Parse running processes into a lowercase `Set<string>`
|
||||||
|
2. Iterate `gameEntries` in dictionary order (first match wins = priority)
|
||||||
|
3. For each entry, check if any of its `processes` (lowercased) are in the running set
|
||||||
|
4. **Game detected (new or changed):** Set `currentGameId`, build `Activity` object with `timestamps.start = Date.now()`, fire `onChangeCallback`
|
||||||
|
5. **Same game still running:** No-op (no IPC sent)
|
||||||
|
6. **Game exited (was detected, now gone):** Clear `currentGameId` and `currentActivity`, fire callback with `null`
|
||||||
|
7. **No game (and none before):** No-op
|
||||||
|
|
||||||
|
### Activity Object
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface Activity {
|
||||||
|
type: string; // from GameEntry.type, default "playing"
|
||||||
|
name: string; // from GameEntry.name
|
||||||
|
details?: string; // unused currently
|
||||||
|
state?: string; // unused currently
|
||||||
|
timestamps?: { start?: number; end?: number };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lifecycle
|
||||||
|
|
||||||
|
- **Start:** `startActivityDetection(callback)` — called from `app.whenReady()`
|
||||||
|
- **Stop:** `stopActivityDetection()` — called from `before-quit`
|
||||||
|
- **Query:** `getCurrentActivity()` — exposed via `get-current-activity` IPC handle
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Global Keybind Manager
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
|
||||||
|
Captures global keyboard and mouse events via `uiohook-napi` (OS-level input hook) and matches them against user-configured keybinds. Works even when the Backspace window is not focused.
|
||||||
|
|
||||||
|
### Native Keycode to DOM Code Mapping
|
||||||
|
|
||||||
|
uIOhook reports hardware scan codes. The web UI stores keybinds as djb2 hashes of DOM `KeyboardEvent.code` strings. The `UIOHOOK_TO_DOM_CODE` lookup table bridges the two.
|
||||||
|
|
||||||
|
**Mapped key ranges:**
|
||||||
|
|
||||||
|
| Category | Examples |
|
||||||
|
|----------|---------|
|
||||||
|
| Letters | A-Z (keycodes 16-50) |
|
||||||
|
| Digits | 0-9 (keycodes 2-11) |
|
||||||
|
| Function keys | F1-F24 (keycodes 59-107) |
|
||||||
|
| Modifiers | ControlLeft/Right, AltLeft/Right, ShiftLeft/Right, MetaLeft/Right |
|
||||||
|
| Special | Backspace, Tab, Enter, CapsLock, Escape, Space |
|
||||||
|
| Navigation | PageUp/Down, Home, End, Arrows, Insert, Delete |
|
||||||
|
| Punctuation | Semicolon, Equal, Comma, Minus, Period, Slash, Backquote, Brackets, Backslash, Quote |
|
||||||
|
| Numpad | Numpad0-9, NumpadMultiply/Add/Subtract/Decimal/Divide |
|
||||||
|
| Locks | NumLock, ScrollLock, PrintScreen |
|
||||||
|
|
||||||
|
### djb2 Hash Function
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function djb2(code: string): number {
|
||||||
|
let hash = 5381;
|
||||||
|
for (let i = 0; i < code.length; i++) {
|
||||||
|
hash = ((hash << 5) + hash + code.charCodeAt(i)) | 0;
|
||||||
|
}
|
||||||
|
return hash >>> 0;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This hash is used identically in:
|
||||||
|
- `keybindManager.ts:djb2()` — main process, matching against native events
|
||||||
|
- `useKeybinds.ts:browserCodeToUiohook()` — renderer, web fallback path
|
||||||
|
- `KeybindsPanel.tsx:codeToNumeric()` — renderer, recording keybinds in settings UI
|
||||||
|
|
||||||
|
The pre-computed `UIOHOOK_TO_HASH` map converts uIOhook keycodes directly to djb2 hashes at module load time.
|
||||||
|
|
||||||
|
### KeybindConfig
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface KeybindConfig {
|
||||||
|
actionId: string; // e.g. "toggleMute", "pushToTalk"
|
||||||
|
keys: number[]; // djb2 hashes of DOM code strings
|
||||||
|
mouseButton?: number; // uIOhook mouse button index (3=middle, 4=back, 5=forward)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### KeybindManager Class
|
||||||
|
|
||||||
|
**State:**
|
||||||
|
- `keybinds: KeybindConfig[]` — current bindings synced from renderer
|
||||||
|
- `pressedKeys: Set<number>` — currently held keys (djb2 hashes)
|
||||||
|
- `activeActions: Set<string>` — actions whose keybind is currently satisfied
|
||||||
|
- `window: BrowserWindow | null` — target for IPC sends
|
||||||
|
- `started: boolean` — whether uIOhook is running
|
||||||
|
|
||||||
|
### Lifecycle
|
||||||
|
|
||||||
|
1. **`updateKeybinds(keybinds)`** — called when renderer syncs new bindings via `keybinds-sync` IPC
|
||||||
|
- Replaces stored keybinds
|
||||||
|
- Releases any active actions whose binding was removed
|
||||||
|
- Auto-starts uIOhook if keybinds exist and hook not running
|
||||||
|
- Auto-stops uIOhook if keybinds list becomes empty
|
||||||
|
|
||||||
|
2. **`start()`** — registers uIOhook event listeners and calls `uIOhook.start()`
|
||||||
|
- On macOS: checks `systemPreferences.isTrustedAccessibilityClient(true)` first (the `true` parameter triggers the OS permission prompt)
|
||||||
|
- If not trusted: sends `accessibility-status` event to renderer and returns without starting
|
||||||
|
- On start failure: sends `keybind-hook-error` to renderer
|
||||||
|
|
||||||
|
3. **`stop()`** — releases all active actions, clears state, removes listeners, calls `uIOhook.stop()`
|
||||||
|
- Called from `app.on('before-quit')`
|
||||||
|
|
||||||
|
### Event Processing
|
||||||
|
|
||||||
|
**Key down (`onKeyDown`):**
|
||||||
|
1. Convert uIOhook keycode to djb2 hash via `UIOHOOK_TO_HASH`
|
||||||
|
2. Add hash to `pressedKeys`
|
||||||
|
3. `evaluateKeybinds()`: for each keybind (no mouseButton, not already active), check if all `keys` are in `pressedKeys` → if yes, activate and send `pressed: true`
|
||||||
|
|
||||||
|
**Key up (`onKeyUp`):**
|
||||||
|
1. Convert keycode to hash, remove from `pressedKeys`
|
||||||
|
2. `checkReleases()`: for each active action (no mouseButton), check if any required key is no longer pressed → if so, deactivate and send `pressed: false`
|
||||||
|
|
||||||
|
**Mouse down (`onMouseDown`):**
|
||||||
|
1. Ignore buttons 1 (left) and 2 (right) — only extra buttons (3+) are bindable
|
||||||
|
2. `evaluateKeybindsWithMouse(button)`: for keybinds matching this mouseButton that aren't already active, check modifier keys → activate
|
||||||
|
|
||||||
|
**Mouse up (`onMouseUp`):**
|
||||||
|
1. Ignore buttons 1 and 2
|
||||||
|
2. `checkMouseReleases(button)`: deactivate actions bound to this mouse button
|
||||||
|
|
||||||
|
### IPC Output
|
||||||
|
|
||||||
|
All matched actions sent to renderer as: `keybind-action { actionId: string, pressed: boolean }`
|
||||||
|
|
||||||
|
This is critical for **push-to-talk**: the `pressed: true` unmutes, `pressed: false` re-mutes. Toggle actions (mute, deafen, camera, etc.) only trigger on `pressed: true`.
|
||||||
|
|
||||||
|
### macOS Accessibility Permission
|
||||||
|
|
||||||
|
uIOhook requires Accessibility permission on macOS to capture global input events.
|
||||||
|
|
||||||
|
| Method | `prompt` param | Effect |
|
||||||
|
|--------|---------------|--------|
|
||||||
|
| `start()` → `isTrustedAccessibilityClient(true)` | true | Checks + shows OS permission dialog if not trusted |
|
||||||
|
| `checkAccessibility()` → `isTrustedAccessibilityClient(false)` | false | Checks without prompting |
|
||||||
|
|
||||||
|
On non-macOS platforms, `checkAccessibility()` always returns `true`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Keybind System (Web Side)
|
||||||
|
|
||||||
|
### Keybind Store (`keybindStore.ts`)
|
||||||
|
|
||||||
|
Persisted via Zustand `persist` middleware to `localStorage` key `backspace-keybinds` (version 1).
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface Keybind {
|
||||||
|
actionId: string;
|
||||||
|
keys: number[]; // djb2 hashes, sorted ascending
|
||||||
|
mouseButton?: number; // 3=middle, 4=back, 5=forward
|
||||||
|
displayLabel: string; // human-readable, captured at record time
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Blacklisted mouse buttons:** 1 (left), 2 (right) — `setKeybind()` silently ignores these.
|
||||||
|
|
||||||
|
**Conflict detection:** `findConflict(keys, mouseButton?, excludeActionId?)` checks for exact key+mouse match against existing bindings.
|
||||||
|
|
||||||
|
### Bindable Actions
|
||||||
|
|
||||||
|
| Action ID | Label | Type |
|
||||||
|
|-----------|-------|------|
|
||||||
|
| `toggleMute` | Toggle Mute | toggle |
|
||||||
|
| `toggleDeafen` | Toggle Deafen | toggle |
|
||||||
|
| `pushToTalk` | Push to Talk | hold |
|
||||||
|
| `toggleCamera` | Toggle Camera | toggle |
|
||||||
|
| `toggleScreenShare` | Toggle Screen Share | toggle |
|
||||||
|
| `disconnect` | Disconnect | toggle |
|
||||||
|
|
||||||
|
### useKeybinds Hook
|
||||||
|
|
||||||
|
Three parallel systems:
|
||||||
|
|
||||||
|
**1. PTT Lifecycle** — When `pushToTalk` keybind exists and user is in voice: activates PTT mode (`pttActive: true`), force-mutes the user. Deactivates when keybind removed or user leaves voice.
|
||||||
|
|
||||||
|
**2. Electron IPC Bridge** — Active when `isElectron()` and keybinds exist:
|
||||||
|
- Syncs keybind config to main process via `syncKeybinds()`
|
||||||
|
- Subscribes to `onKeybindAction()` for matched events from uIOhook
|
||||||
|
- Cleanup on unmount
|
||||||
|
|
||||||
|
**3. Web Fallback** — Always active (both web and Electron):
|
||||||
|
- Capture-phase `keydown`/`keyup`/`mousedown`/`mouseup` listeners on `window`
|
||||||
|
- Converts `KeyboardEvent.code` to djb2 hash via inline `browserCodeToUiohook()`
|
||||||
|
- Same evaluation logic as KeybindManager (track pressed keys, match keybinds, detect releases)
|
||||||
|
- **Input suppression:** Skips single character keys (no modifiers) when an input/textarea/contentEditable is focused
|
||||||
|
- **Mouse button mapping:** Browser button index -> uIOhook: `{ 1: 3, 3: 4, 4: 5 }` (middle, back, forward)
|
||||||
|
|
||||||
|
**Deduplication:** `dispatchKeybindAction()` uses a 100ms cooldown per `actionId:pressed` pair to prevent double-firing when both the native hook and web fallback trigger simultaneously (common when the Electron window is focused).
|
||||||
|
|
||||||
|
### Action Dispatch (`dispatchKeybindAction()`)
|
||||||
|
|
||||||
|
Only dispatches when user is in a voice channel (`currentVoiceChannelId` exists).
|
||||||
|
|
||||||
|
Checks space mute/deafen enforcement state before dispatching mute/deafen actions (see `voice.md` for voice moderation details).
|
||||||
|
|
||||||
|
| Action | Trigger | Behavior |
|
||||||
|
|--------|---------|----------|
|
||||||
|
| `toggleMute` | `pressed: true` | `handleMuteAction()` (respects space enforcement) |
|
||||||
|
| `toggleDeafen` | `pressed: true` | `handleDeafenAction()` |
|
||||||
|
| `toggleCamera` | `pressed: true` | `handleCameraAction()` |
|
||||||
|
| `toggleScreenShare` | `pressed: true` | `handleScreenShareAction()` |
|
||||||
|
| `disconnect` | `pressed: true` | `handleDisconnectAction()` |
|
||||||
|
| `pushToTalk` | `pressed: true/false` | `setMuted(!pressed)` + `broadcastVoiceStatus()` |
|
||||||
|
|
||||||
|
Toggle actions only fire on `pressed: true`. Push-to-talk fires on both press (unmute) and release (mute).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Build System
|
||||||
|
|
||||||
|
### electron-builder Configuration (`electron-builder.yml`)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
appId: com.backspace.desktop
|
||||||
|
productName: Backspace
|
||||||
|
artifactName: "${productName}-${version}-${arch}.${ext}"
|
||||||
|
output: dist-electron
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build Targets
|
||||||
|
|
||||||
|
| Platform | Formats |
|
||||||
|
|----------|---------|
|
||||||
|
| macOS | dmg, zip |
|
||||||
|
| Windows | nsis (allows custom install dir) |
|
||||||
|
| Linux | AppImage, deb |
|
||||||
|
|
||||||
|
### Build Commands
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `pnpm build` | TypeScript compile + electron-builder (current platform) |
|
||||||
|
| `pnpm build:all` | Cross-platform: `--mac --win --linux --arm64 --x64` |
|
||||||
|
| `pnpm dev` | Compile TypeScript + launch Electron (with icon setup) |
|
||||||
|
|
||||||
|
### Native Module Handling
|
||||||
|
|
||||||
|
**Dependency:** `uiohook-napi` (native N-API addon for global input hooks)
|
||||||
|
|
||||||
|
**Rebuild:** `electron-rebuild -f -w uiohook-napi` runs on `postinstall` to compile for the build machine's Electron ABI.
|
||||||
|
|
||||||
|
**ASAR unpacking:** All `.node` files are unpacked from the ASAR archive (`asarUnpack: "**/*.node"`). Native modules cannot load from inside ASAR.
|
||||||
|
|
||||||
|
**Build exclusions:** Host-compiled artifacts are excluded from the ASAR to prevent them from shadowing platform-correct prebuilts:
|
||||||
|
```yaml
|
||||||
|
- "!**/node_modules/uiohook-napi/build/**"
|
||||||
|
- "!**/node_modules/uiohook-napi/build.bak/**"
|
||||||
|
- "!**/node_modules/uiohook-napi/bin/**"
|
||||||
|
```
|
||||||
|
|
||||||
|
`npmRebuild: false` — electron-builder's built-in rebuild is disabled; the `postinstall` script handles it.
|
||||||
|
|
||||||
|
### afterPack Hook (CRITICAL)
|
||||||
|
|
||||||
|
**File:** `scripts/afterPack.js`
|
||||||
|
|
||||||
|
**Problem:** `electron-rebuild` (postinstall) compiles `uiohook-napi` for the BUILD machine (e.g., macOS arm64), placing the binary in `build/Release/`. The `node-gyp-build` loader checks `build/Release/` BEFORE `prebuilds/{platform}/`. Without cleanup, cross-platform builds (e.g., building Windows packages on macOS) would ship the macOS binary, causing immediate crashes on the target platform.
|
||||||
|
|
||||||
|
**Solution (two steps):**
|
||||||
|
|
||||||
|
1. **Remove host-compiled artifacts:** Deletes `build/`, `build.bak/`, and `bin/` directories from the unpacked `uiohook-napi` in the output.
|
||||||
|
|
||||||
|
2. **Strip foreign prebuilts:** Removes `prebuilds/{platform}-{arch}/` directories for platforms other than the build target. Saves ~1-2MB per build.
|
||||||
|
|
||||||
|
**Path resolution:** On macOS, resources live inside `{productName}.app/Contents/Resources/`; on Windows/Linux, under `resources/`. The hook resolves the correct path via `context.electronPlatformName`.
|
||||||
|
|
||||||
|
**WARNING:** Removing or disabling this hook will cause Windows and Linux builds to crash on launch. This is documented in project memory as a critical constraint.
|
||||||
|
|
||||||
|
### Icon Generation
|
||||||
|
|
||||||
|
**`scripts/gen-icns.sh`** — macOS-only, generates `.icns` from `icon.png` via:
|
||||||
|
1. `sips` to resize into all required icon sizes (16-1024px, including @2x variants)
|
||||||
|
2. `iconutil -c icns` to pack the iconset
|
||||||
|
|
||||||
|
Used in `pnpm dev` to set the dev Electron icon.
|
||||||
|
|
||||||
|
### Auto-Update Publishing
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
publish:
|
||||||
|
- provider: github
|
||||||
|
owner: TheZwiss
|
||||||
|
repo: backspace
|
||||||
|
```
|
||||||
|
|
||||||
|
GitHub releases are the update source. The `electron-updater` library handles checking, downloading, and applying updates.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Persisted Files (userData)
|
||||||
|
|
||||||
|
| File | Content | Purpose |
|
||||||
|
|------|---------|---------|
|
||||||
|
| `instance-url.json` | `{ url: string }` | Saved instance URL |
|
||||||
|
| `window-state.json` | `WindowState` | Window position, size, maximize state |
|
||||||
|
| `auto-launch.json` | `AutoLaunchSettings` | Open at login + start minimized prefs |
|
||||||
|
| `games-cache.json` | `VersionedDictionary` | Cached remote game dictionary |
|
||||||
|
| `games-cache-etag.txt` | ETag string | For conditional HTTP requests |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## App Lifecycle Summary
|
||||||
|
|
||||||
|
### Startup Sequence (`app.whenReady()`)
|
||||||
|
|
||||||
|
1. Set application menu (platform-specific)
|
||||||
|
2. Clear service worker cache + HTTP cache
|
||||||
|
3. Register `setDisplayMediaRequestHandler` for screen share
|
||||||
|
4. Register all IPC handlers
|
||||||
|
5. Create main window (with state restoration)
|
||||||
|
6. Create tray icon
|
||||||
|
7. Initialize auto-updater (10s delayed first check)
|
||||||
|
8. Start activity detection (immediate first poll, 15s interval, background remote sync)
|
||||||
|
9. Sync auto-launch settings with OS
|
||||||
|
10. Check for deep link in launch args
|
||||||
|
|
||||||
|
### Shutdown Sequence (`before-quit`)
|
||||||
|
|
||||||
|
1. Set `isQuitting = true` (allows window close to proceed)
|
||||||
|
2. Stop activity detection (clear interval, null callback)
|
||||||
|
3. Stop keybind manager (release active actions, stop uIOhook)
|
||||||
@@ -0,0 +1,661 @@
|
|||||||
|
# DM System
|
||||||
|
|
||||||
|
Source files:
|
||||||
|
- `packages/server/src/routes/dm.ts` -- REST endpoints for DM CRUD, group lifecycle, message send/edit/delete, federation event queueing, `broadcastDmMessage()` with soft-close reopen logic
|
||||||
|
- `packages/server/src/routes/federation.ts` -- Inbound relay event processors: `processMemberAddEvent`, `processMemberRemoveEvent`, `processOwnershipTransferEvent`, `processCreateEvent`, `processUpdateEvent`, `processDeleteEvent`, reaction processors, identity resolution (`resolveLocalUser`, `resolveOrCreateReplicatedUser`, `findOrCreateDmChannel`)
|
||||||
|
- `packages/server/src/utils/federationOutbox.ts` -- `queueOutboxEvent`, `appendMutationLog`, `queueDmRelay`, `getDmParticipants`, `getGroupDmTargetOrigins`, `computeFederatedId`, `buildRelayPayload`
|
||||||
|
- `packages/server/src/utils/storageJanitor.ts` -- `cleanupSoftDeletedDmChannels()` (24h grace period hard-delete)
|
||||||
|
- `packages/server/src/db/migrate.ts` -- Self-healing migration for corrupted group DM ownership
|
||||||
|
- `packages/server/src/ws/handler.ts` -- `sendToDmMembers()` broadcasts (ConnectionManager method)
|
||||||
|
- `packages/web/src/stores/spaceStore.ts` -- Zustand DM state: `addDmChannel`, `removeDmChannel`, `addDmMember`, `removeDmMember`, `updateDmOwner`, `closeDm`, `leaveDm`, `findExistingDmForUser`
|
||||||
|
- `packages/web/src/hooks/useWebSocket.ts` -- Frontend WS event handlers for `dm_channel_created`, `dm_channel_closed`, `dm_member_added`, `dm_member_removed`, `dm_owner_updated`
|
||||||
|
- `packages/web/src/components/modals/NewDmModal.tsx` -- 1-on-1 DM creation UI with user search and deduplication
|
||||||
|
- `packages/web/src/components/modals/AddDmMemberModal.tsx` -- Group DM member add / 1-on-1 upgrade UI
|
||||||
|
|
||||||
|
DB tables: `dm_channels`, `dm_members`, `dm_messages`, `dm_reactions`, `read_states`, `attachments`, `embeds`. See `docs/systems/database.md` for full schemas.
|
||||||
|
|
||||||
|
Related specs: `docs/systems/federation.md` (wire protocol, outbox worker, peer lifecycle), `docs/systems/websocket.md` (event wire formats), `docs/systems/voice.md` (DM call state machine).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Channel Type Identification
|
||||||
|
|
||||||
|
| Property | 1-on-1 DM | Group DM |
|
||||||
|
|----------|-----------|----------|
|
||||||
|
| `ownerId` | `NULL` | Creator's local user ID (never NULL) |
|
||||||
|
| `federatedId` format | 32-char hex (SHA-256 hash) | 36-char UUID (random) |
|
||||||
|
| Mutable membership | No (immutable pair) | Yes (owner adds, anyone leaves) |
|
||||||
|
| Max members | 2 | 10 |
|
||||||
|
| Friendship required | No | Yes (for new adds; exempt for existing DM members during 1-on-1 upgrade) |
|
||||||
|
| Soft-close | Yes (`closed=1` on dm_members) | Yes (same) |
|
||||||
|
| Leave | Not supported (use close) | Yes (DELETE `/api/dm/:id/members`) |
|
||||||
|
| Deletion | Never (1-on-1 DMs persist) | Soft-delete when last member leaves, hard-delete after 24h |
|
||||||
|
|
||||||
|
**Critical invariant:** `ownerId` must NEVER be set to NULL on a group DM. A NULL `ownerId` identifies the channel as 1-on-1 -- nulling it corrupts the channel's type identity and breaks membership logic.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Federated ID Algorithm
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// federationOutbox.ts:computeFederatedId()
|
||||||
|
|
||||||
|
// 1-on-1: deterministic SHA-256 hash of sorted home user IDs
|
||||||
|
// Same result on any instance for the same user pair
|
||||||
|
const sorted = [homeUserIdA, homeUserIdB].sort();
|
||||||
|
const federatedId = crypto.createHash('sha256')
|
||||||
|
.update(sorted.join(':'))
|
||||||
|
.digest('hex')
|
||||||
|
.slice(0, 32); // 32-char hex string
|
||||||
|
|
||||||
|
// Group: random UUID assigned by the creating instance
|
||||||
|
const federatedId = crypto.randomUUID(); // 36-char UUID with dashes
|
||||||
|
```
|
||||||
|
|
||||||
|
The format difference (32-char hex vs 36-char UUID with dashes) allows detecting channel type independently of `ownerId`. The self-healing migration uses this: `length(federated_id) = 36 AND federated_id LIKE '________-____-____-____-____________'` identifies group DMs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1-on-1 DM Creation
|
||||||
|
|
||||||
|
**Endpoint:** `POST /api/dm` -- `dm.ts:dmRoutes`
|
||||||
|
|
||||||
|
**Request:** `{ userId: string }`
|
||||||
|
|
||||||
|
**Deduplication algorithm:**
|
||||||
|
1. Query all `dm_members` rows where `userId = caller`
|
||||||
|
2. For each membership, check if `targetUserId` is also a member of that channel
|
||||||
|
3. If found, verify exactly 2 members in that channel (skip group DMs that happen to include the target)
|
||||||
|
4. If the channel exists and is not soft-deleted: reopen if caller had `closed=1`, return existing channel
|
||||||
|
5. If no match: create new channel atomically in a transaction
|
||||||
|
|
||||||
|
**Creation transaction:**
|
||||||
|
1. Insert `dm_channels` with `ownerId = NULL`, no `federatedId` (assigned lazily when federation relay first fires)
|
||||||
|
2. Insert two `dm_members` rows (caller + target)
|
||||||
|
|
||||||
|
**Post-creation:**
|
||||||
|
- Send `dm_channel_created` to the target user via WebSocket
|
||||||
|
- Return 201 with the `DmChannel` response to the caller
|
||||||
|
|
||||||
|
**No federation event queued at creation time.** The `federatedId` for 1-on-1 DMs is computed on demand when the first message is relayed via `queueDmRelay()`. The receiving instance uses `findOrCreateDmChannel()` which computes the deterministic hash and creates the channel if needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Group DM Creation
|
||||||
|
|
||||||
|
**Endpoint:** `POST /api/dm/group` -- `dm.ts:dmRoutes`
|
||||||
|
|
||||||
|
**Request:** `CreateGroupDmRequest`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface CreateGroupDmRequest {
|
||||||
|
users: GroupDmUserIdentity[]; // At least 2
|
||||||
|
fromDmChannelId?: string; // Source 1-on-1 DM for upgrade
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GroupDmUserIdentity {
|
||||||
|
id: string;
|
||||||
|
homeUserId?: string | null;
|
||||||
|
homeInstance?: string | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Validation:**
|
||||||
|
1. `users` array must have at least 2 entries (minimum 3 total members including caller)
|
||||||
|
2. Total members (1 + users.length) capped at 10
|
||||||
|
3. Each identity resolved to a local user row:
|
||||||
|
- If `homeUserId` + `homeInstance` provided: `resolveOrCreateReplicatedUser()`
|
||||||
|
- Else: direct ID lookup, falling back to `resolveLocalUser()` for remote snowflake IDs
|
||||||
|
4. No duplicate resolved IDs
|
||||||
|
5. Caller cannot include themselves
|
||||||
|
6. All target users must be friends with the caller (exception: existing DM members when `fromDmChannelId` references a 1-on-1 DM the caller belongs to)
|
||||||
|
|
||||||
|
**Creation transaction:**
|
||||||
|
1. Insert `dm_channels` with `ownerId = caller`
|
||||||
|
2. Insert `dm_members` for caller + all target users
|
||||||
|
|
||||||
|
**Post-creation federation setup:**
|
||||||
|
- If federation relay is enabled and any member has a remote `homeInstance`:
|
||||||
|
- Generate random UUID `federatedId` via `computeFederatedId()`
|
||||||
|
- Update channel with `federatedId`, `ownerHomeUserId`, `ownerHomeInstance`
|
||||||
|
|
||||||
|
**Broadcasting (local-only principle):**
|
||||||
|
- `dm_channel_created` sent only to members whose `homeInstance` matches this instance
|
||||||
|
- Remote members receive the channel via federation relay bootstrap on their home instance
|
||||||
|
|
||||||
|
**System messages:**
|
||||||
|
- One `member_added` system message per target user, inserted into `dm_messages`
|
||||||
|
- Broadcast only to local members (remote instances create their own system messages)
|
||||||
|
|
||||||
|
**Federation relay (for remote members):**
|
||||||
|
- For each target user with a remote `homeInstance`:
|
||||||
|
- Queue `member_add` event with full `group` roster (all participants)
|
||||||
|
- `targetOrigins` includes all participant home origins plus the new member's origin
|
||||||
|
- Event `messageId` format: `member_add:{userId}:{timestamp}`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Soft-Close and Reopen
|
||||||
|
|
||||||
|
### Close (Hide)
|
||||||
|
|
||||||
|
**Endpoint:** `DELETE /api/dm/:id` -- `dm.ts:dmRoutes`
|
||||||
|
|
||||||
|
1. Verify caller is a member
|
||||||
|
2. Set `dm_members.closed = 1` for the caller (preserves membership)
|
||||||
|
3. Send `dm_channel_closed` to the caller (multi-tab sync)
|
||||||
|
4. Channel disappears from the caller's sidebar but they remain a member
|
||||||
|
|
||||||
|
### Automatic Reopen
|
||||||
|
|
||||||
|
**Trigger:** `dm.ts:broadcastDmMessage()`
|
||||||
|
|
||||||
|
When a new message arrives in a DM channel, for each member with `closed = 1`:
|
||||||
|
1. Flip `closed` back to `0`
|
||||||
|
2. Send `dm_channel_created` with full channel payload (including the new message as `lastMessage`) so their sidebar picks it up
|
||||||
|
3. Then send the `dm_message_created` event
|
||||||
|
|
||||||
|
This ensures closed DMs resurface automatically when new activity occurs.
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
- `spaceStore.closeDm(id)` calls `api.dm.close(id)` then removes the channel from `dmChannels` state
|
||||||
|
- `dm_channel_closed` WS event calls `removeDmChannel(id)` which also cleans up unread/read state via `chatStore.removeChannelStates()`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Adding Members to an Existing Group DM
|
||||||
|
|
||||||
|
**Endpoint:** `POST /api/dm/:id/members` -- `dm.ts:dmRoutes`
|
||||||
|
|
||||||
|
**Request:** `{ userId: string }`
|
||||||
|
|
||||||
|
**Validation:**
|
||||||
|
1. Caller must be a member of the channel
|
||||||
|
2. Channel must be a group DM (`ownerId` is not NULL)
|
||||||
|
3. Caller must be the group owner (`dmChannel.ownerId === request.userId`)
|
||||||
|
4. Target user must exist
|
||||||
|
5. Caller and target must be friends
|
||||||
|
6. Target must not already be a member
|
||||||
|
7. Current member count must be < 10
|
||||||
|
|
||||||
|
**Lazy federation setup:**
|
||||||
|
- If the channel lacks a `federatedId` and the new member (or any existing member) is remote:
|
||||||
|
- Generate UUID `federatedId`, set `ownerHomeUserId` and `ownerHomeInstance`
|
||||||
|
|
||||||
|
**Broadcast sequence:**
|
||||||
|
1. `dm_member_added` to all existing members (before the new one sees it)
|
||||||
|
2. `dm_channel_created` to the new member (full channel payload)
|
||||||
|
3. System message (`member_added`) broadcast to all members via `sendToDmMembers`
|
||||||
|
|
||||||
|
**Federation relay:**
|
||||||
|
- Queue `member_add` with full `group` roster
|
||||||
|
- Target origins include the new member's home instance even if not previously in the group
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Leaving a Group DM
|
||||||
|
|
||||||
|
**Endpoint:** `DELETE /api/dm/:id/members` -- `dm.ts:dmRoutes`
|
||||||
|
|
||||||
|
**Preconditions:**
|
||||||
|
- Caller must be a member
|
||||||
|
- Channel must be a group DM (`ownerId` is not NULL; 1-on-1 DMs return 400)
|
||||||
|
|
||||||
|
**Sequence:**
|
||||||
|
|
||||||
|
1. If caller is in an active voice call in this DM, leave it first (auto-end call if room becomes empty)
|
||||||
|
2. Capture federation target origins BEFORE member deletion (so the leaving user's peer is included)
|
||||||
|
3. Insert `member_removed` system message (while user is still a member, so broadcast includes them)
|
||||||
|
4. Delete `dm_members` row
|
||||||
|
5. Delete `read_states` for the departing user
|
||||||
|
6. Queue `member_remove` federation event (reason: `'leave'`)
|
||||||
|
|
||||||
|
**Ownership transfer (if caller was owner and members remain):**
|
||||||
|
1. New owner = first remaining member (`remainingMembers[0]`)
|
||||||
|
2. Update `dm_channels.ownerId`
|
||||||
|
3. Broadcast `dm_owner_updated` to remaining members
|
||||||
|
4. Insert `owner_changed` system message
|
||||||
|
5. Update `ownerHomeUserId` / `ownerHomeInstance` on the channel
|
||||||
|
6. Queue `ownership_transfer` federation event
|
||||||
|
|
||||||
|
**Last member leaves:**
|
||||||
|
- Soft-delete: set `dm_channels.deletedAt = Date.now()`
|
||||||
|
- No ownership transfer (no remaining members)
|
||||||
|
- Storage janitor hard-deletes after 24-hour grace period
|
||||||
|
|
||||||
|
**Broadcast to leaving user:**
|
||||||
|
- `dm_channel_closed` event (removes from sidebar)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DM Deletion and Garbage Collection
|
||||||
|
|
||||||
|
### Soft-Delete Trigger
|
||||||
|
|
||||||
|
A channel is soft-deleted (`deletedAt` set) when:
|
||||||
|
- The last member leaves a group DM (`dm.ts` leave endpoint)
|
||||||
|
- The last local member is removed via federation relay (`federation.ts:processMemberRemoveEvent`)
|
||||||
|
|
||||||
|
### Hard-Delete (GC)
|
||||||
|
|
||||||
|
**Function:** `storageJanitor.ts:cleanupSoftDeletedDmChannels()`
|
||||||
|
|
||||||
|
**Grace period:** 24 hours from `deletedAt`
|
||||||
|
|
||||||
|
**Cascade (single transaction):**
|
||||||
|
1. Delete `dm_reactions` for all message IDs
|
||||||
|
2. Delete `embeds` for all message IDs
|
||||||
|
3. Delete `attachments` (DB rows) for all message IDs
|
||||||
|
4. Delete `federation_file_queue` entries for all message IDs
|
||||||
|
5. Delete `dm_messages`
|
||||||
|
6. Delete `dm_members` (should be 0, defensive)
|
||||||
|
7. Delete `read_states`
|
||||||
|
8. Delete `federation_outbox` entries (by `contextId`)
|
||||||
|
9. Delete `federation_mutation_log` entries (by `contextId`)
|
||||||
|
10. Delete the `dm_channels` row
|
||||||
|
|
||||||
|
**Post-transaction:** Delete attachment files from disk (filesystem ops are idempotent)
|
||||||
|
|
||||||
|
### Re-activation
|
||||||
|
|
||||||
|
If a `member_add` federation event arrives for a soft-deleted channel (non-null `deletedAt`), `processMemberAddEvent` cancels the soft-delete by setting `deletedAt = NULL`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Message Operations
|
||||||
|
|
||||||
|
### Send Message
|
||||||
|
|
||||||
|
**Endpoint:** `POST /api/dm/:id/messages` -- `dm.ts:dmRoutes`
|
||||||
|
|
||||||
|
**Rate limit:** 5 per 5 seconds per user
|
||||||
|
|
||||||
|
**Request:** `{ content?: string, attachments?: string[], replyToId?: string }`
|
||||||
|
|
||||||
|
**Validation:**
|
||||||
|
- Caller must be a member (`isDmMember`)
|
||||||
|
- Must have content or attachments (not both empty)
|
||||||
|
- Content max length: 4000 chars (`MAX_MESSAGE_LENGTH`)
|
||||||
|
- Attachment ownership verified (must be unlinked and owned by caller)
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
1. Insert message + link attachments in a single transaction
|
||||||
|
2. Hydrate full `DmMessageWithUser` via `getDmMessageWithUser()`
|
||||||
|
3. Broadcast via `broadcastDmMessage()` (handles soft-close reopen)
|
||||||
|
4. Queue federation relay via `queueDmRelay(message, channelId, 'create')`
|
||||||
|
5. Resolve embeds asynchronously via `setImmediate()`
|
||||||
|
|
||||||
|
### Edit Message
|
||||||
|
|
||||||
|
**Endpoint:** `PATCH /api/dm/messages/:id` -- `dm.ts:dmRoutes`
|
||||||
|
|
||||||
|
1. Author-only (`msg.userId !== request.userId` returns 403)
|
||||||
|
2. Update content and set `editedAt`
|
||||||
|
3. Delete old embeds, re-resolve new embeds asynchronously
|
||||||
|
4. Broadcast `dm_message_updated` to all members
|
||||||
|
5. Queue federation relay via `queueDmRelay(updated, channelId, 'update')`
|
||||||
|
|
||||||
|
### Delete Message
|
||||||
|
|
||||||
|
**Endpoint:** `DELETE /api/dm/messages/:id` -- `dm.ts:dmRoutes`
|
||||||
|
|
||||||
|
1. Author-only
|
||||||
|
2. Collect attachment filenames before deletion
|
||||||
|
3. Delete attachments, reactions, and message atomically in a transaction
|
||||||
|
4. Clean up files from disk
|
||||||
|
5. Broadcast `dm_message_deleted` to all members
|
||||||
|
6. Federation: `appendMutationLog()` + `queueOutboxEvent()` with `eventType='delete'`
|
||||||
|
|
||||||
|
**Note:** Delete federation events are queued without `targetOrigins` -- they broadcast to ALL active peers regardless of group membership. This differs from create/update which use `getGroupDmTargetOrigins()` for group DMs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Federation Relay Pipeline
|
||||||
|
|
||||||
|
This section covers the DM-specific application-level relay logic. For the wire protocol, outbox delivery, HMAC signing, and retry mechanics, see `docs/systems/federation.md`.
|
||||||
|
|
||||||
|
### Outbound: Target Origin Resolution
|
||||||
|
|
||||||
|
**Function:** `federationOutbox.ts:getGroupDmTargetOrigins()`
|
||||||
|
|
||||||
|
```
|
||||||
|
Channel has ownerId?
|
||||||
|
├── No (1-on-1) → return undefined → broadcasts to ALL active peers
|
||||||
|
└── Yes (group) → query all members' homeInstances
|
||||||
|
→ normalize bare domains to full URLs
|
||||||
|
→ filter out our own origin
|
||||||
|
→ return unique peer origins
|
||||||
|
```
|
||||||
|
|
||||||
|
**Function:** `federationOutbox.ts:queueDmRelay()`
|
||||||
|
|
||||||
|
Single source of truth for message relay payload construction:
|
||||||
|
1. Build attachment array with `sourceUrl` pointing to local uploads
|
||||||
|
2. Fetch `getDmParticipants()` for identity resolution on the receiving side
|
||||||
|
3. Fetch channel to check for `federatedId` (included only for group DMs with an owner)
|
||||||
|
4. Call `appendMutationLog()` + `queueOutboxEvent()` with the constructed payload
|
||||||
|
|
||||||
|
### Outbound: Relay Payload Structure
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// federationOutbox.ts:buildRelayPayload()
|
||||||
|
{
|
||||||
|
userId: localUser.id,
|
||||||
|
homeUserId: user.homeUserId || user.id,
|
||||||
|
homeInstance: user.homeInstance || getOurOrigin(),
|
||||||
|
content: message.content,
|
||||||
|
replyToId: message.replyToId ?? null,
|
||||||
|
editedAt: message.editedAt ?? null,
|
||||||
|
createdAt: message.createdAt,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The full event includes `participants` (all channel members with their federated identities and profile snapshots) and optionally `federatedId` (for group DMs).
|
||||||
|
|
||||||
|
### Inbound: Message Create
|
||||||
|
|
||||||
|
**Function:** `federation.ts:processCreateEvent()`
|
||||||
|
|
||||||
|
**Deduplication:** Check `sourceInstance` + `sourceMessageId` -- reject if already exists.
|
||||||
|
|
||||||
|
**Participant resolution:**
|
||||||
|
- ALL participants resolved via `resolveOrCreateReplicatedUser()` (auto-creates stubs for unknown remote users)
|
||||||
|
- Profile data from relay event hydrated onto replicated user stubs via `hydrateReplicatedUserProfile()`
|
||||||
|
|
||||||
|
**Channel resolution (group vs 1-on-1):**
|
||||||
|
|
||||||
|
| Has `federatedId`? | Path |
|
||||||
|
|---------------------|------|
|
||||||
|
| Yes (group DM) | Lookup by `federatedId`. If not found, reject (`channel_not_found`) -- channel must exist from prior `member_add` bootstrap |
|
||||||
|
| No (1-on-1 DM) | Compute deterministic `federatedId` from the two participants' home user IDs, then `findOrCreateDmChannel()` |
|
||||||
|
|
||||||
|
**`findOrCreateDmChannel()`:**
|
||||||
|
- Lookup by `federatedId`: if found, ensure both users are members (re-add if removed)
|
||||||
|
- If not found: create new channel with `ownerId = NULL` and the computed `federatedId`, add both users as members
|
||||||
|
|
||||||
|
**Attachment handling:**
|
||||||
|
- Attachment rows created immediately with `filename = sourceUrl` (remote URL)
|
||||||
|
- Frontend renders remote URLs directly when filename starts with `http`
|
||||||
|
- Background file worker downloads the file and updates the filename to the local path
|
||||||
|
- SSRF protection: `isUrlFromPeer()` validates attachment URL hostname matches peer origin
|
||||||
|
|
||||||
|
**Broadcast filtering:**
|
||||||
|
- Skip members whose `homeInstance === sourceInstance` (they already have the message from their home instance)
|
||||||
|
|
||||||
|
### Inbound: Message Update
|
||||||
|
|
||||||
|
**Function:** `federation.ts:processUpdateEvent()`
|
||||||
|
|
||||||
|
1. Find local message by `sourceInstance` + `sourceMessageId`
|
||||||
|
2. Update content and `editedAt`
|
||||||
|
3. Broadcast `dm_message_updated` to all local members
|
||||||
|
|
||||||
|
### Inbound: Message Delete
|
||||||
|
|
||||||
|
**Function:** `federation.ts:processDeleteEvent()`
|
||||||
|
|
||||||
|
1. Find local message by `sourceInstance` + `sourceMessageId`
|
||||||
|
2. Delete attachments, reactions, and message atomically
|
||||||
|
3. Clean up attachment files from disk
|
||||||
|
4. Broadcast `dm_message_deleted` to all local members
|
||||||
|
|
||||||
|
### Inbound: Reaction Add/Remove
|
||||||
|
|
||||||
|
**Functions:** `federation.ts:processReactionAddEvent()`, `processReactionRemoveEvent()`
|
||||||
|
|
||||||
|
- Uses `resolveLocalDmMessage()` for cross-instance message resolution (handles messages originating on this instance vs relayed messages)
|
||||||
|
- Reaction add is idempotent (existing reaction accepted silently)
|
||||||
|
- Broadcasts `reaction_added` / `reaction_removed` to local members
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Group DM Federation Lifecycle
|
||||||
|
|
||||||
|
### Bootstrap Path (Channel Does Not Exist Locally)
|
||||||
|
|
||||||
|
**Trigger:** `processMemberAddEvent()` receives a `member_add` event with `event.group` metadata for a `federatedId` not found locally.
|
||||||
|
|
||||||
|
**Sequence:**
|
||||||
|
1. Resolve owner via `resolveOrCreateReplicatedUser()` -- guaranteed non-null
|
||||||
|
2. Create `dm_channels` row with `ownerId`, `federatedId`, `ownerHomeUserId`, `ownerHomeInstance`
|
||||||
|
3. For each member in `event.group.members`: resolve via `resolveOrCreateReplicatedUser()`, insert `dm_members` (idempotent skip if already exists)
|
||||||
|
4. Set local `bootstrapped = true` flag
|
||||||
|
5. Build full `DmChannel` payload
|
||||||
|
6. Send `dm_channel_created` only to members whose home instance is THIS instance (local-only broadcast)
|
||||||
|
|
||||||
|
### Incremental Path (Channel Already Exists)
|
||||||
|
|
||||||
|
**Trigger:** `processMemberAddEvent()` finds the channel by `federatedId`.
|
||||||
|
|
||||||
|
**Sequence:**
|
||||||
|
1. Validate authority: `sourceInstance` must match `channel.ownerHomeInstance`
|
||||||
|
2. Cancel soft-delete if channel was pending GC (`deletedAt` set)
|
||||||
|
3. Resolve added user via `resolveOrCreateReplicatedUser()`
|
||||||
|
4. Enforce 10-member cap
|
||||||
|
5. Insert `dm_members` row (idempotent)
|
||||||
|
6. Insert system message for member addition
|
||||||
|
7. Broadcast `dm_message_created` (system) and `dm_member_added` to local members
|
||||||
|
|
||||||
|
### Bootstrap vs Incremental Batching
|
||||||
|
|
||||||
|
When a group DM is created with multiple remote members, the origin instance queues one `member_add` event per remote member. These events arrive in a batch on the receiving instance. Only the FIRST event triggers bootstrap (channel not found). Subsequent events find the channel and take the incremental path. This is correct because the bootstrap adds ALL roster members from `event.group.members`, making the incremental events idempotent.
|
||||||
|
|
||||||
|
### Member Remove (Inbound)
|
||||||
|
|
||||||
|
**Function:** `federation.ts:processMemberRemoveEvent()`
|
||||||
|
|
||||||
|
1. Find channel by `federatedId`. If not found, accept silently (idempotent).
|
||||||
|
2. Authority check: for kicks, `sourceInstance` must match `ownerHomeInstance`. For self-leave (`reason === 'leave'`), any instance is accepted.
|
||||||
|
3. Resolve user via `resolveLocalUser()` (they should already exist). If not found, accept silently.
|
||||||
|
4. Insert `member_removed` system message (before deletion so broadcast includes leaving user)
|
||||||
|
5. Delete `dm_members` row
|
||||||
|
6. Delete `read_states`
|
||||||
|
7. Broadcast `dm_member_removed` to remaining local members
|
||||||
|
8. If zero members remain: soft-delete channel
|
||||||
|
|
||||||
|
### Ownership Transfer (Inbound)
|
||||||
|
|
||||||
|
**Function:** `federation.ts:processOwnershipTransferEvent()`
|
||||||
|
|
||||||
|
1. Find channel by `federatedId`. If not found, accept silently.
|
||||||
|
2. Authority check: `sourceInstance` must match `channel.ownerHomeInstance`
|
||||||
|
3. Resolve new owner via `resolveOrCreateReplicatedUser()` -- **MUST guarantee non-null** (see invariant above)
|
||||||
|
4. Update `dm_channels`: `ownerId`, `ownerHomeUserId`, `ownerHomeInstance`
|
||||||
|
5. Broadcast `dm_owner_updated` to local members
|
||||||
|
6. Insert `owner_changed` system message
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## System Messages
|
||||||
|
|
||||||
|
System messages (`type = 'system'` in `dm_messages`) record group lifecycle events in the chat timeline.
|
||||||
|
|
||||||
|
### Event Types
|
||||||
|
|
||||||
|
| Event | Content JSON | Actor (`userId`) |
|
||||||
|
|-------|-------------|-----------------|
|
||||||
|
| `member_added` | `{ event, targetUserId, targetDisplayName }` | User who added them |
|
||||||
|
| `member_removed` | `{ event, targetUserId, targetDisplayName, reason }` | User who left/was removed |
|
||||||
|
| `owner_changed` | `{ event, newOwnerId, newOwnerDisplayName }` | Previous owner |
|
||||||
|
|
||||||
|
### Instance-Local Creation
|
||||||
|
|
||||||
|
System messages are NOT relayed via federation. Each instance creates its own independently:
|
||||||
|
|
||||||
|
- **Origin instance:** Creates in the REST endpoint, broadcasts to local members only (group DM creation) or all local members (incremental add/leave)
|
||||||
|
- **Receiving instance:** Creates in the federation event processor, broadcasts to local members
|
||||||
|
|
||||||
|
This avoids duplicate system messages for users connected to multiple instances.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Local-Only Broadcast Principle
|
||||||
|
|
||||||
|
Users connected to multiple instances must see each DM channel exactly once (from their home instance). `dm_channel_created` and system message broadcasts during group DM creation filter to local members:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const isLocalMember = (u: { homeInstance?: string | null }) =>
|
||||||
|
!u.homeInstance || !domainOrigin ||
|
||||||
|
u.homeInstance === domainOrigin ||
|
||||||
|
`https://${u.homeInstance}` === domainOrigin;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Applies to:**
|
||||||
|
- `dm_channel_created` broadcasts (both origin and receiving instance bootstrap)
|
||||||
|
- System message broadcasts during group DM creation (origin instance only)
|
||||||
|
|
||||||
|
**Does NOT apply to:**
|
||||||
|
- Regular DM messages (`dm_message_created` for user messages) -- these broadcast to all local `dm_members`
|
||||||
|
- `dm_member_added` / `dm_member_removed` / `dm_owner_updated` structural events
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend State Management
|
||||||
|
|
||||||
|
### Zustand Store (`spaceStore.ts`)
|
||||||
|
|
||||||
|
| Action | Behavior |
|
||||||
|
|--------|----------|
|
||||||
|
| `addDmChannel(channel, origin?)` | Prepends to `dmChannels`, deduplicates by ID, records origin in `channelOriginMap` |
|
||||||
|
| `removeDmChannel(id)` | Filters from `dmChannels`, cleans up unread/read state via `chatStore.removeChannelStates()` |
|
||||||
|
| `addDmMember(dmChannelId, user)` | Appends user to channel's `members` array (dedup by ID) |
|
||||||
|
| `removeDmMember(dmChannelId, userId)` | Filters user from channel's `members` array |
|
||||||
|
| `updateDmOwner(dmChannelId, newOwnerId)` | Updates `ownerId` on the channel |
|
||||||
|
| `closeDm(id)` | Calls `api.dm.close(id)` via origin-aware API client, removes from state |
|
||||||
|
| `leaveDm(id)` | Calls `api.dm.leave(id)` via origin-aware API client, removes from state |
|
||||||
|
| `findExistingDmForUser(targetUser)` | Scans `dmChannels` for a 2-member DM where the other member's `homeUserId` matches the target's `homeUserId` |
|
||||||
|
|
||||||
|
### WebSocket Event Handlers (`useWebSocket.ts`)
|
||||||
|
|
||||||
|
| WS Event | Handler |
|
||||||
|
|----------|---------|
|
||||||
|
| `dm_channel_created` | Normalize remote user assets, call `addDmChannel(channel, origin)` |
|
||||||
|
| `dm_channel_closed` | Call `removeDmChannel(dmChannelId)` |
|
||||||
|
| `dm_member_added` | Normalize remote user assets, call `addDmMember(dmChannelId, user)` |
|
||||||
|
| `dm_member_removed` | Call `removeDmMember(dmChannelId, userId)` |
|
||||||
|
| `dm_owner_updated` | Call `updateDmOwner(dmChannelId, newOwnerId)` |
|
||||||
|
|
||||||
|
### New DM Modal (`NewDmModal.tsx`)
|
||||||
|
|
||||||
|
1. User types a search query (min 2 chars, 300ms debounce)
|
||||||
|
2. Calls `api.social.search()` for user results
|
||||||
|
3. On user selection:
|
||||||
|
- Check `findExistingDmForUser()` for deduplication -- navigate to existing DM if found
|
||||||
|
- Otherwise call `api.dm.create({ userId })` via the origin-aware API client
|
||||||
|
- Add channel to state and navigate
|
||||||
|
|
||||||
|
### Add DM Member Modal (`AddDmMemberModal.tsx`)
|
||||||
|
|
||||||
|
- Shows the caller's friends list, filtered by search query
|
||||||
|
- Excludes current DM members (shown as "Already in this DM")
|
||||||
|
- Enforces 10-member cap in the UI (`remainingSlots` calculation)
|
||||||
|
- Two creation paths:
|
||||||
|
- **1-on-1 DM upgrade:** If `dmChannel.ownerId` is null, calls `api.dm.createGroup()` with the existing other member + selected friends + `fromDmChannelId`
|
||||||
|
- **Existing group DM:** Calls `api.dm.addMember()` sequentially for each selected friend
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Healing Migration
|
||||||
|
|
||||||
|
**Location:** `migrate.ts:runMigrations()`
|
||||||
|
|
||||||
|
**Detection:** Find `dm_channels` where:
|
||||||
|
- `owner_id IS NULL`
|
||||||
|
- `federated_id IS NOT NULL`
|
||||||
|
- `deleted_at IS NULL`
|
||||||
|
- `length(federated_id) = 36 AND federated_id LIKE '________-____-____-____-____________'` (UUID format = group DM)
|
||||||
|
|
||||||
|
**Repair:** Set `owner_id` to the first remaining `dm_members.user_id`.
|
||||||
|
|
||||||
|
**Root cause:** A bug in `processOwnershipTransferEvent` (fixed in commit cd7aff0) used `resolveLocalUser` with a `?? null` fallback. When resolution failed (even transiently), it set `ownerId = NULL`, converting the group DM into a 1-on-1-looking channel.
|
||||||
|
|
||||||
|
**Fix:** `processOwnershipTransferEvent` now uses `resolveOrCreateReplicatedUser()` which always returns a valid user, making null impossible.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Origin Normalization
|
||||||
|
|
||||||
|
**Critical pitfall** (origin format mismatch):
|
||||||
|
|
||||||
|
| Location | Format | Example |
|
||||||
|
|----------|--------|---------|
|
||||||
|
| `users.home_instance` | Bare domain | `nova.ddns.net` |
|
||||||
|
| `federation_peers.origin` | Full URL | `https://nova.ddns.net` |
|
||||||
|
| `getOurOrigin()` | Full URL | `https://orbit.ddns.net` |
|
||||||
|
|
||||||
|
When comparing home instances against peer origins, always normalize:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const normalized = homeInstance.startsWith('http')
|
||||||
|
? homeInstance
|
||||||
|
: `https://${homeInstance}`;
|
||||||
|
```
|
||||||
|
|
||||||
|
`getGroupDmTargetOrigins()` performs this normalization. Failure to normalize causes `queueOutboxEvent` to find zero matching peers and silently drop events.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### REST Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Auth | Purpose |
|
||||||
|
|--------|------|------|---------|
|
||||||
|
| `GET` | `/api/dm` | JWT | List caller's DM channels (excludes `closed=1` and `deleted_at` IS NOT NULL) |
|
||||||
|
| `POST` | `/api/dm` | JWT | Create or get existing 1-on-1 DM |
|
||||||
|
| `POST` | `/api/dm/group` | JWT | Create group DM with multiple members |
|
||||||
|
| `DELETE` | `/api/dm/:id` | JWT | Soft-close DM for caller |
|
||||||
|
| `POST` | `/api/dm/:id/members` | JWT | Add member to group DM (owner only) |
|
||||||
|
| `DELETE` | `/api/dm/:id/members` | JWT | Leave group DM |
|
||||||
|
| `GET` | `/api/dm/:id/messages` | JWT | Get messages with cursor pagination |
|
||||||
|
| `POST` | `/api/dm/:id/messages` | JWT | Send message (rate-limited: 5/5s) |
|
||||||
|
| `PATCH` | `/api/dm/messages/:id` | JWT | Edit message (author only) |
|
||||||
|
| `DELETE` | `/api/dm/messages/:id` | JWT | Delete message (author only) |
|
||||||
|
|
||||||
|
### Pagination
|
||||||
|
|
||||||
|
`GET /api/dm/:id/messages` supports cursor-based pagination:
|
||||||
|
- `before`: Message ID cursor (fetch messages before this ID)
|
||||||
|
- `limit`: 1-100, default 50
|
||||||
|
- Results returned in chronological order (oldest first)
|
||||||
|
|
||||||
|
### DM Channel List Sorting
|
||||||
|
|
||||||
|
`GET /api/dm` returns channels sorted by `lastMessage.createdAt` descending (newest activity first), falling back to `channel.createdAt` for channels with no messages.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## WebSocket Events
|
||||||
|
|
||||||
|
For full wire formats, see `docs/systems/websocket.md`.
|
||||||
|
|
||||||
|
### State-Change Events
|
||||||
|
|
||||||
|
| Event | Direction | Triggered By |
|
||||||
|
|-------|-----------|-------------|
|
||||||
|
| `dm_channel_created` | S->C | Group DM bootstrap, new 1-on-1, soft-close reopen |
|
||||||
|
| `dm_channel_closed` | S->C | User closes DM, user leaves group |
|
||||||
|
| `dm_member_added` | S->C | Incremental member add (not bootstrap) |
|
||||||
|
| `dm_member_removed` | S->C | Member leave/kick |
|
||||||
|
| `dm_owner_updated` | S->C | Ownership transfer |
|
||||||
|
|
||||||
|
### Content Events
|
||||||
|
|
||||||
|
| Event | Direction | Triggered By |
|
||||||
|
|-------|-----------|-------------|
|
||||||
|
| `dm_message_created` | S->C | New message (user or system) |
|
||||||
|
| `dm_message_updated` | S->C | Message edit |
|
||||||
|
| `dm_message_deleted` | S->C | Message delete |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Historical Bugs
|
||||||
|
|
||||||
|
| Bug | Symptom | Root Cause | Fix |
|
||||||
|
|-----|---------|-----------|-----|
|
||||||
|
| ownerId nulling | Group DM becomes 1-on-1 | `processOwnershipTransferEvent` used `resolveLocalUser ?? null` | Use `resolveOrCreateReplicatedUser` (always non-null) + self-healing migration |
|
||||||
|
| Origin normalization | Federation events silently dropped | `getGroupDmTargetOrigins` returned bare domains vs full URL peer origins | Normalize to full URL before comparison |
|
||||||
|
| Missing federatedId in outbox | All membership events rejected by peer | Outbox worker reconstruction omitted `federatedId` | Copy `parsed.federatedId` during reconstruction |
|
||||||
|
| Cross-instance duplicate channels | Duplicate sidebar entries | `dm_channel_created` broadcast to ALL members including remote | Local-only broadcast principle |
|
||||||
|
| Bootstrap vs incremental confusion | N/A (design note) | `bootstrapped` flag is function-local; batch events work correctly because bootstrap adds ALL roster members | No fix needed -- documented as correct behavior |
|
||||||
@@ -0,0 +1,460 @@
|
|||||||
|
# Embed & Link Preview System
|
||||||
|
|
||||||
|
Source files:
|
||||||
|
- `packages/server/src/utils/embedClassifier.ts` — URL classification and provider detection
|
||||||
|
- `packages/server/src/utils/embedResolver.ts` — URL extraction, embed resolution pipeline, DB persistence, batch fetching
|
||||||
|
- `packages/server/src/utils/metadataFetcher.ts` — OpenGraph/HTML metadata scraping with Cheerio
|
||||||
|
- `packages/server/src/utils/ssrf.ts` — SSRF protection (DNS resolution, private IP blocking)
|
||||||
|
- `packages/web/src/components/chat/EmbedRenderer.tsx` — Client-side embed routing by type
|
||||||
|
- `packages/web/src/components/chat/embeds/GenericEmbed.tsx` — Generic link preview card
|
||||||
|
- `packages/web/src/components/chat/embeds/ImageEmbed.tsx` — Direct image embed with lightbox
|
||||||
|
- `packages/web/src/components/chat/embeds/RichEmbed.tsx` — Rich iframe embed (Spotify)
|
||||||
|
- `packages/web/src/components/chat/embeds/VideoEmbed.tsx` — Video embed (YouTube, Vimeo, direct)
|
||||||
|
- `packages/shared/src/types.ts` — `Embed`, `EmbedType`, `EmbedProvider` type definitions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Type Definitions
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type EmbedType = 'generic' | 'video' | 'image' | 'audio' | 'rich';
|
||||||
|
type EmbedProvider = 'youtube' | 'vimeo' | 'spotify';
|
||||||
|
|
||||||
|
interface Embed {
|
||||||
|
id: string; // Snowflake
|
||||||
|
messageId: string | null; // FK -> messages.id (space messages)
|
||||||
|
dmMessageId: string | null; // FK -> dm_messages.id (DMs)
|
||||||
|
url: string; // Original URL from message content
|
||||||
|
embedType: EmbedType;
|
||||||
|
provider: EmbedProvider | null;
|
||||||
|
title: string | null;
|
||||||
|
description: string | null;
|
||||||
|
image: string | null; // Thumbnail / og:image URL
|
||||||
|
embedUrl: string | null; // iframe-safe embed URL
|
||||||
|
width: number | null; // Image/thumbnail pixel width
|
||||||
|
height: number | null; // Image/thumbnail pixel height
|
||||||
|
color: string | null; // Reserved, always null currently
|
||||||
|
createdAt: number; // Epoch ms
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
DB schema: see `embeds` table in [database.md](database.md). Constraint: exactly one of `messageId`/`dmMessageId` is set.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pipeline Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
Message created/edited
|
||||||
|
-> extractUrls(content) // regex, dedupe, limit 5
|
||||||
|
-> for each URL:
|
||||||
|
classifyUrl(url) // extension match or provider detection
|
||||||
|
fetchUrlMetadata(url) // if needsMetadataFetch (SSRF-validated)
|
||||||
|
probeRemoteImageDimensions // if image with unknown dimensions
|
||||||
|
INSERT into embeds table
|
||||||
|
-> broadcast embeds_resolved / dm_embeds_resolved via WebSocket
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. URL Extraction
|
||||||
|
|
||||||
|
`embedResolver.ts:extractUrls()`
|
||||||
|
|
||||||
|
**Regex:** `https?:\/\/[^\s<>"{}|\\^`[\]]+`
|
||||||
|
|
||||||
|
- Matches `http://` and `https://` URLs in message content
|
||||||
|
- Deduplicates while preserving order (first occurrence wins)
|
||||||
|
- **Limit:** 5 URLs per message (`MAX_EMBEDS_PER_MESSAGE = 5`)
|
||||||
|
- Returns empty array for null/empty content
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. URL Classification
|
||||||
|
|
||||||
|
`embedClassifier.ts:classifyUrl()`
|
||||||
|
|
||||||
|
Classification runs in two phases: extension matching (no URL parsing needed), then provider matching (requires valid `URL` object).
|
||||||
|
|
||||||
|
### Phase 1 — Direct Media Extensions
|
||||||
|
|
||||||
|
Regex-based, checked before URL parsing. These skip metadata fetch entirely.
|
||||||
|
|
||||||
|
| Pattern | EmbedType | Provider | needsMetadataFetch |
|
||||||
|
|---------|-----------|----------|--------------------|
|
||||||
|
| `.jpg`, `.jpeg`, `.png`, `.gif`, `.webp`, `.avif` | `image` | null | false |
|
||||||
|
| `.mp3`, `.ogg`, `.wav`, `.flac`, `.opus` | `audio` | null | false |
|
||||||
|
| `.mp4`, `.webm`, `.mov` | `video` | null | false |
|
||||||
|
|
||||||
|
Extension matching is case-insensitive and tolerates query strings (`(\?.*)?$`).
|
||||||
|
|
||||||
|
### Phase 2 — Provider Matching
|
||||||
|
|
||||||
|
Requires successful `new URL()` parsing. Hostname normalized by stripping `www.` prefix.
|
||||||
|
|
||||||
|
#### YouTube
|
||||||
|
|
||||||
|
Hosts: `youtube.com`, `m.youtube.com`, `youtu.be`
|
||||||
|
|
||||||
|
Supported URL patterns via `extractYouTubeId()`:
|
||||||
|
| Pattern | Example |
|
||||||
|
|---------|---------|
|
||||||
|
| `/watch?v=ID` | `youtube.com/watch?v=dQw4w9WgXcQ` |
|
||||||
|
| `/shorts/ID` | `youtube.com/shorts/dQw4w9WgXcQ` |
|
||||||
|
| `/embed/ID` | `youtube.com/embed/dQw4w9WgXcQ` |
|
||||||
|
| `/v/ID` (legacy) | `youtube.com/v/dQw4w9WgXcQ` |
|
||||||
|
| Short link | `youtu.be/dQw4w9WgXcQ` |
|
||||||
|
|
||||||
|
Video ID regex: `[A-Za-z0-9_-]+`
|
||||||
|
|
||||||
|
Result: `embedType: 'video'`, `provider: 'youtube'`, `embedUrl: https://www.youtube-nocookie.com/embed/{videoId}`, `needsMetadataFetch: true`
|
||||||
|
|
||||||
|
Privacy: Uses `youtube-nocookie.com` domain for embed iframes.
|
||||||
|
|
||||||
|
#### Vimeo
|
||||||
|
|
||||||
|
Host: `vimeo.com`
|
||||||
|
|
||||||
|
Pattern: `vimeo.com/{numericId}` (regex: `/^\/(\d+)/`)
|
||||||
|
|
||||||
|
Result: `embedType: 'video'`, `provider: 'vimeo'`, `embedUrl: https://player.vimeo.com/video/{id}`, `needsMetadataFetch: true`
|
||||||
|
|
||||||
|
#### Spotify
|
||||||
|
|
||||||
|
Host: `open.spotify.com`
|
||||||
|
|
||||||
|
Pattern: `open.spotify.com/{type}/{id}` where type is `track`, `album`, or `playlist`, id is `[A-Za-z0-9]+`
|
||||||
|
|
||||||
|
Result: `embedType: 'rich'`, `provider: 'spotify'`, `embedUrl: https://open.spotify.com/embed/{type}/{id}`, `needsMetadataFetch: true`
|
||||||
|
|
||||||
|
#### Fallthrough
|
||||||
|
|
||||||
|
Any URL that does not match a provider: `embedType: 'generic'`, `provider: null`, `embedUrl: null`, `needsMetadataFetch: true`
|
||||||
|
|
||||||
|
Invalid URLs (fail `new URL()` parsing): same as fallthrough.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. SSRF Protection
|
||||||
|
|
||||||
|
`ssrf.ts:validateExternalUrl()`
|
||||||
|
|
||||||
|
Called before every outbound fetch (metadata fetching and image dimension probing). Throws on any violation.
|
||||||
|
|
||||||
|
### Validation Steps
|
||||||
|
|
||||||
|
1. **URL parsing** — `new URL(url)` must succeed
|
||||||
|
2. **Scheme check** — only `http:` and `https:` allowed
|
||||||
|
3. **DNS resolution** — `dns.promises.lookup(hostname)` resolves hostname to IP
|
||||||
|
4. **Private IP check** — `isPrivateIp(address)` rejects internal addresses
|
||||||
|
|
||||||
|
### Blocked IP Ranges
|
||||||
|
|
||||||
|
`ssrf.ts:isPrivateIp()`
|
||||||
|
|
||||||
|
| Range | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `127.*` | Loopback |
|
||||||
|
| `0.*`, `0.0.0.0` | Unspecified |
|
||||||
|
| `10.*` | Private class A |
|
||||||
|
| `192.168.*` | Private class C |
|
||||||
|
| `172.16.0.0/12` | Private class B (172.16–172.31, checked via integer parse of second octet) |
|
||||||
|
| `169.254.*` | Link-local |
|
||||||
|
| `::1` | IPv6 loopback |
|
||||||
|
| `fc*`, `fd*` | IPv6 unique local |
|
||||||
|
| `fe80*` | IPv6 link-local |
|
||||||
|
|
||||||
|
### Redirect Handling
|
||||||
|
|
||||||
|
Both `fetchUrlMetadata` and `probeRemoteImageDimensions` use `redirect: 'follow'` in their `fetch()` calls. SSRF validation is performed on the **original** URL before fetch, but the native `fetch` follows redirects without re-validating intermediate URLs. This means a redirect from a public IP to a private IP would not be caught by the current implementation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Metadata Fetching
|
||||||
|
|
||||||
|
`metadataFetcher.ts:fetchUrlMetadata()`
|
||||||
|
|
||||||
|
### Flow
|
||||||
|
|
||||||
|
1. `validateExternalUrl(url)` — SSRF check, returns `null` on failure
|
||||||
|
2. `fetch(url)` with `User-Agent: BackspaceBot/1.0`, 5-second timeout via `AbortController`
|
||||||
|
3. **Content-Type detection** — if response is `image/*`, `video/*`, or `audio/*`, returns early with `contentType` field set (no HTML parsing)
|
||||||
|
4. **Size guard** — rejects responses with `Content-Length > 512KB`
|
||||||
|
5. **Stream-read with hard limit** — reads body via `ReadableStream`, stops at 512KB even for chunked (unknown-length) responses
|
||||||
|
6. **HTML parsing** via Cheerio
|
||||||
|
|
||||||
|
### Metadata Extraction
|
||||||
|
|
||||||
|
Parsed from HTML using Cheerio with the following priority:
|
||||||
|
|
||||||
|
| Field | Primary Source | Fallback |
|
||||||
|
|-------|---------------|----------|
|
||||||
|
| `title` | `og:title` | `<title>` element |
|
||||||
|
| `description` | `og:description` | `<meta name="description">` |
|
||||||
|
| `image` | `og:image` | none |
|
||||||
|
| `siteName` | `og:site_name` | none |
|
||||||
|
| `imageWidth` | `og:image:width` | none |
|
||||||
|
| `imageHeight` | `og:image:height` | none |
|
||||||
|
|
||||||
|
`imageWidth`/`imageHeight` are only included when they parse as finite positive integers.
|
||||||
|
|
||||||
|
### Return Type
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface UrlMetadata {
|
||||||
|
title: string | null;
|
||||||
|
description: string | null;
|
||||||
|
image: string | null;
|
||||||
|
siteName: string | null;
|
||||||
|
url: string;
|
||||||
|
contentType?: string; // Set only for direct media (image/video/audio)
|
||||||
|
imageWidth?: number; // From og:image:width (HTML pages only)
|
||||||
|
imageHeight?: number; // From og:image:height (HTML pages only)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Embed Resolution
|
||||||
|
|
||||||
|
`embedResolver.ts:resolveEmbeds()`
|
||||||
|
|
||||||
|
Main pipeline function. Iterates over extracted URLs, resolves each independently (one URL failure does not block others).
|
||||||
|
|
||||||
|
### Per-URL Resolution Logic
|
||||||
|
|
||||||
|
```
|
||||||
|
classify URL
|
||||||
|
|
|
||||||
|
|-- image (by extension)?
|
||||||
|
| -> set image = url, skip metadata fetch
|
||||||
|
|
|
||||||
|
|-- needsMetadataFetch?
|
||||||
|
| -> fetchUrlMetadata(url)
|
||||||
|
| -> if metadata.contentType is media -> override embedType, set image = url
|
||||||
|
| -> else -> extract title/description/image/dimensions from OG metadata
|
||||||
|
| -> if generic + no title -> skip URL (no embed created)
|
||||||
|
|
|
||||||
|
|-- YouTube provider?
|
||||||
|
| -> set fallback thumbnail: https://img.youtube.com/vi/{id}/hqdefault.jpg
|
||||||
|
| -> set fallback dimensions: 480x360
|
||||||
|
|
|
||||||
|
|-- image with unknown dimensions?
|
||||||
|
| -> probeRemoteImageDimensions(image)
|
||||||
|
|
|
||||||
|
-> INSERT embed row into DB
|
||||||
|
```
|
||||||
|
|
||||||
|
### Content-Type Override
|
||||||
|
|
||||||
|
When `fetchUrlMetadata` returns a `contentType` field (indicating the URL points directly to a media file rather than an HTML page), the classifier's `embedType` is overridden:
|
||||||
|
|
||||||
|
| Content-Type prefix | Override to |
|
||||||
|
|---------------------|-------------|
|
||||||
|
| `image/` | `image` (+ sets `image = url`) |
|
||||||
|
| `video/` | `video` |
|
||||||
|
| `audio/` | `audio` |
|
||||||
|
|
||||||
|
When Content-Type is detected as media, OG metadata fields (title, description, image) are ignored.
|
||||||
|
|
||||||
|
### YouTube Thumbnail Fallback
|
||||||
|
|
||||||
|
For YouTube URLs, before metadata fetch, a predictable thumbnail URL is pre-populated:
|
||||||
|
- URL: `https://img.youtube.com/vi/{videoId}/hqdefault.jpg`
|
||||||
|
- Dimensions: 480x360 (hardcoded, matches hqdefault.jpg)
|
||||||
|
|
||||||
|
If the metadata fetch returns an `og:image`, it does **not** override this fallback for `image` (the `||` operator means the pre-populated non-null value wins). However, OG dimensions would only populate `width`/`height` if they were still null (using `??`), so the hardcoded 480x360 persists.
|
||||||
|
|
||||||
|
### Generic Embed Skip Rule
|
||||||
|
|
||||||
|
If `effectiveEmbedType` remains `generic` after metadata fetch and no `title` was extracted, the URL is silently skipped — no embed row is created.
|
||||||
|
|
||||||
|
### DB Insertion
|
||||||
|
|
||||||
|
Each embed gets a unique Snowflake ID. The `messageId` / `dmMessageId` field is set based on the `isDm` flag. All inserts are synchronous (Drizzle `.run()`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Image Dimension Probing
|
||||||
|
|
||||||
|
`embedResolver.ts:probeRemoteImageDimensions()`
|
||||||
|
|
||||||
|
Used for direct image URLs where dimensions are unknown (not provided by OG tags or provider defaults).
|
||||||
|
|
||||||
|
### Mechanism
|
||||||
|
|
||||||
|
1. **SSRF validation** — `validateExternalUrl(url)`, returns `null` on block
|
||||||
|
2. **Range request** — fetches first 32KB (`PROBE_BYTES = 32_768`) with header `Range: bytes=0-32767`
|
||||||
|
3. **Timeout** — 3-second abort (`PROBE_TIMEOUT_MS = 3_000`)
|
||||||
|
4. **Graceful body read** — reads up to `PROBE_BYTES` via `ReadableStream`, then aggressively cancels the connection via `reader.cancel()`
|
||||||
|
5. **Dimension extraction** — passes buffer to `sharp(buffer).metadata()`, returns `{width, height}` if both are positive integers
|
||||||
|
|
||||||
|
### Headers
|
||||||
|
|
||||||
|
```
|
||||||
|
User-Agent: BackspaceBot/1.0
|
||||||
|
Accept: image/*
|
||||||
|
Range: bytes=0-32767
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response Handling
|
||||||
|
|
||||||
|
- Accepts HTTP `200` (server ignored Range) or `206` (partial content)
|
||||||
|
- Any other status returns `null`
|
||||||
|
- If the server returns more than 32KB (ignored Range header), only the first 32KB is read
|
||||||
|
- All failures (network, timeout, unrecognized format, SSRF) silently return `null`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. WebSocket Events
|
||||||
|
|
||||||
|
### Broadcast Delivery
|
||||||
|
|
||||||
|
After all embeds for a message are resolved, a single event is broadcast:
|
||||||
|
|
||||||
|
| Context | Event Type | Delivery | Fields |
|
||||||
|
|---------|-----------|----------|--------|
|
||||||
|
| Space channel | `embeds_resolved` | `connectionManager.sendToChannel(spaceId, channelId, ...)` | `messageId`, `channelId`, `embeds[]` |
|
||||||
|
| DM | `dm_embeds_resolved` | `connectionManager.sendToDmMembers(channelId, ...)` | `messageId`, `dmChannelId`, `embeds[]` |
|
||||||
|
|
||||||
|
If no embeds were resolved (all URLs skipped or failed), no event is broadcast.
|
||||||
|
|
||||||
|
### Client-Side Handling
|
||||||
|
|
||||||
|
`useWebSocket.ts` handles both events by patching the message in `useChatStore`:
|
||||||
|
1. Finds the message array for the channel/DM
|
||||||
|
2. Maps over messages, replacing the `embeds` array for the matching `messageId`
|
||||||
|
3. For federated contexts (`!isHome`): resolves relative image URLs via `resolveAssetUrl(embed.image, origin)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Edit Re-resolution
|
||||||
|
|
||||||
|
When a message is edited, embeds are re-resolved via a **delete-then-resolve** pattern (not using `reResolveEmbeds` — that function exists but is currently unused).
|
||||||
|
|
||||||
|
### Edit Flow (identical for REST and WebSocket handlers)
|
||||||
|
|
||||||
|
1. Update message content in DB
|
||||||
|
2. **Synchronous delete** — `DELETE FROM embeds WHERE messageId = ?` (or `dmMessageId`)
|
||||||
|
3. Broadcast `message_updated` / `dm_message_updated` with empty `embeds[]`
|
||||||
|
4. **Asynchronous re-resolve** — `setImmediate(() => resolveEmbeds(...).catch(() => {}))` runs the full pipeline
|
||||||
|
5. New embeds arrive via `embeds_resolved` / `dm_embeds_resolved` event
|
||||||
|
|
||||||
|
This two-phase approach ensures the edit broadcast is immediate (with stale embeds removed), while new embeds arrive shortly after via a separate event.
|
||||||
|
|
||||||
|
### Callsites
|
||||||
|
|
||||||
|
| Handler | File | Line |
|
||||||
|
|---------|------|------|
|
||||||
|
| REST `PATCH /api/messages/:id` | `routes/messages.ts` | Inline delete + `setImmediate(resolveEmbeds)` |
|
||||||
|
| WS `message_edit` | `ws/events.ts` | Inline delete + `setImmediate(resolveEmbeds)` |
|
||||||
|
| REST `PATCH /api/dm/messages/:id` | `routes/dm.ts` | Inline delete + `setImmediate(resolveEmbeds)` |
|
||||||
|
| WS `dm_message_edit` | `ws/events.ts` | Inline delete + `setImmediate(resolveEmbeds)` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Batch Fetching
|
||||||
|
|
||||||
|
Two functions load embeds for message lists (used when fetching message history):
|
||||||
|
|
||||||
|
| Function | Filters by | Returns |
|
||||||
|
|----------|-----------|---------|
|
||||||
|
| `fetchEmbedsForMessages(messageIds[])` | `embeds.messageId IN (...)` | `Map<messageId, embedRow[]>` |
|
||||||
|
| `fetchDmEmbedsForMessages(dmMessageIds[])` | `embeds.dmMessageId IN (...)` | `Map<dmMessageId, embedRow[]>` |
|
||||||
|
|
||||||
|
`embedRowToEmbed()` converts a DB row to the shared `Embed` type (maps nulls, casts enum strings).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Client Rendering
|
||||||
|
|
||||||
|
`EmbedRenderer` dispatches by `embed.embedType`:
|
||||||
|
|
||||||
|
```
|
||||||
|
EmbedRenderer
|
||||||
|
|-- 'video' -> VideoEmbed
|
||||||
|
|-- 'image' -> ImageEmbed
|
||||||
|
|-- 'audio' -> inline <audio> element
|
||||||
|
|-- 'rich' -> RichEmbed
|
||||||
|
|-- 'generic' -> GenericEmbed (default)
|
||||||
|
```
|
||||||
|
|
||||||
|
### VideoEmbed
|
||||||
|
|
||||||
|
Two modes based on whether a provider is present:
|
||||||
|
|
||||||
|
**Direct video** (`!provider && !embedUrl`):
|
||||||
|
- Renders `<video>` element with `controls`, `preload="none"`, 16:9 aspect ratio
|
||||||
|
|
||||||
|
**Provider iframe** (YouTube/Vimeo):
|
||||||
|
- Initial state: thumbnail image with play button overlay (glass-bubble style)
|
||||||
|
- On click: replaces with `<iframe>` loading `embedUrl?autoplay=1&origin={window.location.origin}`
|
||||||
|
- iframe permissions: `accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share`, `allowFullScreen`
|
||||||
|
- Footer shows capitalized provider name and linked title
|
||||||
|
|
||||||
|
### ImageEmbed
|
||||||
|
|
||||||
|
- Renders `<img>` with `max-w-[400px]`, `max-h-[300px]`, `loading="lazy"`, `referrerPolicy="no-referrer"`
|
||||||
|
- Image source: `embed.image ?? embed.url`
|
||||||
|
- Click opens image preview lightbox via `useUIStore.openImagePreview()`
|
||||||
|
|
||||||
|
### RichEmbed (Spotify)
|
||||||
|
|
||||||
|
Click-to-load pattern (no auto-loading of third-party iframes):
|
||||||
|
|
||||||
|
**Unloaded state** (default):
|
||||||
|
- Shows thumbnail, provider label, title, description, "Click to load" prompt
|
||||||
|
- Entire card is a `<button>` that triggers load
|
||||||
|
|
||||||
|
**Loaded state**:
|
||||||
|
- Renders `<iframe>` with `sandbox="allow-scripts allow-same-origin allow-popups"`
|
||||||
|
- iframe permissions: `autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture`
|
||||||
|
|
||||||
|
Height determination via `getIframeHeight()`:
|
||||||
|
1. `embed.height` if set
|
||||||
|
2. `PROVIDER_HEIGHTS[provider]` — currently only `spotify: 152`
|
||||||
|
3. Default: `200`
|
||||||
|
|
||||||
|
### GenericEmbed
|
||||||
|
|
||||||
|
- Returns `null` if no `embed.title` (no-op render)
|
||||||
|
- Shows provider name (from `embed.provider` or parsed hostname), linked title, description (3-line clamp)
|
||||||
|
- If `embed.image` exists: 80x80 thumbnail on the right side
|
||||||
|
|
||||||
|
### AudioEmbed (inline in EmbedRenderer)
|
||||||
|
|
||||||
|
- Shows linked title (if present) + native `<audio>` element with `controls`, `preload="metadata"`
|
||||||
|
- Max width 400px, left border accent
|
||||||
|
|
||||||
|
### Common Styling
|
||||||
|
|
||||||
|
All embed cards share:
|
||||||
|
- `max-w-[400px]` constraint
|
||||||
|
- `mt-2` top margin (spacing from message content)
|
||||||
|
- `bg-surface-channel` background (matte surface tier)
|
||||||
|
- `rounded-[4px]` or `rounded-lg` corners
|
||||||
|
- `referrerPolicy="no-referrer"` on all images
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Utility Endpoint
|
||||||
|
|
||||||
|
`GET /api/utils/metadata?url=` (auth required)
|
||||||
|
|
||||||
|
Exposes `fetchUrlMetadata()` directly as a REST endpoint. Returns the `UrlMetadata` object, or `{}` if fetch fails. This is independent of the embed pipeline and can be used for ad-hoc URL previews.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Constants Summary
|
||||||
|
|
||||||
|
| Constant | Value | Location |
|
||||||
|
|----------|-------|----------|
|
||||||
|
| `MAX_EMBEDS_PER_MESSAGE` | 5 | `embedResolver.ts` |
|
||||||
|
| `PROBE_BYTES` | 32,768 (32KB) | `embedResolver.ts` |
|
||||||
|
| `PROBE_TIMEOUT_MS` | 3,000ms | `embedResolver.ts` |
|
||||||
|
| Metadata fetch timeout | 5,000ms | `metadataFetcher.ts` |
|
||||||
|
| HTML body size limit | 512,000 bytes (512KB) | `metadataFetcher.ts` |
|
||||||
|
| User-Agent | `BackspaceBot/1.0` | both fetchers |
|
||||||
|
| Spotify iframe height | 152px | `RichEmbed.tsx` |
|
||||||
|
| Default rich iframe height | 200px | `RichEmbed.tsx` |
|
||||||
|
| YouTube thumbnail dimensions | 480x360 | `embedResolver.ts` |
|
||||||
@@ -0,0 +1,855 @@
|
|||||||
|
# Federation System
|
||||||
|
|
||||||
|
Source files:
|
||||||
|
- `packages/server/src/routes/federation.ts` -- API endpoints (peer handshake, relay, sync) + all inbound event processors + identity resolution functions
|
||||||
|
- `packages/server/src/utils/federationAuth.ts` -- HMAC signing, verification, header parsing, `getOurOrigin()`
|
||||||
|
- `packages/server/src/utils/federationOutbox.ts` -- Event queuing, coalescing, relay payload construction, mutation log, participant/target resolution
|
||||||
|
- `packages/server/src/utils/federationWorker.ts` -- Background workers: outbox delivery, file download, health check, janitor, initial sync
|
||||||
|
- `packages/server/src/utils/storageJanitor.ts` -- Federation GC: outbox expiry, mutation log retention, file queue cleanup, DM channel purge
|
||||||
|
- `packages/server/src/routes/social.ts` -- Friend request/accept/cancel/remove endpoints that queue federation events
|
||||||
|
- `packages/server/src/routes/dm.ts` -- DM REST endpoints that queue federation events (message relay, group lifecycle)
|
||||||
|
- `packages/server/src/ws/events.ts` -- WebSocket event handlers that queue DM message/reaction relay events
|
||||||
|
- `packages/web/src/utils/profileSync.ts` -- Client-side profile sync via LWW timestamps (not S2S relay)
|
||||||
|
- `packages/web/src/utils/identity.ts` -- Client-side federated identity resolution helpers
|
||||||
|
|
||||||
|
DB tables: `federation_peers`, `federation_outbox`, `federation_file_queue`, `federation_mutation_log`, plus `users` (identity), `dm_channels`/`dm_members`/`dm_messages` (DM federation), `friends`/`friend_requests` (friend federation), `attachments` (file replication).
|
||||||
|
See `docs/systems/database.md` for full schemas.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
Backspace federation is peer-to-peer with no central authority. Each instance maintains its own copy of all data. Peers exchange real-time events for DMs and friendships via a signed relay protocol.
|
||||||
|
|
||||||
|
**Canonical identity:** The `(homeUserId, homeInstance)` pair is globally unique. Local users have `homeInstance = NULL` and `homeUserId = NULL`. Federated users are represented as **replicated user stubs** -- minimal user records with `passwordHash = '!federation-replicated'` (bcrypt never produces this value, so login is impossible).
|
||||||
|
|
||||||
|
**Trust model:** Symmetric shared-secret HMAC. Both peers share the same 256-bit secret. Events are attributed to users by `homeUserId + homeInstance` in the payload, with authority checks verifying the source instance matches the claimed origin of the acting user.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Peer Handshake & Discovery
|
||||||
|
|
||||||
|
### 2-Phase Flow
|
||||||
|
|
||||||
|
**Phase 1 -- Initiate** (`POST /api/federation/peer/initiate`)
|
||||||
|
- Auth: JWT + admin role required
|
||||||
|
- Validates `remoteOrigin` is a well-formed HTTP(S) URL via `validateOrigin()`
|
||||||
|
- Prevents self-peering (`localOrigin === remoteOrigin`)
|
||||||
|
- Handles existing peers: active -> return 200, pending -> return 409, revoked -> delete and re-initiate
|
||||||
|
- Generates HMAC secret: `generateHmacSecret()` -> `randomBytes(32).toString('hex')` (256-bit)
|
||||||
|
- Generates challenge: `randomBytes(16).toString('hex')` (128-bit, currently unused by acceptor)
|
||||||
|
- Creates local peer record with `status='pending'`
|
||||||
|
- POSTs to `{remoteOrigin}/api/federation/peer/accept` with `{ sourceOrigin, challenge, hmacSecret }`
|
||||||
|
- Timeout: 10 seconds (`AbortSignal.timeout`)
|
||||||
|
- On remote acceptance: updates local peer to `status='active'`, sets `lastSeenAt`
|
||||||
|
- On failure: deletes pending peer, returns 502 (network error) or 504 (timeout)
|
||||||
|
|
||||||
|
**Phase 2 -- Accept** (`POST /api/federation/peer/accept`)
|
||||||
|
- Auth: **none** (first contact -- no JWT, no HMAC)
|
||||||
|
- Rate-limited: 10 requests per minute per IP (in-memory sliding window, buckets cleaned every 60s)
|
||||||
|
- Validates `sourceOrigin`, `challenge`, and `hmacSecret` from body
|
||||||
|
- Handles existing peers: active -> return 200 (idempotent), revoked -> return 403, pending -> update with new secret and activate
|
||||||
|
- New peer: creates record with provided `hmacSecret`, sets `status='active'`
|
||||||
|
- Returns `{ accepted: true }` on success
|
||||||
|
|
||||||
|
### Secret Storage
|
||||||
|
|
||||||
|
Both instances store the **same** HMAC secret. The initiating instance generates it and sends it in the accept request. There is no secret rotation mechanism -- the secret persists until the peer is revoked and re-initiated.
|
||||||
|
|
||||||
|
### Peer Status Lifecycle
|
||||||
|
|
||||||
|
```
|
||||||
|
initiate
|
||||||
|
(none) ──────────► pending ──────────► active
|
||||||
|
│
|
||||||
|
10+ consecutive │ delivery failures
|
||||||
|
failures ▼
|
||||||
|
unreachable
|
||||||
|
│
|
||||||
|
health check OK │
|
||||||
|
▼
|
||||||
|
active
|
||||||
|
│
|
||||||
|
admin revoke │
|
||||||
|
▼
|
||||||
|
revoked ──► (delete) ──► re-initiate
|
||||||
|
```
|
||||||
|
|
||||||
|
| Status | Outbox delivery | Health check | Relay accepts | Re-initiation |
|
||||||
|
|--------|----------------|--------------|---------------|---------------|
|
||||||
|
| `active` | Yes | No | Yes | No (returns existing) |
|
||||||
|
| `pending` | No | No | No | No (returns 409) |
|
||||||
|
| `unreachable` | No (entries wait) | Yes (1h interval) | Yes (resets to active) | No |
|
||||||
|
| `revoked` | No (entries purged) | No | No (returns 403) | Yes (old record deleted) |
|
||||||
|
|
||||||
|
### PEER_UNREACHABLE_THRESHOLD
|
||||||
|
|
||||||
|
Defined in `federationWorker.ts:45` as `10`. After 10 consecutive delivery failures for a peer, the worker sets `status = 'unreachable'`. The health check worker (1h interval) pings `GET /api/instance/info` on unreachable peers and reverts to `active` on success.
|
||||||
|
|
||||||
|
### Admin Endpoints
|
||||||
|
|
||||||
|
| Endpoint | Method | Auth | Purpose |
|
||||||
|
|----------|--------|------|---------|
|
||||||
|
| `/api/federation/peer/initiate` | POST | JWT + admin | Start peering handshake |
|
||||||
|
| `/api/federation/peer/accept` | POST | None (rate-limited) | Accept incoming handshake |
|
||||||
|
| `/api/federation/peers` | GET | JWT + admin | List all peers (secret excluded) |
|
||||||
|
| `/api/federation/peers/:id` | DELETE | JWT + admin | Revoke peer, purge outbox |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. HMAC Request Authentication
|
||||||
|
|
||||||
|
### Signing Format
|
||||||
|
|
||||||
|
```
|
||||||
|
HMAC-SHA256(secret, "${timestamp}.${requestBody}")
|
||||||
|
```
|
||||||
|
|
||||||
|
Where `timestamp` is `Date.now()` (Unix milliseconds) and `requestBody` is the JSON string.
|
||||||
|
|
||||||
|
### HTTP Headers
|
||||||
|
|
||||||
|
| Header | Format | Example |
|
||||||
|
|--------|--------|---------|
|
||||||
|
| `X-Federation-Signature` | `sha256=<hex>` | `sha256=a1b2c3...` |
|
||||||
|
| `X-Federation-Origin` | Full URL | `https://nova.ddns.net` |
|
||||||
|
| `X-Federation-Timestamp` | Unix ms string | `1711619400000` |
|
||||||
|
| `Content-Type` | `application/json` | -- |
|
||||||
|
|
||||||
|
### Verification (`federationAuth.ts:verifySignature`)
|
||||||
|
|
||||||
|
1. Validate inputs: reject empty/missing body, signature, or secret
|
||||||
|
2. **Timestamp window:** `Math.abs(Date.now() - timestamp) <= maxAgeMs` (default 15 minutes)
|
||||||
|
3. Recompute: `HMAC-SHA256(secret, "${timestamp}.${body}")`
|
||||||
|
4. **Constant-time comparison:** `crypto.timingSafeEqual` on hex-decoded buffers
|
||||||
|
5. Length check: mismatched buffer lengths are rejected before `timingSafeEqual`
|
||||||
|
|
||||||
|
### Replay Attack Prevention
|
||||||
|
|
||||||
|
The 15-minute timestamp window prevents replaying old requests. However, there is **no nonce or sequence number** -- a valid request can be replayed within the 15-minute window. See Known Issues.
|
||||||
|
|
||||||
|
### Inbound Verification Flow (`POST /api/federation/relay`)
|
||||||
|
|
||||||
|
1. `parseFederationHeaders()` extracts origin, timestamp, signature from headers
|
||||||
|
2. Look up peer by `origin` in `federation_peers` -- must exist and be `status = 'active'`
|
||||||
|
3. Re-serialize request body to JSON: `JSON.stringify(request.body)`
|
||||||
|
4. `verifySignature(bodyString, signature, peer.hmacSecret, timestamp)` -- reject if false
|
||||||
|
|
||||||
|
**Important:** The body is re-serialized server-side. This means Fastify's JSON parsing and re-stringification must produce identical output to the sender's `JSON.stringify`. In practice this works because both sides use standard `JSON.stringify` with no custom replacers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Identity Resolution
|
||||||
|
|
||||||
|
### Functions
|
||||||
|
|
||||||
|
**`resolveLocalUser(homeUserId, db)`** -- `federation.ts:878`
|
||||||
|
- Read-only lookup. Returns `undefined` if not found.
|
||||||
|
- Matches: `(users.homeUserId = homeUserId)` OR `(users.id = homeUserId AND homeInstance IS NULL)`
|
||||||
|
- Excludes deleted users (`isDeleted = 0`)
|
||||||
|
- When multiple candidates exist: prefers the one with `homeUserId` set (replicated stub) over a local ID match
|
||||||
|
- **Use when:** Optional lookups where null is acceptable (member_remove, reaction processing, friend_remove)
|
||||||
|
|
||||||
|
**`resolveOrCreateReplicatedUser(homeUserId, homeInstance, db)`** -- `federation.ts:912`
|
||||||
|
- Calls `resolveLocalUser` first. If found, returns it.
|
||||||
|
- If not found, creates a stub with:
|
||||||
|
- `username`: `{homeUserId}@{domain}` (domain extracted from homeInstance URL)
|
||||||
|
- `passwordHash`: `'!federation-replicated'`
|
||||||
|
- `homeInstance`: the full URL passed in
|
||||||
|
- `homeUserId`: the remote user's home ID
|
||||||
|
- Collision-safe: appends `_1`, `_2`, ..., `_10` suffix if username exists; after 10 attempts, uses `_<random hex>`
|
||||||
|
- **Use when:** You MUST have a valid user ID (setting `ownerId`, inserting `dm_members`, creating messages)
|
||||||
|
|
||||||
|
**`hydrateReplicatedUserProfile(user, profile, db)`** -- `federation.ts:2041`
|
||||||
|
- Updates replicated stubs only (`homeInstance` must be set)
|
||||||
|
- Only updates null/empty fields (preserves manually-set local values)
|
||||||
|
- Exception: avatar/banner are overwritten if the current value is a bare filename (not an absolute URL)
|
||||||
|
- Resolves bare filenames to `{homeInstance}/api/uploads/{filename}` absolute URLs
|
||||||
|
- Sets `displayName` from `profile.displayName || profile.username` -- ensures federated users show a human-readable name instead of `user@instance`
|
||||||
|
|
||||||
|
### Critical Rule
|
||||||
|
|
||||||
|
Any code path that sets `ownerId`, creates a `dm_members` row, or inserts a message MUST use `resolveOrCreateReplicatedUser`. Using `resolveLocalUser` with a `?? null` fallback has caused data corruption (see Known Issues: ownerId nulling).
|
||||||
|
|
||||||
|
### Origin Normalization
|
||||||
|
|
||||||
|
**Two formats exist in the database:**
|
||||||
|
|
||||||
|
| Location | Format | Example |
|
||||||
|
|----------|--------|---------|
|
||||||
|
| `users.home_instance` | Bare domain OR full URL | `nova.ddns.net` or `https://nova.ddns.net` |
|
||||||
|
| `federation_peers.origin` | Full URL | `https://nova.ddns.net` |
|
||||||
|
| `getOurOrigin()` return | Full URL | `https://orbit.ddns.net` |
|
||||||
|
| `resolveOrCreateReplicatedUser` stores | Full URL (passed through) | `https://nova.ddns.net` |
|
||||||
|
| Auth registration stores | Bare domain | `nova.ddns.net` |
|
||||||
|
|
||||||
|
The inconsistency exists because:
|
||||||
|
- `resolveOrCreateReplicatedUser` stores `homeInstance` as-is from the relay event (full URL)
|
||||||
|
- The auth registration path (`/api/auth/register` with `homeInstance` param) validates as bare domain only (regex: `/^[a-zA-Z0-9._-]+$/`)
|
||||||
|
- Relay event payloads populate `homeInstance` from `getOurOrigin()` (full URL) or from `user.homeInstance || getOurOrigin()` (which falls back to full URL)
|
||||||
|
|
||||||
|
**Normalization pattern used in code:**
|
||||||
|
```typescript
|
||||||
|
const normalized = homeInstance.startsWith('http') ? homeInstance : `https://${homeInstance}`;
|
||||||
|
```
|
||||||
|
|
||||||
|
Locations where normalization is applied:
|
||||||
|
- `getGroupDmTargetOrigins()` (`federationOutbox.ts:294`) -- normalizes before comparing to `ourOrigin`
|
||||||
|
- `dm.ts:655` -- `isLocalMember` broadcast filter checks both formats
|
||||||
|
- `dm.ts:743` -- normalizes target homeInstance before peer origin comparison
|
||||||
|
|
||||||
|
**Locations with potential mismatch (see Known Issues):**
|
||||||
|
- `federation.ts:1278` -- `memberUser?.homeInstance === sourceInstance` -- compares stored homeInstance (possibly bare domain) against `sourceInstance` (full URL from relay request header)
|
||||||
|
- `federationOutbox.ts:376-379` -- `getFriendEventTargets` compares `fromHomeInstance` against `ourOrigin` without normalization. The passed values come from `user.homeInstance || domainOrigin` where `domainOrigin = getOurOrigin()`. If `homeInstance` is a bare domain, `homeInstance !== ourOrigin` is true, so the bare domain gets added to targets, but `queueOutboxEvent` then fails to match it against `federation_peers.origin`
|
||||||
|
- `federationWorker.ts:424` -- `user.homeInstance === ourOrigin` in `handleSizeRejection`. Bare domain homeInstance won't match, potentially including a user in `affectedUserIds` who shouldn't be (minor).
|
||||||
|
- `federation.ts:2388` -- `from.homeInstance === ourOrigin` in `processFriendAddEvent` determines which user is "local" for broadcasting. The `from.homeInstance` comes from the relay event payload, which should be a full URL, so this comparison works correctly in practice.
|
||||||
|
- `federation.ts:2447` -- same pattern in `processFriendRemoveEvent`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. DM Message Relay
|
||||||
|
|
||||||
|
### 1-on-1 DMs
|
||||||
|
|
||||||
|
**Outbound (origin instance):**
|
||||||
|
1. Message created via REST (`POST /api/dm/:id/messages`) or WS (`dm_message_create`)
|
||||||
|
2. `queueDmRelay(message, channelId, 'create')` called from `dm.ts` / `events.ts`
|
||||||
|
3. `buildRelayPayload()` constructs the message portion with `homeUserId`, `homeInstance`, `content`, `replyToId`, `editedAt`, `createdAt`
|
||||||
|
4. `getDmParticipants(channelId)` resolves all members to `(homeUserId, homeInstance)` pairs with profile snapshots
|
||||||
|
5. `getGroupDmTargetOrigins(channelId)` returns `undefined` (no owner -> broadcast to all)
|
||||||
|
6. `queueOutboxEvent(messageId, channelId, 'create', payload, undefined)` -> queued to ALL active peers
|
||||||
|
|
||||||
|
**Inbound (receiving instance -- `processCreateEvent`):**
|
||||||
|
1. Validate: `event.message` and `event.participants` (>= 2) required
|
||||||
|
2. Dedup: check `(sourceInstance, sourceMessageId)` -- reject if exists
|
||||||
|
3. Resolve ALL participants via `resolveOrCreateReplicatedUser`, hydrate profiles
|
||||||
|
4. No `event.federatedId` -> 1-on-1 path
|
||||||
|
5. Compute deterministic `federatedId = SHA256(sorted([homeUserIdA, homeUserIdB])).slice(0, 32)`
|
||||||
|
6. `findOrCreateDmChannel(federatedId, [localUserA.id, localUserB.id], db)`:
|
||||||
|
- Find by `federatedId` in `dm_channels`
|
||||||
|
- If exists: ensure both users are members (idempotent insert)
|
||||||
|
- If not: create channel with `federatedId`, add both members
|
||||||
|
7. Insert `dm_messages` with `sourceInstance` and `sourceMessageId`
|
||||||
|
8. Process attachments (see File Replication)
|
||||||
|
9. Broadcast `dm_message_created` to local members, **skipping** members whose `homeInstance === sourceInstance` (they already have the original)
|
||||||
|
|
||||||
|
### Group DMs
|
||||||
|
|
||||||
|
**Outbound (origin instance):**
|
||||||
|
Same as 1-on-1 except:
|
||||||
|
- `getGroupDmTargetOrigins(channelId)` returns a list of peer origins that have at least one participant
|
||||||
|
- Normalizes `homeInstance` to full URL before comparison
|
||||||
|
- `queueOutboxEvent` receives `targetPeerOrigins` and only queues to those peers
|
||||||
|
- Payload includes `federatedId` (random UUID assigned at channel creation)
|
||||||
|
|
||||||
|
**Inbound (receiving instance -- `processCreateEvent`):**
|
||||||
|
1. `event.federatedId` present -> group DM path
|
||||||
|
2. Find channel by `federatedId` -- must already exist (bootstrapped by prior `member_add`)
|
||||||
|
3. If not found -> reject with `channel_not_found`
|
||||||
|
4. Insert message, broadcast to local members
|
||||||
|
|
||||||
|
### Federated ID Generation (`federationOutbox.ts:computeFederatedId`)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 1-on-1: deterministic 32-char hex hash
|
||||||
|
const sorted = [homeUserIdA, homeUserIdB].sort();
|
||||||
|
return sha256(sorted.join(':')).slice(0, 32);
|
||||||
|
|
||||||
|
// Group: random 36-char UUID with dashes
|
||||||
|
return crypto.randomUUID();
|
||||||
|
```
|
||||||
|
|
||||||
|
The format difference (32-char hash vs 36-char UUID) is used by the self-healing migration to detect channel type independently of `owner_id`.
|
||||||
|
|
||||||
|
### Message Deduplication
|
||||||
|
|
||||||
|
Every relayed message is stored with:
|
||||||
|
- `source_instance`: the relay request's `sourceInstance` header value
|
||||||
|
- `source_message_id`: the `event.messageId` (original message ID on source instance)
|
||||||
|
|
||||||
|
The `(source_instance, source_message_id)` pair is checked before insertion. Duplicates are rejected with reason `'duplicate'`. A unique partial index enforces this at the DB level: `idx_dm_messages_source_unique ON dm_messages(source_instance, source_message_id) WHERE source_instance IS NOT NULL`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Outbox & Relay Pipeline
|
||||||
|
|
||||||
|
### Event Queuing (`federationOutbox.ts:queueOutboxEvent`)
|
||||||
|
|
||||||
|
```
|
||||||
|
Trigger (API/WS handler)
|
||||||
|
-> isFederationRelayEnabled()? No -> return silently
|
||||||
|
-> Fetch active peers from federation_peers
|
||||||
|
-> Filter to targetPeerOrigins (if specified) -- EXACT string match against peer.origin
|
||||||
|
-> If zero peers match -> return silently (KNOWN ISSUE: silent event dropping)
|
||||||
|
-> For each peer, in a transaction:
|
||||||
|
-> Check for existing outbox entry by (peerId, entityId)
|
||||||
|
-> COALESCE:
|
||||||
|
- delete + existing create -> delete both (net: never relayed)
|
||||||
|
- update + existing create -> update payload, keep 'create' eventType
|
||||||
|
- update + existing update -> update payload and eventType
|
||||||
|
- no existing -> insert new entry
|
||||||
|
-> TTL: now + (relayTtlDays * 86400000)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Coalescing Rules (per-peer, per-entity)
|
||||||
|
|
||||||
|
| Incoming | Existing | Result |
|
||||||
|
|----------|----------|--------|
|
||||||
|
| `delete` | `create` | Entry removed (message was never relayed) |
|
||||||
|
| `update` | `create` | Payload updated, keeps `create` type (peer gets full message) |
|
||||||
|
| `update` | `update` | Payload updated, type becomes latest |
|
||||||
|
| `delete` | `update` | Payload updated, type becomes `delete` |
|
||||||
|
| any | none | New entry inserted |
|
||||||
|
|
||||||
|
### Outbox Delivery Worker (`federationWorker.ts:processOutboxTick`)
|
||||||
|
|
||||||
|
**Interval:** 10 seconds (`OUTBOX_INTERVAL_MS`)
|
||||||
|
**Batch size:** 50 (`OUTBOX_BATCH_LIMIT`)
|
||||||
|
**Timeout:** 30 seconds per request (`OUTBOX_FETCH_TIMEOUT_MS`)
|
||||||
|
|
||||||
|
1. Query entries where `nextRetryAt <= now` joined with active peers, ordered by `createdAt ASC`, limit 50
|
||||||
|
2. Group by peer
|
||||||
|
3. For each peer, reconstruct `FederationRelayEvent[]` from stored payloads:
|
||||||
|
- Parse JSON payload
|
||||||
|
- Copy fields: `federatedId`, `participants`, `message`, `reactions`, `reaction`, `membership`, `ownership`, `group`, `friendship`, file_rejected fields
|
||||||
|
- Set `eventType`, `contextType`, `messageId`, `dmChannelId`, `encryptionVersion`, `timestamp`
|
||||||
|
4. Build `FederationRelayRequest` with `version: 1`, `sourceInstance: ourOrigin`
|
||||||
|
5. Sign with `buildFederationHeaders(body, peerHmacSecret, ourOrigin)`
|
||||||
|
6. POST to `{peerOrigin}/api/federation/relay`
|
||||||
|
7. On success (200):
|
||||||
|
- Delete accepted entries from outbox (matched by `entityId` -> `outboxId`)
|
||||||
|
- Log rejected entries (remain in outbox for retry)
|
||||||
|
- Store `result.maxUploadSize` on peer record
|
||||||
|
- Update peer: `lastSeenAt = now`, `consecutiveFailures = 0`
|
||||||
|
8. On failure (non-200 or network error):
|
||||||
|
- `handleOutboxDeliveryFailure()`:
|
||||||
|
- Increment `attempts` per entry, compute `nextRetryAt = now + backoff`
|
||||||
|
- Increment peer `consecutiveFailures`, set `lastFailureAt`
|
||||||
|
- If `consecutiveFailures >= PEER_UNREACHABLE_THRESHOLD (10)` -> mark peer `unreachable`
|
||||||
|
|
||||||
|
### Retry Backoff Schedule
|
||||||
|
|
||||||
|
| Attempt | Delay |
|
||||||
|
|---------|-------|
|
||||||
|
| 1 | 30 seconds |
|
||||||
|
| 2 | 1 minute |
|
||||||
|
| 3 | 5 minutes |
|
||||||
|
| 4 | 15 minutes |
|
||||||
|
| 5 | 1 hour |
|
||||||
|
| 6 | 6 hours |
|
||||||
|
| 7+ | 24 hours (cap) |
|
||||||
|
|
||||||
|
### Relay Request/Response Format
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```typescript
|
||||||
|
interface FederationRelayRequest {
|
||||||
|
version: 1;
|
||||||
|
sourceInstance: string; // Full URL, e.g., "https://nova.ddns.net"
|
||||||
|
events: FederationRelayEvent[]; // Max 50 per batch
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```typescript
|
||||||
|
interface FederationRelayResponse {
|
||||||
|
accepted: string[]; // messageIds successfully processed
|
||||||
|
rejected: Array<{
|
||||||
|
messageId: string;
|
||||||
|
reason: string; // e.g., 'duplicate', 'unknown_message', 'missing_participants'
|
||||||
|
}>;
|
||||||
|
maxUploadSize: number; // This instance's max upload size in bytes
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Inbound Relay Dispatch (`POST /api/federation/relay`)
|
||||||
|
|
||||||
|
Body limit: 10 MB. Max 50 events per batch.
|
||||||
|
|
||||||
|
| eventType | Processor | contextType |
|
||||||
|
|-----------|-----------|-------------|
|
||||||
|
| `create` | `processCreateEvent` | dm |
|
||||||
|
| `update` | `processUpdateEvent` | dm |
|
||||||
|
| `delete` | `processDeleteEvent` | dm |
|
||||||
|
| `reaction_add` | `processReactionAddEvent` | dm |
|
||||||
|
| `reaction_remove` | `processReactionRemoveEvent` | dm |
|
||||||
|
| `member_add` | `processMemberAddEvent` | dm |
|
||||||
|
| `member_remove` | `processMemberRemoveEvent` | dm |
|
||||||
|
| `ownership_transfer` | `processOwnershipTransferEvent` | dm |
|
||||||
|
| `friend_request_create` | `processFriendRequestCreateEvent` | friend |
|
||||||
|
| `friend_request_update` | `processFriendRequestUpdateEvent` | friend |
|
||||||
|
| `friend_request_cancel` | `processFriendRequestCancelEvent` | friend |
|
||||||
|
| `friend_add` | `processFriendAddEvent` | friend |
|
||||||
|
| `friend_remove` | `processFriendRemoveEvent` | friend |
|
||||||
|
| `file_rejected` | `processFileRejectedEvent` | dm |
|
||||||
|
|
||||||
|
After processing all events, the relay endpoint updates the peer's `lastSeenAt` and resets `consecutiveFailures`, then returns accepted/rejected arrays plus `maxUploadSize`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Group DM Lifecycle over Federation
|
||||||
|
|
||||||
|
### member_add (`processMemberAddEvent` -- `federation.ts:1618`)
|
||||||
|
|
||||||
|
**Required fields:** `event.federatedId`, `event.membership.user`
|
||||||
|
|
||||||
|
**Two paths:**
|
||||||
|
|
||||||
|
**Bootstrap path** (channel does not exist locally by `federatedId`):
|
||||||
|
1. Requires `event.group` metadata (owner + full member roster)
|
||||||
|
2. Creates `dm_channels` row with `federatedId`, `ownerId` (resolved via `resolveOrCreateReplicatedUser`), `ownerHomeUserId`, `ownerHomeInstance`
|
||||||
|
3. Adds ALL roster members from `event.group.members` (each resolved via `resolveOrCreateReplicatedUser`)
|
||||||
|
4. Sends `dm_channel_created` to **local-only members** (home instance matches `getOurOrigin()`, with normalization for bare domain)
|
||||||
|
5. Sets `bootstrapped = true` to skip redundant system messages and member_add broadcasts below
|
||||||
|
|
||||||
|
**Incremental path** (channel already exists):
|
||||||
|
1. Validates authority: `sourceInstance === channel.ownerHomeInstance` (only owner's instance can add)
|
||||||
|
2. Cancels soft-delete if channel was pending GC
|
||||||
|
3. Resolves added user via `resolveOrCreateReplicatedUser`
|
||||||
|
4. Enforces max 10 members
|
||||||
|
5. Inserts `dm_members` row (idempotent -- skip if exists)
|
||||||
|
6. Inserts system message, broadcasts `dm_member_added` to local WebSocket clients
|
||||||
|
|
||||||
|
### member_remove (`processMemberRemoveEvent` -- `federation.ts:1825`)
|
||||||
|
|
||||||
|
1. Find channel by `federatedId` -- if not found, accept idempotently
|
||||||
|
2. Validate authority: owner's instance for kicks (`reason !== 'leave'`), any instance for self-leave
|
||||||
|
3. Resolve user via `resolveLocalUser` -- if not found, accept idempotently
|
||||||
|
4. Insert system message (before deletion, so broadcast includes the leaving user)
|
||||||
|
5. Delete `dm_members` row, clean up `read_states`
|
||||||
|
6. Broadcast `dm_member_removed` to remaining local members
|
||||||
|
7. If zero members remain -> soft-delete channel (`deletedAt = now`)
|
||||||
|
|
||||||
|
### ownership_transfer (`processOwnershipTransferEvent` -- `federation.ts:1938`)
|
||||||
|
|
||||||
|
1. Find channel by `federatedId` -- if not found, accept idempotently
|
||||||
|
2. Validate authority: `sourceInstance === channel.ownerHomeInstance`
|
||||||
|
3. Resolve new owner via `resolveOrCreateReplicatedUser` (**never** `resolveLocalUser` -- must guarantee valid ID)
|
||||||
|
4. Update `dm_channels`: `ownerId`, `ownerHomeUserId`, `ownerHomeInstance`
|
||||||
|
5. Broadcast `dm_owner_updated` WebSocket event
|
||||||
|
6. Insert system message with previous owner as actor
|
||||||
|
|
||||||
|
### Local-Only Broadcast Principle
|
||||||
|
|
||||||
|
Users connected to multiple instances must see each DM channel exactly once (from their home instance). All structural broadcasts (`dm_channel_created`, system messages) filter to **local members only**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const isLocalMember = (u: { homeInstance?: string | null }) =>
|
||||||
|
!u.homeInstance || !domainOrigin ||
|
||||||
|
u.homeInstance === domainOrigin ||
|
||||||
|
`https://${u.homeInstance}` === domainOrigin;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Does NOT apply to:** Regular DM messages (`dm_message_created` for user messages). These broadcast to all local `dm_members` regardless of home instance.
|
||||||
|
|
||||||
|
### System Messages
|
||||||
|
|
||||||
|
System messages (`type = 'system'` in `dm_messages`) are **instance-local** -- they are NOT relayed via federation. Each instance creates its own when processing events.
|
||||||
|
|
||||||
|
| Event | Content JSON | Actor (`userId`) |
|
||||||
|
|-------|-------------|-----------------|
|
||||||
|
| `member_added` | `{event, targetUserId, targetDisplayName}` | User who added them |
|
||||||
|
| `member_removed` | `{event, targetUserId, targetDisplayName, reason}` | User who left/was removed |
|
||||||
|
| `owner_changed` | `{event, newOwnerId, newOwnerDisplayName}` | Previous owner |
|
||||||
|
|
||||||
|
### Outbound Queuing (Origin Instance -- `dm.ts`)
|
||||||
|
|
||||||
|
When a group DM is created or modified locally, the origin instance queues federation events:
|
||||||
|
|
||||||
|
**Group DM creation** (`POST /api/dm/group`):
|
||||||
|
- Iterates each remote target user (those with `homeInstance !== domainOrigin`)
|
||||||
|
- Builds a `member_add` event per remote user, carrying the full roster in `event.group`
|
||||||
|
- Computes `finalTargets` by starting from `getGroupDmTargetOrigins()` and adding the new member's normalized homeInstance
|
||||||
|
- Calls `appendMutationLog` + `queueOutboxEvent` per event
|
||||||
|
|
||||||
|
**Add member to existing group** (`POST /api/dm/:id/members`):
|
||||||
|
- Same structure as creation -- builds `member_add` with full group metadata
|
||||||
|
- Normalizes new member's homeInstance to full URL before including in targets
|
||||||
|
|
||||||
|
**Leave group** (`DELETE /api/dm/:id/members`):
|
||||||
|
- Computes `fedTargetOrigins` **before** deleting the member (so the leaving user's peer is still included)
|
||||||
|
- Queues `member_remove` event with `reason: 'leave'`
|
||||||
|
|
||||||
|
**Ownership transfer** (`PATCH /api/dm/:id`):
|
||||||
|
- Queues `ownership_transfer` event with `previousOwner` and `newOwner`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. File Replication
|
||||||
|
|
||||||
|
### Outbound (origin instance)
|
||||||
|
|
||||||
|
When `queueDmRelay` constructs the relay payload, each attachment gets a `sourceUrl`:
|
||||||
|
```
|
||||||
|
sourceUrl: `${getOurOrigin()}/api/uploads/${attachment.filename}`
|
||||||
|
```
|
||||||
|
|
||||||
|
### Inbound (receiving instance -- `processCreateEvent`)
|
||||||
|
|
||||||
|
1. For each attachment in `event.message.attachments`:
|
||||||
|
- SSRF check: `isUrlFromPeer(sourceUrl, peerOrigin)` -- hostname of sourceUrl must match peer origin hostname
|
||||||
|
- Create `attachments` row with `filename = sourceUrl` (remote URL as interim filename)
|
||||||
|
- Queue `federation_file_queue` entry with `status = 'pending'`, `expiresAt = now + 30 days`
|
||||||
|
2. Initial WebSocket broadcast uses sourceUrl directly (frontend's `AttachmentRenderer` detects `http` prefix)
|
||||||
|
|
||||||
|
### File Download Worker (`federationWorker.ts:processFileQueueEntry`)
|
||||||
|
|
||||||
|
**Interval:** 30 seconds. **Batch:** 5 files. **Timeout:** 60 seconds per download.
|
||||||
|
|
||||||
|
1. SSRF protection: validate sourceUrl hostname matches peerOrigin hostname
|
||||||
|
2. Pre-download size check against `maxUploadSizeBytes` from instance settings
|
||||||
|
3. Download via `fetch` with streaming pipeline to disk (`Readable.fromWeb` -> `fs.createWriteStream`)
|
||||||
|
4. Post-download size verification (defense in depth)
|
||||||
|
5. Generate thumbnail via `sharp` (same as local upload flow)
|
||||||
|
6. Update `attachments` row: `filename = localFilename`, `size`, `thumbnailFilename`
|
||||||
|
7. Fallback: if no existing attachment row was found (legacy queue entry), insert a new one
|
||||||
|
8. Mark file queue entry as `completed` with `targetFilename`
|
||||||
|
9. Broadcast `dm_message_updated` to refresh client-side attachment display
|
||||||
|
|
||||||
|
### Size Rejection Flow (`handleSizeRejection`)
|
||||||
|
|
||||||
|
When a file exceeds the local instance's size limit:
|
||||||
|
|
||||||
|
1. Mark file queue entry as `rejected` with `reason = 'size_limit_exceeded'`
|
||||||
|
2. Update local attachment: `federationStatus = 'remote'`, `federationMeta` = source info JSON
|
||||||
|
3. Determine affected local users (native to this instance -- `!user.homeInstance || user.homeInstance === ourOrigin`)
|
||||||
|
4. Queue `file_rejected` reverse relay event to the sender's instance (`sourceInstance`)
|
||||||
|
5. Broadcast `dm_message_updated` locally so clients see the 'remote' badge
|
||||||
|
|
||||||
|
### Inbound file_rejected (`processFileRejectedEvent` -- `federation.ts:2458`)
|
||||||
|
|
||||||
|
When the origin instance receives a `file_rejected` event:
|
||||||
|
|
||||||
|
1. Find local message by `event.messageId` (the original local message ID)
|
||||||
|
2. Match attachment by `sourceFilename` or fallback to single attachment
|
||||||
|
3. Resolve `affectedUserIds` (homeUserIds) to local replicated user stubs
|
||||||
|
4. Merge rejection info into `federationMeta` (accumulates from multiple peers)
|
||||||
|
5. Set `federationStatus = 'remote_partial'`
|
||||||
|
6. Broadcast `dm_message_updated` + targeted `federation_file_rejected` toast to message author
|
||||||
|
|
||||||
|
### Federation Status on Attachments
|
||||||
|
|
||||||
|
| Status | Meaning |
|
||||||
|
|--------|---------|
|
||||||
|
| `null` | Local upload, no federation involvement |
|
||||||
|
| `'local'` | Successfully downloaded from peer |
|
||||||
|
| `'remote'` | Rejected (size limit), `federationMeta` has source instance info |
|
||||||
|
| `'remote_partial'` | Rejected by some peers, `federationMeta` has per-user rejection array |
|
||||||
|
|
||||||
|
### File Download Retry
|
||||||
|
|
||||||
|
Uses the same backoff schedule as outbox delivery. Max attempts: 10 (`MAX_FILE_ATTEMPTS`). After exceeding max attempts: `status = 'failed'`, `rejectionReason = 'max_attempts_exceeded'`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Friend Relay
|
||||||
|
|
||||||
|
### Event Flow (social.ts)
|
||||||
|
|
||||||
|
| User Action | Federation Event | Authority Check |
|
||||||
|
|-------------|-----------------|-----------------|
|
||||||
|
| Send friend request | `friend_request_create` | `from.homeInstance === sourceInstance` |
|
||||||
|
| Accept/decline request | `friend_request_update` | `to.homeInstance === sourceInstance` |
|
||||||
|
| Cancel outgoing request | `friend_request_cancel` | `from.homeInstance === sourceInstance` |
|
||||||
|
| Accept creates friendship | `friend_add` | `to.homeInstance === sourceInstance` |
|
||||||
|
| Remove friend | `friend_remove` | Either side's instance |
|
||||||
|
|
||||||
|
### Target Resolution (`getFriendEventTargets`)
|
||||||
|
|
||||||
|
Computes which peer origins need the event. Compares `fromHomeInstance` and `toHomeInstance` against `getOurOrigin()`. **Known issue:** no normalization -- bare domain homeInstance will not match full URL `ourOrigin`, causing the bare domain to be passed as a target. However, `queueOutboxEvent` then fails to match it against `federation_peers.origin` (full URL), silently dropping the event.
|
||||||
|
|
||||||
|
### Context ID
|
||||||
|
|
||||||
|
Friend events use a deterministic context ID: `friend:${sorted[homeUserIdA, homeUserIdB].join(':')}`.
|
||||||
|
|
||||||
|
### Outbound Payload Construction
|
||||||
|
|
||||||
|
Each friend endpoint builds a `FederationRelayEvent` with:
|
||||||
|
- `contextType: 'friend'`
|
||||||
|
- `friendship` payload containing `from` and `to` as `FederationRelayParticipant` objects
|
||||||
|
- `fromProfile` and/or `toProfile` snapshots (`FederationRelayProfileSnapshot`)
|
||||||
|
- `entityId` formatted as `friend_req:${sorted_ids}:${timestamp}` (for requests) or `friend_remove:${sorted_ids}:${timestamp}`
|
||||||
|
|
||||||
|
The full event payload is stored in both `appendMutationLog` (for sync) and `queueOutboxEvent` (for delivery).
|
||||||
|
|
||||||
|
### Inbound Processing
|
||||||
|
|
||||||
|
**`processFriendRequestCreateEvent` (`federation.ts:2082`):**
|
||||||
|
- Authority check: `from.homeInstance !== sourceInstance` -> reject
|
||||||
|
- Resolve sender via `resolveOrCreateReplicatedUser` + hydrate profile
|
||||||
|
- Resolve recipient via `resolveLocalUser` (must be native to this instance)
|
||||||
|
- Idempotency: if already friends or pending request exists, accept as no-op
|
||||||
|
- Create `friend_requests` row, broadcast `friend_request_received` to recipient
|
||||||
|
|
||||||
|
**`processFriendRequestUpdateEvent` (`federation.ts:2178`):**
|
||||||
|
- Authority check: `to.homeInstance !== sourceInstance` -> reject
|
||||||
|
- Resolve sender (original requester) via `resolveLocalUser` (must exist locally)
|
||||||
|
- Resolve recipient (acceptor/decliner) via `resolveOrCreateReplicatedUser`
|
||||||
|
- Find pending request, update status
|
||||||
|
- Broadcast `friend_request_accepted` or `friend_request_declined` to the original sender
|
||||||
|
|
||||||
|
**`processFriendRequestCancelEvent` (`federation.ts:2254`):**
|
||||||
|
- Authority check: `from.homeInstance !== sourceInstance` -> reject
|
||||||
|
- Both users must exist locally. If not, accept idempotently.
|
||||||
|
- Delete the pending friend request. Broadcast `friend_request_cancelled` to recipient.
|
||||||
|
|
||||||
|
**`processFriendAddEvent` (`federation.ts:2318`):**
|
||||||
|
- Authority check: `to.homeInstance !== sourceInstance` -> reject
|
||||||
|
- Resolve both users via `resolveOrCreateReplicatedUser` + hydrate profiles
|
||||||
|
- Insert `friends` row (idempotent)
|
||||||
|
- Auto-resolve any pending `friend_requests` to `'accepted'` (handles out-of-order delivery)
|
||||||
|
- Determine which user is local (`from.homeInstance === ourOrigin`) and broadcast `friend_request_accepted`
|
||||||
|
|
||||||
|
**`processFriendRemoveEvent` (`federation.ts:2404`):**
|
||||||
|
- Authority check: either `from.homeInstance` or `to.homeInstance` must be `sourceInstance`
|
||||||
|
- Both users resolved via `resolveLocalUser`. If not found, accept idempotently.
|
||||||
|
- Delete `friends` row in both directions
|
||||||
|
- Determine local user (whose `homeInstance` is NOT the source) and broadcast `friend_removed`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Profile Sync
|
||||||
|
|
||||||
|
Profile sync uses **two mechanisms** that operate independently:
|
||||||
|
|
||||||
|
### S2S Profile Hydration (Server-side)
|
||||||
|
|
||||||
|
When relay events carry `FederationRelayProfileSnapshot` data:
|
||||||
|
- `processCreateEvent`: hydrates participant profiles on message relay
|
||||||
|
- `processFriendRequestCreateEvent` / `processFriendAddEvent`: hydrates friend profiles
|
||||||
|
|
||||||
|
`hydrateReplicatedUserProfile` only fills null/empty fields. Avatar/banner are overwritten only if the current value is not an absolute URL (catches stale bare filenames).
|
||||||
|
|
||||||
|
### Client-side LWW Sync (`profileSync.ts`)
|
||||||
|
|
||||||
|
Operates via the web client, not S2S relay:
|
||||||
|
- On connect to a remote instance, compares `profileUpdatedAt` timestamps
|
||||||
|
- If home is newer: pushes profile to remote (re-uploads avatar/banner)
|
||||||
|
- If remote is newer: pulls from remote to home, then relays to all other remotes
|
||||||
|
- Incremental: `syncProfileUpdateToRemotes` pushes partial updates after local profile edits
|
||||||
|
|
||||||
|
This is a **client-driven** mechanism -- it only runs when a user is actively connected to multiple instances. It does not use the relay pipeline or outbox.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Reaction Relay
|
||||||
|
|
||||||
|
### Outbound
|
||||||
|
|
||||||
|
Reactions are queued by WS event handlers in `events.ts`:
|
||||||
|
- `dm_reaction_add` -> `queueOutboxEvent(reactionId, channelId, 'reaction_add', payload, targetOrigins)`
|
||||||
|
- `dm_reaction_remove` -> `queueOutboxEvent(messageId, channelId, 'reaction_remove', payload, targetOrigins)`
|
||||||
|
|
||||||
|
Payload includes `userId`, `homeUserId`, `emoji`, `createdAt`, plus `messageId` and `messageHomeInstance` for cross-instance message resolution.
|
||||||
|
|
||||||
|
The mutation log entry for reactions stores a simpler payload (no `messageId`/`messageHomeInstance`), while the outbox entry carries the full reaction payload including those fields.
|
||||||
|
|
||||||
|
### Inbound
|
||||||
|
|
||||||
|
**`processReactionAddEvent` (`federation.ts:1480`):**
|
||||||
|
1. Resolve message via `resolveLocalDmMessage(canonicalMessageId, messageHomeInstance, sourceInstance, db)`:
|
||||||
|
- If `messageHomeInstance === getOurOrigin()` -> find by local ID (the message originated here)
|
||||||
|
- Otherwise -> find by `(messageHomeInstance || sourceInstance, canonicalMessageId)` tracking -- uses `messageHomeInstance` when available (correct origin in 3-instance relay), falls back to `sourceInstance`
|
||||||
|
2. Resolve reacting user via `resolveLocalUser` (must already exist)
|
||||||
|
3. Dedup: check existing reaction by `(dmMessageId, userId, emoji)`
|
||||||
|
4. Insert `dm_reactions`, broadcast `reaction_added` to local clients
|
||||||
|
|
||||||
|
**`processReactionRemoveEvent` (`federation.ts:1561`):**
|
||||||
|
- Same resolution logic
|
||||||
|
- Delete matching reaction, broadcast `reaction_removed` if changes > 0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Initial Sync
|
||||||
|
|
||||||
|
### `runInitialSyncForNewPeers()` (`federationWorker.ts:739`)
|
||||||
|
|
||||||
|
Triggered once at server startup (async, non-blocking). Finds peers with `status = 'active'` and `lastSyncedAt = 0`.
|
||||||
|
|
||||||
|
**For each unsynced peer:**
|
||||||
|
1. **DM sync pass:** Paginate through `POST {peerOrigin}/api/federation/sync` with `sinceTimestamp = 0`, `limit = 100`
|
||||||
|
2. **Self-POST:** Relay received events by POSTing to `{ourOrigin}/api/federation/relay` -- this routes through the standard inbound processing
|
||||||
|
3. **Friend sync pass:** Same pagination with `contextType: 'friend'`
|
||||||
|
4. Update `lastSyncedAt = Date.now()` after completion
|
||||||
|
5. On failure: don't update `lastSyncedAt` -- retried on next startup
|
||||||
|
|
||||||
|
### Sync Endpoint (`POST /api/federation/sync`)
|
||||||
|
|
||||||
|
HMAC-authenticated. Returns events from the `federation_mutation_log`.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```typescript
|
||||||
|
{ sinceTimestamp: number, dmChannelId?: string, federatedId?: string, contextType?: 'dm'|'friend', limit?: 1-500 }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```typescript
|
||||||
|
{ events: FederationRelayEvent[], hasMore: boolean, checkpoint: number }
|
||||||
|
```
|
||||||
|
|
||||||
|
**DM sync:**
|
||||||
|
- Queries all `dm_channels` with non-null `federated_id` (not soft-deleted)
|
||||||
|
- Joins `federation_mutation_log` with `dm_messages` to reconstruct events
|
||||||
|
- Only returns locally-created messages (`source_instance IS NULL` via the LEFT JOIN)
|
||||||
|
- Handles delete mutations separately (message rows don't exist for deletes)
|
||||||
|
- For create/update: fetches current message state from DB, builds full relay event with attachments and participants
|
||||||
|
- Membership/friend mutations store the full event payload in the mutation log, so they are returned directly
|
||||||
|
|
||||||
|
**Friend sync:**
|
||||||
|
- Queries `federation_mutation_log WHERE context_type = 'friend'`
|
||||||
|
- Returns stored payloads directly (friend events carry their complete data)
|
||||||
|
|
||||||
|
### Known Bug: DNS Hairpin Self-POST
|
||||||
|
|
||||||
|
`runInitialSyncForNewPeers` POSTs to `{ourOrigin}/api/federation/relay` where `ourOrigin = getOurOrigin()`. In production, `ourOrigin` is `https://{DOMAIN}`, e.g., `https://nova.ddns.net`. This means the server makes an HTTP request to itself through the public DNS and reverse proxy (Caddy). This works but:
|
||||||
|
- Adds unnecessary network round-trip latency
|
||||||
|
- Fails if DNS hairpin is not supported by the network
|
||||||
|
- Fails if the server is behind NAT without hairpin NAT configured
|
||||||
|
|
||||||
|
A direct function call to the relay processing logic would be more robust.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. DM Calls over Federation
|
||||||
|
|
||||||
|
DM calls use LiveKit for WebRTC signaling and media transport. The call lifecycle is managed entirely via WebSocket events (`dm_call_start`, `dm_call_accept`, `dm_call_reject`, `dm_call_end` in `ws/events.ts`).
|
||||||
|
|
||||||
|
**Current state: DM calls do NOT work across federated instances.**
|
||||||
|
|
||||||
|
The call state machine is local to a single server instance -- there is no federation relay for call events. When user A on instance 1 calls user B on instance 2:
|
||||||
|
- The `dm_call_incoming` event is sent via `connectionManager.sendToUser(targetUser.id, ...)` which only broadcasts to WebSocket connections on the local instance
|
||||||
|
- User B's replicated stub exists on instance 1, but user B is connected via WebSocket to instance 2
|
||||||
|
- The call event is never delivered
|
||||||
|
|
||||||
|
LiveKit tokens are also instance-local (`/api/livekit/token` requires JWT auth for the local instance).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Background Workers
|
||||||
|
|
||||||
|
All workers are started by `startFederationWorkers()` on server boot and stopped by `stopFederationWorkers()` on shutdown. Each worker uses `setTimeout` chains (not `setInterval`) with abort controllers for graceful shutdown.
|
||||||
|
|
||||||
|
| Worker | Interval | Batch | Timeout | Source |
|
||||||
|
|--------|----------|-------|---------|--------|
|
||||||
|
| Outbox delivery | 10s | 50 | 30s | `processOutboxTick` |
|
||||||
|
| File download | 30s | 5 | 60s | `processFileQueueTick` |
|
||||||
|
| Health check | 1h | all unreachable | 10s | `processHealthCheckTick` |
|
||||||
|
| Janitor | 1h | -- | -- | `runFederationJanitor` (sync) |
|
||||||
|
| Initial sync | Once at startup | -- | 30s per page | `runInitialSyncForNewPeers` |
|
||||||
|
|
||||||
|
### Janitor Cleanup (`storageJanitor.ts:runFederationJanitor`)
|
||||||
|
|
||||||
|
| Target | Condition | Retention |
|
||||||
|
|--------|-----------|-----------|
|
||||||
|
| `federation_outbox` | `expiresAt < now` | Configurable via `federationRelayTtlDays` (default 30) |
|
||||||
|
| `federation_mutation_log` | `mutatedAt < (now - 90 days)` | 90 days |
|
||||||
|
| `federation_file_queue` (completed) | `createdAt < (now - 7 days)` | 7 days |
|
||||||
|
| `federation_file_queue` (any) | `expiresAt < now` | 30 days (set at queue time) |
|
||||||
|
| `dm_channels` (soft-deleted) | `deletedAt < (now - 24h)` | 24-hour grace period |
|
||||||
|
|
||||||
|
DM channel hard-delete cascades: reactions, embeds, attachments (DB rows + disk files), messages, members, outbox entries, mutation log entries, file queue entries.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Settings Cache
|
||||||
|
|
||||||
|
`federationOutbox.ts` caches `federationRelayEnabled` and `federationRelayTtlDays` from `instance_settings` for 30 seconds (`CACHE_TTL_MS`). This prevents repeated DB reads on every message send. The cache is invalidated by TTL only -- there is no explicit cache bust on settings change.
|
||||||
|
|
||||||
|
Relevant settings in `instance_settings`:
|
||||||
|
|
||||||
|
| Column | Default | Purpose |
|
||||||
|
|--------|---------|---------|
|
||||||
|
| `federation_relay_enabled` | 1 | Master toggle for all federation relay |
|
||||||
|
| `federation_relay_ttl_days` | 30 | Outbox entry TTL |
|
||||||
|
| `max_upload_size_bytes` | `null` (uses `config.maxUploadSize`) | File download size limit |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Client-Side Identity Helpers (`identity.ts`)
|
||||||
|
|
||||||
|
The frontend needs to resolve federated identities for display purposes:
|
||||||
|
|
||||||
|
**`parseFederatedUsername(username)`** -- splits `"youruser@nova.ddns.net"` into `{baseName: "youruser", domain: "nova.ddns.net"}`.
|
||||||
|
|
||||||
|
**`isSelf(user, homeUser)`** -- determines if a user object is the logged-in user or their replicated stub. Uses cascading checks: same ID, known self-ID set, homeInstance + baseName match.
|
||||||
|
|
||||||
|
**`canonicalUserMatch(a, b)`** -- federation-safe check for whether two user objects represent the same person. Cascades through: same local ID, `homeUserId` cross-match, username + homeInstance fallback.
|
||||||
|
|
||||||
|
**`resolveDisplayIdentity(user, homeUser)`** -- returns `homeUser` for display if `user` is a replicated stub of `homeUser`, enabling consistent avatars and display names across instances.
|
||||||
|
|
||||||
|
**Cross-instance self-ID registry:** `registerSelfId(id)` / `clearSelfIds()` track all Snowflake IDs belonging to the current user across connected instances, populated from WS `ready` events.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. Self-Healing Migrations (`migrate.ts`)
|
||||||
|
|
||||||
|
The migration system includes several data integrity checks that run on every server startup:
|
||||||
|
|
||||||
|
**Group DM ownerId repair:**
|
||||||
|
Detects group DMs with UUID-format `federated_id` (length 36, matches `________-____-____-____-____________`) but `NULL owner_id`. Restores the owner from the first remaining member or from `owner_home_user_id`/`owner_home_instance`. Root cause: a bug in `processOwnershipTransferEvent` (fixed in cd7aff0) could set `ownerId = NULL` via `resolveLocalUser` fallback.
|
||||||
|
|
||||||
|
**Federated ID backfill:**
|
||||||
|
Finds 1-on-1 DM channels without `federated_id`, computes deterministic SHA-256 hash from home user IDs, and sets it. Also detects relay-created duplicate channels with the same `federated_id` and merges messages into the oldest channel.
|
||||||
|
|
||||||
|
**Duplicate channel merge:**
|
||||||
|
Finds `federated_id` values appearing on multiple channels and merges them into the oldest, moving messages, members, and cleaning up the duplicates.
|
||||||
|
|
||||||
|
**Mutation log backfill:**
|
||||||
|
If the `federation_mutation_log` table exists but is empty, populates it with `create` entries for all existing DM messages where `source_instance IS NULL` (locally-created messages).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known Issues
|
||||||
|
|
||||||
|
### 1. Origin Format Inconsistency (PARTIALLY FIXED)
|
||||||
|
|
||||||
|
**Root cause:** `users.home_instance` stores both bare domains (from auth registration: `nova.ddns.net`) and full URLs (from `resolveOrCreateReplicatedUser`: `https://nova.ddns.net`). `federation_peers.origin` and `getOurOrigin()` always use full URLs.
|
||||||
|
|
||||||
|
**Fixed locations:**
|
||||||
|
- `getGroupDmTargetOrigins()` normalizes before comparison (`federationOutbox.ts:294`)
|
||||||
|
- `isLocalMember` in `dm.ts:655` checks both formats
|
||||||
|
- DM member_add target resolution in `dm.ts:743` normalizes
|
||||||
|
|
||||||
|
**Remaining unpatched comparisons:**
|
||||||
|
- `federation.ts:1278` -- `memberUser?.homeInstance === sourceInstance` in `processCreateEvent`. If the member's `homeInstance` is a bare domain and `sourceInstance` is a full URL, this comparison fails. Result: the member receives the message even though they should be skipped (minor -- causes duplicate delivery, not data loss).
|
||||||
|
- `getFriendEventTargets()` (`federationOutbox.ts:376-379`) -- compares `fromHomeInstance`/`toHomeInstance` against `getOurOrigin()` without normalization. When a local user's `homeInstance` is null, the fallback `domainOrigin` (full URL) is used, which works correctly. But when a user has `homeInstance` stored as a bare domain and that domain is **this instance** (e.g., an old replicated stub), the comparison `bareDomain !== fullUrl` evaluates to true, incorrectly including the local instance as a target, which then silently drops in `queueOutboxEvent`.
|
||||||
|
- `federationWorker.ts:424` -- `user.homeInstance === ourOrigin` in `handleSizeRejection`. Bare domain homeInstance won't match, potentially including a user in `affectedUserIds` who shouldn't be (minor).
|
||||||
|
|
||||||
|
### 2. Duplicate User Stubs
|
||||||
|
|
||||||
|
The same remote user can have multiple replicated records. Code paths that call `resolveOrCreateReplicatedUser`:
|
||||||
|
- `processCreateEvent` (for each participant)
|
||||||
|
- `processMemberAddEvent` (bootstrap roster + incremental add + owner + addedBy)
|
||||||
|
- `processOwnershipTransferEvent` (new owner)
|
||||||
|
- `processFriendRequestCreateEvent` (sender)
|
||||||
|
- `processFriendRequestUpdateEvent` (recipient)
|
||||||
|
- `processFriendAddEvent` (both users)
|
||||||
|
|
||||||
|
`resolveLocalUser` (called first by `resolveOrCreateReplicatedUser`) matches on `homeUserId` OR `(id = homeUserId AND homeInstance IS NULL)`. If a user was created via auth registration (with `homeInstance` as bare domain) and later via relay (with `homeInstance` as full URL), `resolveLocalUser` may not find the first record if the IDs differ. The collision-safe username suffix ensures the insert succeeds, but now two stubs exist for the same person.
|
||||||
|
|
||||||
|
### 3. Silent Failures in queueOutboxEvent
|
||||||
|
|
||||||
|
`queueOutboxEvent` returns silently (no error, no log) when:
|
||||||
|
- Federation relay is disabled
|
||||||
|
- Zero active peers exist
|
||||||
|
- `targetPeerOrigins` filter produces zero matches (origin format mismatch)
|
||||||
|
|
||||||
|
The third case is the most dangerous -- it looks like the event was queued but nothing was actually written. This has been the root cause of events silently disappearing for group DMs and friend events.
|
||||||
|
|
||||||
|
### 4. Trust Model Analysis
|
||||||
|
|
||||||
|
| Threat | Mitigation | Gap |
|
||||||
|
|--------|-----------|-----|
|
||||||
|
| Peer impersonation | `X-Federation-Origin` is verified against `federation_peers.origin` | An attacker who compromises the HMAC secret can impersonate the peer |
|
||||||
|
| User attribution fraud | Authority checks: e.g., `from.homeInstance !== sourceInstance` rejects events where the acting user doesn't belong to the source instance | The check is string equality on `homeInstance` from the payload, which the sender controls. A malicious peer could claim any user belongs to them by setting `homeInstance` to their own origin. |
|
||||||
|
| Event flooding | Outbox batches limited to 50 events. `/api/federation/peer/accept` rate-limited to 10/min. | No rate limit on `/api/federation/relay` itself. A peer could send unlimited relay requests. |
|
||||||
|
| Replay attacks | 15-minute timestamp window | No nonce -- valid requests can be replayed within the window |
|
||||||
|
| Message content manipulation | None | A compromised peer can forge message content attributed to any user on their instance |
|
||||||
|
|
||||||
|
### 5. DNS Hairpin Self-POST Bug
|
||||||
|
|
||||||
|
`runInitialSyncForNewPeers` (`federationWorker.ts:792-797`) POSTs received sync events to `{ourOrigin}/api/federation/relay` via public DNS. This adds unnecessary latency and fails when DNS hairpin is not configured. The function should call the relay processing logic directly instead of making an HTTP request to itself.
|
||||||
|
|
||||||
|
### 6. DM Calls Do Not Work over Federation
|
||||||
|
|
||||||
|
See section 12. The call state machine is entirely local to a single server instance. No federation relay exists for call events (`dm_call_start`, `dm_call_incoming`, `dm_call_accept`, `dm_call_reject`, `dm_call_end`).
|
||||||
@@ -0,0 +1,542 @@
|
|||||||
|
# Mobile & Responsive UI System
|
||||||
|
|
||||||
|
Source files:
|
||||||
|
- `packages/web/src/components/layout/MobileShell.tsx` — Root mobile container: three tabs, screen stack, swipe gesture, browser history sync
|
||||||
|
- `packages/web/src/components/layout/MobileScreenStack.tsx` — Push/pop animation state machine with CSS slide transitions
|
||||||
|
- `packages/web/src/components/layout/MobileBottomNav.tsx` — Tab bar with unread badge counts, hidden when stack non-empty
|
||||||
|
- `packages/web/src/components/layout/MobileNav.tsx` — Legacy hamburger menu (pre-MobileShell), renders only when `isMobile` true
|
||||||
|
- `packages/web/src/components/layout/MobileScreenHeader.tsx` — Reusable back-arrow header for pushed screens
|
||||||
|
- `packages/web/src/components/layout/MobileChatScreen.tsx` — Channel/DM chat view with MessageList, MessageInput, TypingIndicator
|
||||||
|
- `packages/web/src/components/layout/MobileDmsScreen.tsx` — DM list with online friends row, unread indicators, FAB for new DM
|
||||||
|
- `packages/web/src/components/layout/MobileSpacesScreen.tsx` — Space strip + channel list (split-pane), folder support, voice user rows
|
||||||
|
- `packages/web/src/components/layout/MobileYouScreen.tsx` — User profile card, action rows, logout
|
||||||
|
- `packages/web/src/components/layout/MobileSettingsScreen.tsx` — Settings hub and direct-panel rendering via `initialPanel` prop
|
||||||
|
- `packages/web/src/components/layout/MobileInstancePanel.tsx` — Admin-only instance settings hub (General, Streaming, Storage, Users)
|
||||||
|
- `packages/web/src/components/layout/MobileMembersScreen.tsx` — Space member list grouped by role, with activity cards
|
||||||
|
- `packages/web/src/components/layout/MobileVoiceFullScreen.tsx` — Full-screen voice call view with participant grid and control bar
|
||||||
|
- `packages/web/src/components/layout/MobileVoiceMiniBar.tsx` — Persistent mini-bar overlay during voice calls
|
||||||
|
- `packages/web/src/components/layout/MobileFolderSheet.tsx` — Bottom sheet for space folder contents, rename, color, ungroup
|
||||||
|
- `packages/web/src/hooks/useSwipeGesture.ts` — Edge swipe-back touch gesture hook
|
||||||
|
- `packages/web/src/stores/uiStore.ts` — Mobile navigation state (mobileScreen, mobileStack, push/pop actions)
|
||||||
|
|
||||||
|
Cross-references:
|
||||||
|
- Surface/glass tiers, animations, input classes: see `docs/systems/design-system.md`
|
||||||
|
- Voice call state machine, LiveKit integration: see `docs/systems/voice.md`
|
||||||
|
- Desktop three-column layout (AppLayout): see `docs/systems/design-system.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Responsive Breakpoint
|
||||||
|
|
||||||
|
Detection is in `AppLayout.tsx`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const checkMobile = () => setIsMobile(window.innerWidth < 768);
|
||||||
|
// Called on mount + resize listener
|
||||||
|
```
|
||||||
|
|
||||||
|
| Breakpoint | Value | Layout |
|
||||||
|
|------------|-------|--------|
|
||||||
|
| Desktop | `>= 768px` | AppLayout three-column grid (sidebar + chat + member list) |
|
||||||
|
| Mobile | `< 768px` | MobileShell (tab bar + screen stack) |
|
||||||
|
|
||||||
|
`AppLayout` conditionally renders `<MobileShell />` when `isMobile === true`. Modals render globally in both modes.
|
||||||
|
|
||||||
|
### Desktop-to-Mobile Transition (`uiStore:setIsMobile`)
|
||||||
|
|
||||||
|
| Direction | State changes |
|
||||||
|
|-----------|--------------|
|
||||||
|
| **To mobile** (`isMobile: true`) | `sidebarOpen: false`, `memberListOpen: false` |
|
||||||
|
| **To desktop** (`isMobile: false`) | `sidebarOpen: true`, `mobileScreen: 'spaces'`, `mobileStack: []` (memberListOpen retains its persisted value) |
|
||||||
|
|
||||||
|
The `setIsMobile` function is a no-op if the value hasn't changed (`prev === isMobile` guard).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
MobileShell (100dvh flex column)
|
||||||
|
+-- MobileScreenStack (flex-1, relative, overflow-hidden)
|
||||||
|
| +-- Root screen (spaces | dms | you) — always rendered, visibility-hidden when covered
|
||||||
|
| +-- Stacked screens (absolute inset-0, bg-surface-base, z-10)
|
||||||
|
+-- MobileVoiceMiniBar (conditional: when currentVoiceChannelId && voice-full not on top)
|
||||||
|
+-- MobileBottomNav (glass-bubble tab bar, hidden when stack non-empty)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mobile Navigation State (`uiStore`)
|
||||||
|
|
||||||
|
### Data Types
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface MobileStackEntry {
|
||||||
|
screen: string; // Screen key from screenMap
|
||||||
|
params?: Record<string, string>; // e.g., { channelId, spaceId }
|
||||||
|
}
|
||||||
|
|
||||||
|
// State
|
||||||
|
mobileScreen: 'spaces' | 'dms' | 'you'; // Active root tab
|
||||||
|
mobileStack: MobileStackEntry[]; // Push/pop stack
|
||||||
|
```
|
||||||
|
|
||||||
|
### Actions
|
||||||
|
|
||||||
|
| Action | Behavior |
|
||||||
|
|--------|----------|
|
||||||
|
| `setMobileTab(tab)` | Sets `mobileScreen` to tab, clears `mobileStack` to `[]` |
|
||||||
|
| `pushMobileScreen(screen, params?)` | Appends entry to `mobileStack`, calls `history.pushState({ mobileScreen: screen }, '')` |
|
||||||
|
| `popMobileScreen()` | Removes last entry from `mobileStack` (no-op if empty). Does NOT call `history.back()` |
|
||||||
|
|
||||||
|
### Browser History Integration
|
||||||
|
|
||||||
|
`pushMobileScreen` calls `history.pushState` to add a browser history entry. `MobileShell` listens for `popstate` events:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// MobileShell.tsx
|
||||||
|
useEffect(() => {
|
||||||
|
const handlePopState = () => {
|
||||||
|
if (useUIStore.getState().mobileStack.length > 0) {
|
||||||
|
popMobileScreen();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('popstate', handlePopState);
|
||||||
|
return () => window.removeEventListener('popstate', handlePopState);
|
||||||
|
}, [popMobileScreen]);
|
||||||
|
```
|
||||||
|
|
||||||
|
This means the hardware/browser back button pops the mobile screen stack. `popMobileScreen` intentionally does not call `history.back()` to avoid infinite loops when triggered by the `popstate` handler.
|
||||||
|
|
||||||
|
### Deep Link Reconstruction
|
||||||
|
|
||||||
|
On mount, `MobileShell` checks `location.pathname` for `/channels/:spaceId/:channelId` and pushes a `channel-chat` screen if the stack is empty:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
useEffect(() => {
|
||||||
|
const path = location.pathname;
|
||||||
|
const match = path.match(/^\/channels\/([^/]+)\/([^/]+)$/);
|
||||||
|
if (match && mobileStack.length === 0) {
|
||||||
|
pushMobileScreen('channel-chat', { channelId, spaceId });
|
||||||
|
}
|
||||||
|
}, []); // Mount only
|
||||||
|
```
|
||||||
|
|
||||||
|
### User Profile Mobile Override
|
||||||
|
|
||||||
|
`uiStore:openUserProfile` detects `isMobile` and pushes a `user-profile` screen instead of showing a positioned popout:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
if (get().isMobile) {
|
||||||
|
set((state) => ({
|
||||||
|
mobileStack: [...state.mobileStack, { screen: 'user-profile', params: { userId: user.id } }],
|
||||||
|
}));
|
||||||
|
history.pushState({ mobileScreen: 'user-profile' }, '');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MobileScreenStack — Animation State Machine
|
||||||
|
|
||||||
|
File: `MobileScreenStack.tsx`
|
||||||
|
|
||||||
|
The stack manages CSS slide-in/slide-out animations using a dual-state approach: the canonical `mobileStack` (from uiStore) drives transitions, while `renderStack` (local state) controls what's actually rendered.
|
||||||
|
|
||||||
|
### State
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const [transitioning, setTransitioning] = useState<'push' | 'pop' | null>(null);
|
||||||
|
const [renderStack, setRenderStack] = useState(mobileStack);
|
||||||
|
const prevStackRef = useRef(mobileStack);
|
||||||
|
const animatingRef = useRef(false);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Transition Algorithm
|
||||||
|
|
||||||
|
The `useEffect` on `mobileStack` compares the new length to the previous length:
|
||||||
|
|
||||||
|
**Push (newLen > prevLen):**
|
||||||
|
1. Set `renderStack = mobileStack` (new screen enters the DOM)
|
||||||
|
2. Set `transitioning = 'push'` (new screen positioned at `translateX(100%)` — off-screen right)
|
||||||
|
3. Double `requestAnimationFrame` ensures the off-screen position is painted
|
||||||
|
4. Set `transitioning = null` (CSS transition kicks in, slides screen to `translateX(0)`)
|
||||||
|
|
||||||
|
**Pop (newLen < prevLen):**
|
||||||
|
1. Set `transitioning = 'pop'` (top screen gets `transition-transform duration-200 ease-out` + `translateX(100%)`)
|
||||||
|
2. After 200ms timeout: set `renderStack = mobileStack` (removed screen exits DOM), `transitioning = null`
|
||||||
|
|
||||||
|
**Same length (replacement):**
|
||||||
|
- Directly set `renderStack = mobileStack` (no animation)
|
||||||
|
|
||||||
|
### CSS Classes per Screen State
|
||||||
|
|
||||||
|
| Condition | Classes | Transform |
|
||||||
|
|-----------|---------|-----------|
|
||||||
|
| Top screen, `transitioning === 'push'` | `absolute inset-0 bg-surface-base z-10` | `translateX(100%)` |
|
||||||
|
| Top screen, `transitioning === 'pop'` | `... transition-transform duration-200 ease-out` | `translateX(100%)` |
|
||||||
|
| Top screen, settled (no transition) | `... transition-transform duration-200 ease-out` | (none, defaults to 0) |
|
||||||
|
| Non-top screen | `absolute inset-0 bg-surface-base z-10` | (none) |
|
||||||
|
|
||||||
|
### Root Screen Visibility
|
||||||
|
|
||||||
|
The root screen (spaces/dms/you) is always rendered but has `visibility: hidden` when `renderStack.length > 0`. This avoids unmount/remount when returning to root.
|
||||||
|
|
||||||
|
### Animation Timing
|
||||||
|
|
||||||
|
| Phase | Duration | Mechanism |
|
||||||
|
|-------|----------|-----------|
|
||||||
|
| Push: off-screen paint | ~2 frames (via double rAF) | `requestAnimationFrame` x2 |
|
||||||
|
| Push: slide in | 200ms | CSS `transition-transform duration-200 ease-out` |
|
||||||
|
| Pop: slide out | 200ms | CSS `transition-transform duration-200 ease-out` |
|
||||||
|
| Pop: DOM cleanup | 200ms | `setTimeout(200)` after which `renderStack` is updated |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MobileBottomNav — Tab Bar
|
||||||
|
|
||||||
|
File: `MobileBottomNav.tsx`
|
||||||
|
|
||||||
|
### Visibility
|
||||||
|
|
||||||
|
Returns `null` when `mobileStack.length > 0` — hidden whenever a pushed screen is active.
|
||||||
|
|
||||||
|
### Tabs
|
||||||
|
|
||||||
|
| Tab | Badge Type | Badge Source |
|
||||||
|
|-----|-----------|--------------|
|
||||||
|
| Spaces | Dot (red) | `unreadChannels` has any non-voice channel |
|
||||||
|
| DMs | Numeric count | Count of DM channels where `lastMessage.id > readStates[dmId]` |
|
||||||
|
| You | Dot (red) | Pending incoming friend requests (`status === 'pending'` and `fromId !== authUser.id`) |
|
||||||
|
|
||||||
|
Badge caps at `99+` for numeric badges.
|
||||||
|
|
||||||
|
### Tab Tap Behavior
|
||||||
|
|
||||||
|
| Tab | Navigation |
|
||||||
|
|-----|------------|
|
||||||
|
| Spaces | Navigates to last known space route, or `/` |
|
||||||
|
| DMs | Navigates to `/channels/@me` |
|
||||||
|
| You | No navigation (stays on current route) |
|
||||||
|
|
||||||
|
All tabs call `setMobileTab(tab)` which clears the mobile stack.
|
||||||
|
|
||||||
|
### Styling
|
||||||
|
|
||||||
|
- Container: `glass-bubble` surface tier
|
||||||
|
- Height: `calc(56px + env(safe-area-inset-bottom))`
|
||||||
|
- Active tab: `text-accent-primary`; Inactive: `text-txt-secondary`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Edge Swipe-Back Gesture (`useSwipeGesture`)
|
||||||
|
|
||||||
|
File: `hooks/useSwipeGesture.ts`
|
||||||
|
|
||||||
|
### Parameters
|
||||||
|
|
||||||
|
| Param | Default | Description |
|
||||||
|
|-------|---------|-------------|
|
||||||
|
| `onSwipeRight` | — | Callback fired on successful swipe |
|
||||||
|
| `edgeThreshold` | `20` (px) | Touch must start within this distance from the left edge |
|
||||||
|
| `swipeThreshold` | `50` (px) | Horizontal movement required to trigger |
|
||||||
|
| `enabled` | `true` | Disables all event listeners when false |
|
||||||
|
|
||||||
|
### Algorithm
|
||||||
|
|
||||||
|
1. `touchstart`: If `touch.clientX <= 20`, record start position
|
||||||
|
2. `touchmove`: If vertical movement exceeds horizontal (and not already swiping), cancel. If horizontal dx > 50px, set swiping flag and `preventDefault()`
|
||||||
|
3. `touchend` / `touchcancel`: If swiping flag is set, fire `onSwipeRight`
|
||||||
|
|
||||||
|
### Usage in MobileShell
|
||||||
|
|
||||||
|
```ts
|
||||||
|
useSwipeGesture({
|
||||||
|
onSwipeRight: () => {
|
||||||
|
if (mobileStack.length > 0) popMobileScreen();
|
||||||
|
},
|
||||||
|
enabled: mobileStack.length > 0,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Only active when there are pushed screens to pop. Document-level event listeners are added/removed based on `enabled`.
|
||||||
|
|
||||||
|
Event options: `touchstart` is `{ passive: true }`, `touchmove` is `{ passive: false }` (allows `preventDefault` to block scroll during swipe).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Screen Map
|
||||||
|
|
||||||
|
`MobileShell` defines a `screenMap` that maps screen keys to render functions:
|
||||||
|
|
||||||
|
| Screen Key | Component | Params |
|
||||||
|
|------------|-----------|--------|
|
||||||
|
| `channel-chat` | `MobileChatScreen` | `{ channelId, spaceId }` |
|
||||||
|
| `friends` | `FriendsPage` (with `mobile` prop) | — |
|
||||||
|
| `settings` | `MobileSettingsScreen` | — |
|
||||||
|
| `settings-account` | `MobileSettingsScreen` | `initialPanel="account"` |
|
||||||
|
| `settings-voice` | `MobileSettingsScreen` | `initialPanel="voice"` |
|
||||||
|
| `settings-privacy` | `MobileSettingsScreen` | `initialPanel="privacy"` |
|
||||||
|
| `settings-connections` | `MobileSettingsScreen` | `initialPanel="connections"` |
|
||||||
|
| `settings-instance` | `MobileInstancePanel` | — |
|
||||||
|
| `settings-instance-general` | `GeneralPanel` (wrapped) | — |
|
||||||
|
| `settings-instance-streaming` | `StreamingPanel` (wrapped) | — |
|
||||||
|
| `settings-instance-storage` | `StoragePanel` (wrapped) | — |
|
||||||
|
| `settings-instance-users` | `UsersPanel` (wrapped) | — |
|
||||||
|
| `members` | `MobileMembersScreen` | `{ spaceId? }` |
|
||||||
|
| `voice-full` | `MobileVoiceFullScreen` | — |
|
||||||
|
| `explore` | `ExplorePage` | — |
|
||||||
|
| `user-profile` | `UserProfileModal` | `{ userId }` (opens modal via `openModal('userProfile', ...)`) |
|
||||||
|
|
||||||
|
Instance settings sub-panels (`settings-instance-*`) are wrapped inline with `MobileScreenHeader` + scrollable container + `bg-surface-base`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Root Screens
|
||||||
|
|
||||||
|
### MobileSpacesScreen
|
||||||
|
|
||||||
|
Split-pane layout: 60px `glass-strip` space strip on the left + channel list on the right.
|
||||||
|
|
||||||
|
**Space strip features:**
|
||||||
|
- Home/DMs button at top (navigates to DMs tab)
|
||||||
|
- Folder-aware layout via `spaceLayout` and `folders` from spaceStore
|
||||||
|
- Unread pill indicator on left edge (8px dot for unread, 32px bar for selected)
|
||||||
|
- Federation badge on space icons (globe icon, amber dot if disconnected)
|
||||||
|
- Context menu: Invite, Create Folder, Move to Folder, Remove from Folder, Transfer Ownership, Leave
|
||||||
|
- Add Space button at bottom (opens bottom sheet: Create / Join / Explore)
|
||||||
|
|
||||||
|
**Channel list features:**
|
||||||
|
- Channels grouped by categories (collapsible)
|
||||||
|
- Uncategorized channels rendered first
|
||||||
|
- Text channels: `#` prefix, unread dot, selected highlight
|
||||||
|
- Voice channels: speaker icon, inline `VoiceUserRow` for connected users with context menus
|
||||||
|
- Voice channel tap opens `MobileVoiceJoinSheet` (not direct join)
|
||||||
|
- Text channel tap: navigates via router + pushes `channel-chat` screen
|
||||||
|
- Channel/category context menus for management (guarded by `MANAGE_CHANNELS` permission)
|
||||||
|
|
||||||
|
**Layout resolution:**
|
||||||
|
- `spaceLayout` array items can be `{ t: 's', id }` (space) or `{ t: 'f', id }` (folder)
|
||||||
|
- Spaces not in the layout are appended at the end
|
||||||
|
- Folder items render as folder icon buttons that open `MobileFolderSheet`
|
||||||
|
|
||||||
|
### MobileDmsScreen
|
||||||
|
|
||||||
|
- Header: "Messages" title + "Friends" button
|
||||||
|
- Online friends activity row (horizontal scroll, shows avatar + status dot)
|
||||||
|
- DM list sorted by last message time (newest first)
|
||||||
|
- Each DM row: avatar, name, message preview, timestamp, unread dot
|
||||||
|
- Group DMs: group icon instead of avatar, context menu with "Leave Group"
|
||||||
|
- Federated users: `@domain` subtitle below username
|
||||||
|
- Empty state: sleeping mascot
|
||||||
|
- FAB: New DM button (opens `newDm` modal), positioned `bottom-20 right-4`
|
||||||
|
|
||||||
|
### MobileYouScreen
|
||||||
|
|
||||||
|
- Settings gear in header (pushes `settings`)
|
||||||
|
- Profile card: banner/accent background, avatar (-10 overlap), display name, username, custom status, bio
|
||||||
|
- Action rows (each pushes a settings sub-screen): Edit Profile, Friends, Connections, Voice & Audio
|
||||||
|
- Log Out button with `ConfirmDialog`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pushed Screen Components
|
||||||
|
|
||||||
|
### MobileChatScreen
|
||||||
|
|
||||||
|
Params: `{ channelId, spaceId }`
|
||||||
|
|
||||||
|
- Loads messages and sets current channel on mount via `useChatStore`
|
||||||
|
- Resolves channel name: DM names from member list (group: comma-separated), space channels by `#name`
|
||||||
|
- Custom header with back button + channel name + members button (space channels only)
|
||||||
|
- Members button pushes `members` screen (not shown for DMs)
|
||||||
|
- Renders `MessageList`, `TypingIndicator`, `MessageInput`
|
||||||
|
|
||||||
|
### MobileSettingsScreen
|
||||||
|
|
||||||
|
Two modes controlled by `initialPanel` prop:
|
||||||
|
|
||||||
|
1. **Hub mode** (`initialPanel` undefined): List of setting sections (Account, Voice & Audio, Privacy, Connections, Instance for admins). Each pushes `settings-{id}`.
|
||||||
|
2. **Direct panel mode** (`initialPanel` set): Renders the corresponding panel component (AccountPanel, VoicePanel, PrivacyPanel, ConnectionsPanel) directly with a back header.
|
||||||
|
|
||||||
|
### MobileInstancePanel
|
||||||
|
|
||||||
|
Admin-only instance settings hub. Pre-fetches instance settings and streaming limits on mount. Lists four sub-sections (General, Streaming, Storage, Users), each pushing `settings-instance-{id}`.
|
||||||
|
|
||||||
|
### MobileMembersScreen
|
||||||
|
|
||||||
|
Params: `{ spaceId? }` (falls back to `currentSpaceId`)
|
||||||
|
|
||||||
|
- Groups online members by their highest-positioned role
|
||||||
|
- Owner gets special `__owner__` group (position Infinity)
|
||||||
|
- Offline members in separate section
|
||||||
|
- Each member row: avatar with status, role-colored name, federated domain, activity card
|
||||||
|
- Tap opens `user-profile` screen
|
||||||
|
|
||||||
|
Member group resolution (`getMemberGroup`):
|
||||||
|
1. Owner: `{ key: '__owner__', label: 'OWNER', position: Infinity }`
|
||||||
|
2. Has roles: top role by position `{ key: roleId, label: ROLE_NAME, position }`
|
||||||
|
3. No roles: `{ key: '__online__', label: 'ONLINE', position: -1 }`
|
||||||
|
|
||||||
|
### MobileScreenHeader
|
||||||
|
|
||||||
|
Reusable header component used by `MobileInstancePanel`, `MobileMembersScreen`, and inline in screenMap wrappers.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface MobileScreenHeaderProps {
|
||||||
|
title: string;
|
||||||
|
rightActions?: React.ReactNode;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Height: 48px (`h-12`)
|
||||||
|
- Back button calls `popMobileScreen()`
|
||||||
|
- Bottom border: `border-border-soft`
|
||||||
|
- Background: `bg-surface-base`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Voice Overlay
|
||||||
|
|
||||||
|
### MobileVoiceMiniBar
|
||||||
|
|
||||||
|
File: `MobileVoiceMiniBar.tsx`
|
||||||
|
|
||||||
|
**Visibility rules:**
|
||||||
|
- Shown when `currentVoiceChannelId` is truthy
|
||||||
|
- Hidden when `voice-full` is the top screen in `mobileStack`
|
||||||
|
|
||||||
|
**Layout:** `glass-bubble` container, `mx-2 mb-1 rounded-2xl`. Positioned between `MobileScreenStack` and `MobileBottomNav` in the DOM.
|
||||||
|
|
||||||
|
**Content:**
|
||||||
|
- Left: mint circle icon + channel name + participant count (tap expands to `voice-full`)
|
||||||
|
- Right: mute toggle, deafen toggle, disconnect button
|
||||||
|
- Quick controls use `e.stopPropagation()` to prevent expanding on control taps
|
||||||
|
|
||||||
|
**Disconnect logic:** Handles both DM calls (`dm_call_end` WS event) and space voice channels (`voice_leave` WS event), calls `disconnectFn`, clears `activeDmCall`.
|
||||||
|
|
||||||
|
### MobileVoiceFullScreen
|
||||||
|
|
||||||
|
File: `MobileVoiceFullScreen.tsx`
|
||||||
|
|
||||||
|
**Header:** Collapse chevron (down arrow, pops screen), channel name, space name subtitle, participant count, members button (space channels only).
|
||||||
|
|
||||||
|
**Participant grid:**
|
||||||
|
- `grid-cols-1` for 1-2 participants, `grid-cols-2` for 3+
|
||||||
|
- Avatar size: 80px for 1-2 participants, 56px for 3+
|
||||||
|
- Mute/deafen badge overlay on avatar (bottom-right, rose circle with icon)
|
||||||
|
- Shows self-mute, space mute, permission mute, self-deafen, space deafen
|
||||||
|
- Context menu on other participants: voice mod items, local mute checkbox, volume slider
|
||||||
|
|
||||||
|
**Control bar:** `glass-bubble` container with safe area padding.
|
||||||
|
|
||||||
|
| Button | State Colors |
|
||||||
|
|--------|-------------|
|
||||||
|
| Mute | Active: `bg-accent-rose/20 text-accent-rose`, Inactive: `bg-surface-elevated text-txt-primary` |
|
||||||
|
| Deafen | Same as mute |
|
||||||
|
| Camera | Active: `bg-accent-mint/20 text-accent-mint`, Inactive: same |
|
||||||
|
| Screen share | Same as camera |
|
||||||
|
| Disconnect | Always `bg-accent-rose text-white` |
|
||||||
|
|
||||||
|
**Disconnect:** Same logic as mini-bar (handles DM calls and space voice, calls `disconnectFn`, pops screen).
|
||||||
|
|
||||||
|
**Guard:** If `currentVoiceChannelId` is falsy, calls `popMobileScreen()` and returns null.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MobileFolderSheet
|
||||||
|
|
||||||
|
File: `MobileFolderSheet.tsx`
|
||||||
|
|
||||||
|
Bottom sheet for viewing and managing space folders.
|
||||||
|
|
||||||
|
**Presentation:**
|
||||||
|
- Fixed overlay: `z-[300]` backdrop + `z-[301]` sheet
|
||||||
|
- Glass: `glass-modal` surface tier
|
||||||
|
- Animation: `animate-slide-up-sheet` (200ms ease-out translateY)
|
||||||
|
- Max height: `60vh`
|
||||||
|
- Drag handle: 10x1 rounded pill
|
||||||
|
|
||||||
|
**Props:**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface MobileFolderSheetProps {
|
||||||
|
folder: SpaceFolder;
|
||||||
|
onClose: () => void;
|
||||||
|
onSelectSpace: (spaceId: string) => void;
|
||||||
|
onUpdateFolder: (folderId: string, updates: { name?: string | null; color?: string | null }) => void;
|
||||||
|
onUngroup: (folderId: string) => void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Folder header with color swatch, name (inline editable), space count
|
||||||
|
- Context menu: Rename, Color picker (7 accent colors + clear), Ungroup (danger)
|
||||||
|
- Space list with gradient/icon thumbnails, tap selects space and closes sheet
|
||||||
|
|
||||||
|
**Folder colors:**
|
||||||
|
|
||||||
|
| Name | Value |
|
||||||
|
|------|-------|
|
||||||
|
| Mint | `rgb(var(--accent-mint))` |
|
||||||
|
| Peach | `rgb(var(--accent-peach))` |
|
||||||
|
| Lavender | `rgb(var(--accent-lavender))` |
|
||||||
|
| Sky | `rgb(var(--accent-sky))` |
|
||||||
|
| Amber | `rgb(var(--accent-amber))` |
|
||||||
|
| Rose | `rgb(var(--accent-rose))` |
|
||||||
|
| Coral | `rgb(var(--accent-coral))` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MobileNav (Legacy)
|
||||||
|
|
||||||
|
File: `MobileNav.tsx`
|
||||||
|
|
||||||
|
A hamburger-menu component that predates `MobileShell`. It renders a fixed-position toggle button (`z-[120]`) and a backdrop overlay (`z-[35]`). Only renders when `isMobile === true` (via `if (!isMobile) return null`).
|
||||||
|
|
||||||
|
This component provides sidebar toggle functionality for contexts where `MobileShell` is not the active layout. It is separate from the MobileShell tab-based navigation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Safe Area Handling
|
||||||
|
|
||||||
|
Several mobile components respect the iOS safe area inset:
|
||||||
|
|
||||||
|
| Component | CSS |
|
||||||
|
|-----------|-----|
|
||||||
|
| MobileBottomNav | `paddingBottom: env(safe-area-inset-bottom)`, height includes inset |
|
||||||
|
| MobileVoiceFullScreen control bar | `marginBottom: calc(0.5rem + env(safe-area-inset-bottom))` |
|
||||||
|
| MobileFolderSheet | `paddingBottom: env(safe-area-inset-bottom)` |
|
||||||
|
| MobileSpacesScreen add sheet | `paddingBottom: env(safe-area-inset-bottom)` |
|
||||||
|
|
||||||
|
The root MobileShell uses `height: 100dvh` (dynamic viewport height) to account for mobile browser chrome.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Z-Index Layers
|
||||||
|
|
||||||
|
| Layer | Z-Index | Component |
|
||||||
|
|-------|---------|-----------|
|
||||||
|
| Stacked screens | `z-10` | MobileScreenStack pushed screens |
|
||||||
|
| MobileNav backdrop | `z-[35]` | MobileNav sidebar overlay |
|
||||||
|
| MobileNav hamburger | `z-[120]` | MobileNav toggle button |
|
||||||
|
| DMs FAB | `z-20` | MobileDmsScreen new DM button |
|
||||||
|
| Bottom sheets (backdrop) | `z-[300]` | MobileFolderSheet, Add Space sheet, ContextMenu |
|
||||||
|
| Bottom sheets (content) | `z-[301]` | MobileFolderSheet, Add Space sheet, ContextMenu |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## LocalStorage Persistence
|
||||||
|
|
||||||
|
The uiStore uses `zustand/persist` with `partialize`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
partialize: (state) => ({
|
||||||
|
memberListOpen: state.memberListOpen,
|
||||||
|
lastChannelPerSpace: state.lastChannelPerSpace,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Only `memberListOpen` and `lastChannelPerSpace` persist. Mobile navigation state (`mobileScreen`, `mobileStack`) is ephemeral and resets on page reload.
|
||||||
|
|
||||||
|
`lastChannelPerSpace` is used by `MobileBottomNav` to navigate to the last-viewed channel when the Spaces tab is tapped, and by `MobileSpacesScreen` when a text channel is opened (via `setLastChannel`).
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# Permission System
|
||||||
|
|
||||||
|
Source files:
|
||||||
|
- `packages/shared/src/permissions.ts` — Bit definitions, constants
|
||||||
|
- `packages/server/src/utils/permissions.ts` — Server-side resolution
|
||||||
|
- `packages/web/src/utils/permissions.ts` — Client-side helpers
|
||||||
|
|
||||||
|
Storage: Bigint decimal strings in SQLite TEXT columns (bigint not JSON-safe).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Permission Bits
|
||||||
|
|
||||||
|
| Bit | Name | Description |
|
||||||
|
|-----|------|-------------|
|
||||||
|
| 0 | ADMINISTRATOR | Full access, bypasses all checks |
|
||||||
|
| 1 | VIEW_CHANNEL | See channel, read messages |
|
||||||
|
| 2 | MANAGE_CHANNELS | Create/edit/delete channels + categories |
|
||||||
|
| 3 | MANAGE_ROLES | Create/edit/delete roles, assign roles |
|
||||||
|
| 4 | MANAGE_SPACE | Edit space settings, manage join requests |
|
||||||
|
| 5 | CREATE_INVITE | Generate invite codes |
|
||||||
|
| 6 | KICK_MEMBERS | Remove members |
|
||||||
|
| 7 | BAN_MEMBERS | Ban members |
|
||||||
|
| 10 | SEND_MESSAGES | Post in text channels |
|
||||||
|
| 11 | MANAGE_MESSAGES | Delete others' messages |
|
||||||
|
| 12 | ATTACH_FILES | Upload files |
|
||||||
|
| 13 | READ_MESSAGE_HISTORY | View message history |
|
||||||
|
| 14 | ADD_REACTIONS | Add emoji reactions |
|
||||||
|
| 20 | CONNECT | Join voice channels |
|
||||||
|
| 21 | SPEAK | Transmit audio |
|
||||||
|
| 22 | MUTE_MEMBERS | Space-mute others |
|
||||||
|
| 23 | DEAFEN_MEMBERS | Space-deafen others |
|
||||||
|
| 24 | MOVE_MEMBERS | Move between voice channels |
|
||||||
|
| 25 | STREAM | Screen share |
|
||||||
|
| 26 | DISCONNECT_MEMBERS | Disconnect from voice |
|
||||||
|
|
||||||
|
**Default @everyone:** VIEW_CHANNEL, SEND_MESSAGES, CREATE_INVITE, CONNECT, SPEAK, ATTACH_FILES, READ_MESSAGE_HISTORY, ADD_REACTIONS, STREAM
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Resolution Algorithm
|
||||||
|
|
||||||
|
`computePermissions(userId, spaceId, channelId?)` → bigint
|
||||||
|
|
||||||
|
### Step 1: Owner/Admin Check
|
||||||
|
- Space owner OR instance admin (`isAdmin === 1`) → return ALL_PERMISSIONS
|
||||||
|
|
||||||
|
### Step 2: Compute Base (space-level)
|
||||||
|
- Start with @everyone role permissions (role where `id === spaceId`)
|
||||||
|
- OR together all permissions from user's assigned roles
|
||||||
|
- If ADMINISTRATOR bit set → return ALL_PERMISSIONS
|
||||||
|
|
||||||
|
### Step 3: Apply Overrides (if channelId provided)
|
||||||
|
|
||||||
|
Three tiers, each applied category-first then channel-second:
|
||||||
|
|
||||||
|
**Tier 1 — @everyone override** (targetType='role', targetId=spaceId):
|
||||||
|
```
|
||||||
|
if categoryOverride: base = (base & ~deny) | allow
|
||||||
|
if channelOverride: base = (base & ~deny) | allow
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tier 2 — Role overrides** (combined across all assigned roles):
|
||||||
|
```
|
||||||
|
catAllow = 0, catDeny = 0
|
||||||
|
for each role: catAllow |= roleOverride.allow; catDeny |= roleOverride.deny
|
||||||
|
base = (base & ~catDeny) | catAllow
|
||||||
|
|
||||||
|
chanAllow = 0, chanDeny = 0
|
||||||
|
for each role: chanAllow |= roleOverride.allow; chanDeny |= roleOverride.deny
|
||||||
|
base = (base & ~chanDeny) | chanAllow
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tier 3 — Member override** (targetType='member', targetId=userId):
|
||||||
|
```
|
||||||
|
if categoryOverride: base = (base & ~deny) | allow
|
||||||
|
if channelOverride: base = (base & ~deny) | allow
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key rule:** Channel bits always win — applied after category, overwriting conflicting bits. Deny applied first (clears bits), then allow (sets bits).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Helper Functions
|
||||||
|
|
||||||
|
| Function | Purpose |
|
||||||
|
|----------|---------|
|
||||||
|
| `hasPermissionBit(perms, bit)` | Check if bit is set; true if ADMINISTRATOR |
|
||||||
|
| `permissionsToString(perms)` | Bigint → decimal string for JSON |
|
||||||
|
| `stringToPermissions(str)` | Decimal string → bigint (supports legacy JSON array format) |
|
||||||
|
| `computePermissions(userId, spaceId, channelId?)` | Full resolution algorithm |
|
||||||
|
| `computeCategoryPermissions(userId, spaceId, categoryId)` | Stops at category level (no channel overrides) |
|
||||||
|
| `hasPermission(userId, spaceId, permission, channelId?)` | Boolean wrapper |
|
||||||
|
| `getMember/isMember/isSpaceOwner` | Membership checks |
|
||||||
|
| `isDmMember/isBanned` | DM/ban checks |
|
||||||
|
| `getChannelSpaceId(channelId)` | Resolve channel's space |
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
# Search System
|
||||||
|
|
||||||
|
Source files:
|
||||||
|
- `packages/server/src/routes/search.ts` — Server-side search endpoints (channel search, DM search, messages-around)
|
||||||
|
- `packages/web/src/components/chat/SearchPopover.tsx` — Client-side search UI (filter bar, result rendering, jump-to-message)
|
||||||
|
- `packages/web/src/api/client.ts` — API client `search` namespace and `messagesAround` methods
|
||||||
|
- `packages/web/src/stores/chatStore.ts` — `loadMessagesAround()` store action
|
||||||
|
- `packages/web/src/components/chat/MessageList.tsx` — Jump-to-message scroll + highlight logic
|
||||||
|
- `packages/web/src/components/layout/MainContent.tsx` — Search button + popover wiring
|
||||||
|
- `packages/web/src/styles/globals.css` — `.search-highlight` animation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
Four endpoints, all requiring JWT authentication (`preHandler: authenticate`).
|
||||||
|
|
||||||
|
| Endpoint | Auth Check | Response Shape |
|
||||||
|
|----------|-----------|----------------|
|
||||||
|
| `GET /api/channels/:id/search` | `VIEW_CHANNEL + READ_MESSAGE_HISTORY` via `hasPermission()` | `{ results: MessageWithUser[], totalCount: number }` |
|
||||||
|
| `GET /api/dm/:id/search` | `isDmMember()` | `{ results: DmMessageWithUser[], totalCount: number }` |
|
||||||
|
| `GET /api/channels/:id/messages/around` | `VIEW_CHANNEL + READ_MESSAGE_HISTORY` via `hasPermission()` | `MessageWithUser[]` (flat array) |
|
||||||
|
| `GET /api/dm/:id/messages/around` | `isDmMember()` | `DmMessageWithUser[]` (flat array) |
|
||||||
|
|
||||||
|
For full endpoint signatures, see [api.md](api.md) under "Search".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Filter Syntax
|
||||||
|
|
||||||
|
All filters are query string parameters on the search endpoints.
|
||||||
|
|
||||||
|
| Parameter | Type | Description | SQL Behavior |
|
||||||
|
|-----------|------|-------------|-------------|
|
||||||
|
| `q` | string | Text query (trimmed) | `LIKE '%{q}%'` on `content` column — case-insensitive in SQLite by default for ASCII |
|
||||||
|
| `from` | string | Username filter | Exact `LIKE` match on `users.username` (not partial — no wildcards added). If no user found, returns empty results immediately (not 404). |
|
||||||
|
| `has` | `file` \| `image` \| `link` | Attachment/content filter | See "has: filter" section below |
|
||||||
|
| `before` | string | ISO 8601 date | `createdAt < new Date(before).getTime()` — parsed via `new Date()`, invalid dates silently ignored |
|
||||||
|
| `after` | string | ISO 8601 date | `createdAt > new Date(after).getTime()` — parsed via `new Date()`, invalid dates silently ignored |
|
||||||
|
| `offset` | number | Pagination offset | `Math.max(Number(offset) \|\| 0, 0)` — floored to 0 |
|
||||||
|
| `limit` | number | Page size | Clamped: `Math.min(Math.max(Number(limit) \|\| 25, 1), 50)` — default 25, max 50 |
|
||||||
|
|
||||||
|
### has: Filter Implementation
|
||||||
|
|
||||||
|
The `has` filter uses two different mechanisms depending on the value:
|
||||||
|
|
||||||
|
| Value | Mechanism | SQL |
|
||||||
|
|-------|-----------|-----|
|
||||||
|
| `file` | `EXISTS` subquery on `attachments` table | `EXISTS (SELECT 1 FROM attachments WHERE attachments.message_id = messages.id)` |
|
||||||
|
| `image` | `EXISTS` subquery with mimetype filter | `EXISTS (SELECT 1 FROM attachments WHERE attachments.message_id = messages.id AND attachments.mimetype LIKE 'image/%')` |
|
||||||
|
| `link` | LIKE on content column | `content LIKE '%http%'` — appended to the WHERE conditions (not a subquery) |
|
||||||
|
|
||||||
|
For DM search, the subquery joins on `attachments.dm_message_id = dm_messages.id` instead.
|
||||||
|
|
||||||
|
**Important:** The `has: file`/`has: image` filter uses a raw SQL `EXISTS` subquery (`hasFilter`) that is combined with the main `whereClause` using `and()`. This filter is applied separately from the main conditions array because Drizzle ORM conditions and raw SQL fragments are combined at query time.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pagination
|
||||||
|
|
||||||
|
- **Offset-based:** Uses `offset` + `limit` query params
|
||||||
|
- **Default page size:** 25
|
||||||
|
- **Max page size:** 50
|
||||||
|
- **Total count:** Returned as `totalCount` in every search response (separate COUNT query)
|
||||||
|
- **Sort order:** Results ordered by `createdAt DESC` (newest first)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Result Hydration
|
||||||
|
|
||||||
|
Both search endpoints follow the same hydration pipeline after fetching raw message rows:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Fetch message rows (filtered, paginated)
|
||||||
|
2. Batch-fetch users → userMap (userId → user)
|
||||||
|
3. Batch-fetch attachments → attachmentMap (messageId → attachments[])
|
||||||
|
4. Batch-fetch reactions → fetchReactionsForMessages() / fetchDmReactionsForMessages()
|
||||||
|
5. Batch-fetch embeds → fetchEmbedsForMessages() / fetchDmEmbedsForMessages()
|
||||||
|
6. Batch-fetch reply parents → fetchReplyToMessages() / inline DM reply fetch
|
||||||
|
7. Assemble via buildMessageWithUser() / buildDmMessageWithUser()
|
||||||
|
8. Filter out messages with missing users (null check)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Channel Search Hydration
|
||||||
|
|
||||||
|
- **Users:** Batch `SELECT` from `users` with `inArray(users.id, userIds)`
|
||||||
|
- **Attachments:** Batch `SELECT` from `attachments` with `inArray(attachments.messageId, messageIds)`
|
||||||
|
- **Reactions:** `fetchReactionsForMessages(messageIds)` — from `routes/messages.ts`
|
||||||
|
- **Embeds:** `fetchEmbedsForMessages(messageIds)` — from `utils/embedResolver.ts`
|
||||||
|
- **Replies:** `fetchReplyToMessages(messageRows)` — from `routes/messages.ts`
|
||||||
|
- **Assembly:** `buildMessageWithUser()` — from `routes/messages.ts`
|
||||||
|
|
||||||
|
### DM Search Hydration
|
||||||
|
|
||||||
|
- **Users:** Same batch pattern
|
||||||
|
- **Attachments:** Batch `SELECT` from `attachments` with `inArray(attachments.dmMessageId, messageIds)`
|
||||||
|
- **Reactions:** `fetchDmReactionsForMessages(messageIds)` — from `routes/dm.ts`
|
||||||
|
- **Embeds:** `fetchDmEmbedsForMessages(messageIds)` — from `utils/embedResolver.ts`
|
||||||
|
- **Replies:** Inline implementation — fetches `dmMessages` by `replyToId`, builds minimal `DmMessageWithUser` (with empty `attachments`, `embeds`, `reactions` arrays)
|
||||||
|
- **Assembly:** `buildDmMessageWithUser()` — from `routes/dm.ts`
|
||||||
|
|
||||||
|
### Response Types
|
||||||
|
|
||||||
|
Both types are defined in `packages/shared/src/types.ts`. See database.md for underlying table schemas.
|
||||||
|
|
||||||
|
**`MessageWithUser`** extends `Message` with: `user: User`, `attachments: Attachment[]`, `embeds: Embed[]`, `reactions: Reaction[]`, `replyTo?: MessageWithUser | null`
|
||||||
|
|
||||||
|
**`DmMessageWithUser`** extends `DmMessage` with: `user: User`, `attachments: Attachment[]`, `embeds: Embed[]`, `reactions: Reaction[]`, `replyTo?: DmMessageWithUser | null`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Messages-Around Endpoint
|
||||||
|
|
||||||
|
Used for jump-to-message navigation (from search results and deep links). Loads a window of messages centered on a target message.
|
||||||
|
|
||||||
|
### Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
|-----------|------|----------|-------------|
|
||||||
|
| `messageId` | string | Yes | Target message ID (returns 400 if missing) |
|
||||||
|
| `limit` | number | No | Window size, default 50, max 100, clamped to `[1, 100]` |
|
||||||
|
|
||||||
|
### Algorithm
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Validate target message exists in the channel (404 if not found)
|
||||||
|
2. half = floor(limit / 2)
|
||||||
|
3. Fetch "before" rows: messages with id <= messageId, ordered DESC, limit half+1 (includes target)
|
||||||
|
4. Fetch "after" rows: messages with id > messageId, ordered ASC, limit half
|
||||||
|
5. Reverse beforeRows to chronological order
|
||||||
|
6. Concatenate: [...beforeRows, ...afterRows]
|
||||||
|
7. Deduplicate by id (target may appear in both sets)
|
||||||
|
8. Hydrate with same pipeline as search results
|
||||||
|
9. Return flat MessageWithUser[] / DmMessageWithUser[] array
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** The "before" query uses `id <= messageId` (not timestamp-based), and the "after" query uses `id > messageId`. This means the pivot is on Snowflake ID ordering, not `createdAt`. The `ORDER BY` clause still uses `createdAt`, which works because Snowflake IDs are monotonically increasing and correlate with creation time.
|
||||||
|
|
||||||
|
### Channel vs DM Differences
|
||||||
|
|
||||||
|
- Channel: Checks `getChannelSpaceId()` + `hasPermission()` with `VIEW_CHANNEL | READ_MESSAGE_HISTORY`
|
||||||
|
- DM: Checks `isDmMember()`
|
||||||
|
- Channel: Queries `messages` table, uses `fetchReactionsForMessages`, `fetchEmbedsForMessages`, `fetchReplyToMessages`
|
||||||
|
- DM: Queries `dm_messages` table, uses `fetchDmReactionsForMessages`, `fetchDmEmbedsForMessages`, inline reply fetch
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Client-Side: SearchPopover
|
||||||
|
|
||||||
|
`packages/web/src/components/chat/SearchPopover.tsx`
|
||||||
|
|
||||||
|
### Component Props
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface SearchPopoverProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
anchorRef: React.RefObject<HTMLElement | null>;
|
||||||
|
channelId: string;
|
||||||
|
isDm: boolean;
|
||||||
|
onJumpToMessage: (messageId: string) => void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### State
|
||||||
|
|
||||||
|
| State Variable | Type | Purpose |
|
||||||
|
|---------------|------|---------|
|
||||||
|
| `query` | string | Text search input |
|
||||||
|
| `fromFilter` | string | Username filter input |
|
||||||
|
| `hasFilter` | string | `''` \| `'file'` \| `'image'` \| `'link'` — select dropdown |
|
||||||
|
| `beforeFilter` | string | Date string from `<input type="date">` |
|
||||||
|
| `afterFilter` | string | Date string from `<input type="date">` |
|
||||||
|
| `showFilters` | boolean | Filter panel visibility toggle |
|
||||||
|
| `results` | `AnyMessage[]` | Accumulated search results (`MessageWithUser \| DmMessageWithUser`) |
|
||||||
|
| `totalCount` | number | Total matching results (from server) |
|
||||||
|
| `isSearching` | boolean | Loading state |
|
||||||
|
| `offset` | number | Current pagination offset |
|
||||||
|
|
||||||
|
### Behavior
|
||||||
|
|
||||||
|
1. **Reset on open/channel change:** All state resets when `open` or `channelId` changes
|
||||||
|
2. **Debounced search:** 300ms debounce on any query/filter change, calls `doSearch(0)`
|
||||||
|
3. **Empty guard:** Search requires at least one of: `query`, `fromFilter`, `hasFilter`, `beforeFilter`, `afterFilter` — otherwise clears results
|
||||||
|
4. **Pagination:** "Load more" button appends next page. Offset tracked as `searchOffset + data.results.length`
|
||||||
|
5. **Federation-aware:** Uses `getChannelOrigin(channelId)` and `getApiForOrigin(origin)` to route API calls to the correct instance
|
||||||
|
6. **Dismiss:** Click-outside (`mousedown` listener) or Escape key closes the popover
|
||||||
|
7. **Auto-focus:** Input focused 50ms after popover opens
|
||||||
|
|
||||||
|
### API Call Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
SearchPopover.doSearch(offset)
|
||||||
|
→ getChannelOrigin(channelId) // resolve federation origin
|
||||||
|
→ getApiForOrigin(origin) // get API client for that origin
|
||||||
|
→ isDm ? client.search.dm(channelId, params)
|
||||||
|
: client.search.channel(channelId, params)
|
||||||
|
→ params: { q, from, has, before, after, offset, limit: 25 }
|
||||||
|
```
|
||||||
|
|
||||||
|
The client always sends `limit: 25` (hardcoded in SearchPopover).
|
||||||
|
|
||||||
|
### UI Layout
|
||||||
|
|
||||||
|
- **Container:** 420px wide, max 500px tall, `glass` material, positioned via `useFloatingPosition` (bottom placement, 8px offset)
|
||||||
|
- **Search input:** `input-embedded` tier with search icon and clear button
|
||||||
|
- **Filter toggle:** Collapsed by default, shows active-filter indicator dot when any filter is set
|
||||||
|
- **Filter panel:** 2-column grid — `From` (text), `Has` (select), `Before` (date), `After` (date) — all `input-search` tier
|
||||||
|
- **Results list:** Scrollable area with result count header
|
||||||
|
- **Result items:** Avatar + display name + timestamp + content snippet (2-line clamp) + attachment count
|
||||||
|
- **Query highlighting:** `highlightMatch()` wraps matches in `<mark>` tags with `bg-accent-primary/30` styling
|
||||||
|
- **Load more:** Shows remaining count, disabled while loading
|
||||||
|
|
||||||
|
### Result Rendering
|
||||||
|
|
||||||
|
Each result shows:
|
||||||
|
- User avatar (via `<Avatar>` component)
|
||||||
|
- Display name (falls back to username, then "Unknown")
|
||||||
|
- Timestamp via `formatTime()`: "Today at HH:MM", "Yesterday at HH:MM", or "MM/DD/YYYY HH:MM"
|
||||||
|
- Content with query term highlighting (case-insensitive regex split)
|
||||||
|
- Attachment count badge (paperclip icon) if `msg.attachments.length > 0`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Client-Side: Jump-to-Message
|
||||||
|
|
||||||
|
The jump-to-message flow spans three components.
|
||||||
|
|
||||||
|
### Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
1. User clicks search result in SearchPopover
|
||||||
|
→ onJumpToMessage(messageId) callback fires
|
||||||
|
→ MainContent: setJumpToMessageId(id), setSearchOpen(false)
|
||||||
|
|
||||||
|
2. MessageList receives jumpToMessageId prop
|
||||||
|
→ Check if message element exists in DOM: document.getElementById(`msg-${jumpToMessageId}`)
|
||||||
|
→ If found: scroll + highlight immediately
|
||||||
|
→ If not found: call loadMessagesAround(channelId, messageId)
|
||||||
|
→ chatStore.loadMessagesAround() replaces the channel's message cache entirely
|
||||||
|
→ After React render (double requestAnimationFrame), scroll + highlight
|
||||||
|
|
||||||
|
3. Scroll + Highlight:
|
||||||
|
→ el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
|
→ el.classList.add('search-highlight')
|
||||||
|
→ setTimeout 2000ms → el.classList.remove('search-highlight')
|
||||||
|
→ onJumpComplete() → resets jumpToMessageId to null
|
||||||
|
```
|
||||||
|
|
||||||
|
### loadMessagesAround (chatStore)
|
||||||
|
|
||||||
|
`search.ts:chatStore.loadMessagesAround(channelId, messageId)`:
|
||||||
|
|
||||||
|
- Routes to `client.channels.messagesAround()` or `client.dm.messagesAround()` based on `isDmChannel()`
|
||||||
|
- Normalizes remote asset URLs for federated channels
|
||||||
|
- **Replaces** the entire message cache for that channel (not append/prepend)
|
||||||
|
- Sets `hasMore` to `true` (enables upward scroll loading from the new position)
|
||||||
|
- Updates `channelAccessTimes`
|
||||||
|
|
||||||
|
### search-highlight CSS
|
||||||
|
|
||||||
|
Defined in `globals.css`:
|
||||||
|
|
||||||
|
```css
|
||||||
|
@keyframes search-flash {
|
||||||
|
0% { background-color: rgba(124, 108, 246, 0.2); }
|
||||||
|
100% { background-color: transparent; }
|
||||||
|
}
|
||||||
|
.search-highlight { animation: search-flash 2s ease-out; }
|
||||||
|
```
|
||||||
|
|
||||||
|
Purple flash (accent color at 20% opacity) that fades to transparent over 2 seconds.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known Limitations
|
||||||
|
|
||||||
|
1. **SQL LIKE for text search:** Uses `LIKE '%query%'` — no full-text indexing (FTS5), no relevance ranking, no word boundary matching. Performance degrades linearly with message count.
|
||||||
|
2. **from: filter is exact match:** Uses `LIKE` without wildcards on username, but SQLite LIKE is case-insensitive for ASCII by default. Does not search display names.
|
||||||
|
3. **has:link is content-based:** Searches for `'%http%'` in message content — does not check the `embeds` table. May miss non-HTTP links or match false positives (e.g., a message containing the word "http" in prose).
|
||||||
|
4. **No cross-channel search:** Each search is scoped to a single channel or DM. There is no global/space-wide search endpoint.
|
||||||
|
5. **DM reply hydration is minimal:** Reply-to messages in DM search results have empty `attachments`, `embeds`, and `reactions` arrays (unlike channel search which uses `fetchReplyToMessages` with full attachment hydration).
|
||||||
|
6. **Offset pagination:** Uses offset/limit (not cursor-based). Large offsets may have performance implications on big result sets since SQLite must scan and skip rows.
|
||||||
@@ -0,0 +1,719 @@
|
|||||||
|
# Social & Friends System
|
||||||
|
|
||||||
|
Source files:
|
||||||
|
- `packages/server/src/routes/social.ts` -- Friend requests, friend list, unfriend, user discovery, user search
|
||||||
|
- `packages/server/src/routes/users.ts` -- User profile CRUD, mutuals endpoint (`GET /users/:id/mutuals`)
|
||||||
|
- `packages/web/src/stores/socialStore.ts` -- Client-side friend/request state with cross-instance loading and origin tagging
|
||||||
|
- `packages/web/src/stores/discoverStore.ts` -- Client-side user discovery with multi-instance fan-out
|
||||||
|
- `packages/web/src/components/chat/FriendsPage.tsx` -- Friends page UI: tabs (Online/All/Pending/Add Friend/Activity), discover grid, search
|
||||||
|
- `packages/web/src/components/modals/UserProfileModal.tsx` -- Profile modal with friendship actions and mutual display
|
||||||
|
- `packages/web/src/utils/mutuals.ts` -- Cross-instance mutual friend/space loading with dedup
|
||||||
|
- `packages/web/src/utils/identity.ts` -- Federated identity helpers (parseFederatedUsername, isSelf, canonicalUserMatch)
|
||||||
|
- `packages/web/src/hooks/useWebSocket.ts` -- WS event handlers for social events (friend_request_received, etc.)
|
||||||
|
- `packages/server/src/routes/federation.ts` -- Inbound friend relay event processors (5 functions)
|
||||||
|
- `packages/server/src/utils/federationOutbox.ts` -- `buildFriendContextId()`, `getFriendEventTargets()`
|
||||||
|
- `packages/server/src/utils/federationWorker.ts` -- Initial sync friend backfill for new peers
|
||||||
|
|
||||||
|
DB tables: `friends`, `friend_requests`, `users` (discoverable, homeInstance, homeUserId fields).
|
||||||
|
See `docs/systems/database.md` for full schemas.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Friend Request Lifecycle
|
||||||
|
|
||||||
|
### State Machine
|
||||||
|
|
||||||
|
```
|
||||||
|
sender creates
|
||||||
|
(none) ────────────────────────► pending
|
||||||
|
│
|
||||||
|
┌─────────────────┼──────────────────┐
|
||||||
|
│ │ │
|
||||||
|
recipient recipient sender
|
||||||
|
accepts declines cancels
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ ▼
|
||||||
|
accepted declined (row deleted)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
friends row
|
||||||
|
inserted
|
||||||
|
```
|
||||||
|
|
||||||
|
### REST Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Purpose | Auth |
|
||||||
|
|--------|------|---------|------|
|
||||||
|
| `GET` | `/api/social/friends` | List all friends | JWT |
|
||||||
|
| `GET` | `/api/social/requests` | List pending friend requests | JWT |
|
||||||
|
| `POST` | `/api/social/requests` | Send a friend request | JWT |
|
||||||
|
| `PATCH` | `/api/social/requests/:id` | Accept or decline | JWT |
|
||||||
|
| `DELETE` | `/api/social/requests/:id` | Cancel outgoing request | JWT |
|
||||||
|
| `DELETE` | `/api/social/friends/:id` | Remove a friend | JWT |
|
||||||
|
| `GET` | `/api/social/discover` | Discover users | JWT, rate-limited 30/min |
|
||||||
|
| `GET` | `/api/social/search` | Search users by name | JWT, rate-limited 30/min |
|
||||||
|
|
||||||
|
See `docs/systems/api.md` for full endpoint signatures.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Send Friend Request (`POST /api/social/requests`)
|
||||||
|
|
||||||
|
**Input:** `{ username: string }`
|
||||||
|
|
||||||
|
**Validation chain:**
|
||||||
|
1. Username must be non-empty
|
||||||
|
2. Lookup target user by exact username match: `users.username = body.username`
|
||||||
|
3. **Self-friendship prevention:** `targetUser.id === request.userId` returns 400
|
||||||
|
4. **Already friends check:** Checks `friends` table in both directions (userId/friendId and friendId/userId)
|
||||||
|
5. **Duplicate request check:** Checks `friend_requests` for any pending request between the two users in either direction
|
||||||
|
|
||||||
|
**On success:**
|
||||||
|
1. Generates snowflake ID, inserts into `friend_requests` with `status='pending'`
|
||||||
|
2. **WS broadcast:** `friend_request_received` sent to target user with full request payload including sender profile
|
||||||
|
3. **Federation relay:** If either user is federated, queues `friend_request_create` event (see Section 6)
|
||||||
|
4. Returns `{ success: true, requestId: string }`
|
||||||
|
|
||||||
|
### Accept/Decline (`PATCH /api/social/requests/:id`)
|
||||||
|
|
||||||
|
**Input:** `{ status: 'accepted' | 'declined' }`
|
||||||
|
|
||||||
|
**Authorization:** Only the recipient (`request.toId === userId`) can accept or decline.
|
||||||
|
|
||||||
|
**Accept path:**
|
||||||
|
1. **Transaction:** Inserts `friends` row (fromId -> userId, toId -> friendId) AND updates request status to `'accepted'`
|
||||||
|
2. **WS broadcast (after commit):** `friend_request_accepted` sent to the original sender with the accepting user's profile as a `Friend` object
|
||||||
|
3. **Federation relay:** Queues both `friend_request_update` (status=accepted) AND `friend_add` events
|
||||||
|
|
||||||
|
**Decline path:**
|
||||||
|
1. Updates request status to `'declined'` (no transaction needed, single write)
|
||||||
|
2. **WS broadcast:** `friend_request_declined` sent to the original sender with `{ requestId, userId }`
|
||||||
|
3. **Federation relay:** Queues `friend_request_update` (status=declined)
|
||||||
|
|
||||||
|
### Cancel (`DELETE /api/social/requests/:id`)
|
||||||
|
|
||||||
|
**Authorization:** Only the sender (`request.fromId === userId`) can cancel.
|
||||||
|
|
||||||
|
**Validation:** Request must be in `'pending'` status.
|
||||||
|
|
||||||
|
**Actions:**
|
||||||
|
1. **Deletes** the request row (not a status update -- full deletion)
|
||||||
|
2. **WS broadcast:** `friend_request_cancelled` sent to the recipient
|
||||||
|
3. **Federation relay:** Queues `friend_request_cancel` event
|
||||||
|
|
||||||
|
### Remove Friend (`DELETE /api/social/friends/:id`)
|
||||||
|
|
||||||
|
**Path parameter:** `:id` is the friend's user ID (not the friendship row ID).
|
||||||
|
|
||||||
|
**Actions:**
|
||||||
|
1. Verifies friendship exists by checking both directions in `friends` table
|
||||||
|
2. **Deletes** the friendship row in both directions (single WHERE with OR)
|
||||||
|
3. **WS broadcast:** `friend_removed` sent to the other user with `{ userId: callerUserId }`
|
||||||
|
4. **Federation relay:** Queues `friend_remove` event
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Friend List & Request List
|
||||||
|
|
||||||
|
### GET /api/social/friends
|
||||||
|
|
||||||
|
Queries `friends` table where the authenticated user is either `userId` or `friendId`. Extracts the other user's ID from each row, fetches full user records, and returns as `Friend[]` with `addedAt` timestamp from the friendship row's `createdAt`.
|
||||||
|
|
||||||
|
### GET /api/social/requests
|
||||||
|
|
||||||
|
Queries `friend_requests` with `status='pending'` where the authenticated user is either `fromId` or `toId`. Enriches each request with the **other** user's profile (the user who is NOT the requester). Returns as `FriendRequest[]`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. User Discovery
|
||||||
|
|
||||||
|
### GET /api/social/discover
|
||||||
|
|
||||||
|
**Query params:** `q` (search term), `limit` (1-100, default 24), `offset` (default 0)
|
||||||
|
|
||||||
|
**Filters (WHERE clause):**
|
||||||
|
1. `discoverable = 1` -- user must opt into discovery
|
||||||
|
2. `isDeleted = 0` -- exclude tombstoned accounts
|
||||||
|
3. `id != myId` -- exclude self
|
||||||
|
4. `homeInstance IS NULL OR homeInstance = ''` -- **exclude replicated federated stubs** (each instance only surfaces its own native users; federated users are discovered via the parallel fan-out from the client)
|
||||||
|
5. If `q` provided: LIKE match on `username` or `displayName` with `%q%` pattern
|
||||||
|
|
||||||
|
**Pre-loaded social graph (single query each):**
|
||||||
|
- My friend IDs (from `friends` table, both directions)
|
||||||
|
- My space IDs (from `space_members`)
|
||||||
|
- Outbound pending requests (Map: toId -> requestId)
|
||||||
|
- Inbound pending requests (Map: fromId -> requestId)
|
||||||
|
|
||||||
|
**Batch optimization:** For the page of results, fetches ALL friends and space memberships for all page users in two bulk queries (using `inArray`), then builds per-user Sets for intersection computation.
|
||||||
|
|
||||||
|
**Per-user computation:**
|
||||||
|
- `mutualFriendCount`: intersection of my friends and their friends
|
||||||
|
- `mutualSpaceCount`: intersection of my spaces and their spaces
|
||||||
|
- `relationship`: one of `'none'` | `'friends'` | `'outbound_pending'` | `'inbound_pending'`
|
||||||
|
- `requestId`: set when relationship is `outbound_pending` or `inbound_pending`
|
||||||
|
|
||||||
|
**Sort:** `mutualFriendCount DESC`, then `createdAt DESC`
|
||||||
|
|
||||||
|
**Response:** `{ users: DiscoverUser[], total: number }`
|
||||||
|
|
||||||
|
### GET /api/social/search
|
||||||
|
|
||||||
|
**Query params:** `q` (min 1 character)
|
||||||
|
|
||||||
|
Simpler than discover: LIKE match on `username` or `displayName`, excludes self, limit 10. Returns `User[]` (no mutual counts, no relationship enrichment). Does **not** filter by `discoverable` or exclude replicated users.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Mutuals
|
||||||
|
|
||||||
|
### GET /api/users/:id/mutuals
|
||||||
|
|
||||||
|
**Query params:** `homeUserId` (optional, for federation fallback)
|
||||||
|
|
||||||
|
**Target resolution:** Tries path param `:id` first. If no user found and `homeUserId` query param is provided, falls back to matching `users.homeUserId = homeUserId` OR `users.id = homeUserId`. This handles cases where the caller has a remote user's home ID but not their local replicated stub ID.
|
||||||
|
|
||||||
|
**Mutual friends:** Fetches all friend rows for both the caller and the target (both directions), extracts friend IDs into Sets, computes intersection. Fetches full `User` records for the mutual friend IDs.
|
||||||
|
|
||||||
|
**Mutual spaces:** Fetches all `space_members` rows for both the caller and the target, computes intersection of space IDs. Fetches `{ id, name, icon, avatarColor }` for mutual spaces.
|
||||||
|
|
||||||
|
**Response:** `{ mutualFriends: User[], mutualSpaces: { id, name, icon, avatarColor }[] }`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. WebSocket Events
|
||||||
|
|
||||||
|
All social WS events are documented in `docs/systems/websocket.md`. Summary:
|
||||||
|
|
||||||
|
### Server -> Client
|
||||||
|
|
||||||
|
| Event | Payload | Recipient | When |
|
||||||
|
|-------|---------|-----------|------|
|
||||||
|
| `friend_request_received` | `{ request: FriendRequest }` | Target user | Request created |
|
||||||
|
| `friend_request_accepted` | `{ friend: Friend, requestId }` | Original sender | Request accepted |
|
||||||
|
| `friend_request_declined` | `{ requestId, userId }` | Original sender | Request declined |
|
||||||
|
| `friend_request_cancelled` | `{ requestId, userId }` | Target user | Sender cancelled |
|
||||||
|
| `friend_removed` | `{ userId }` | Other user | Unfriended |
|
||||||
|
|
||||||
|
### user_updated Broadcast (profile changes)
|
||||||
|
|
||||||
|
When profile fields change on `PATCH /api/users/@me`, a `user_updated` event is broadcast to a **deduplicated** set of targets:
|
||||||
|
1. All online users who share a space with the updated user
|
||||||
|
2. All co-members of any DM channel the user is in
|
||||||
|
3. All friends of the user (from `friends` table, both directions)
|
||||||
|
4. The user themselves (for multi-tab sync)
|
||||||
|
|
||||||
|
This ensures friends always see real-time profile updates (avatar, display name, bio, status, etc.).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Federation: Friend Relay
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
|
||||||
|
Cross-instance friend operations use 5 relay event types with `contextType: 'friend'`. The federation relay mechanism (outbox, delivery, HMAC signing) is documented in `docs/systems/federation.md`. This section covers the **application logic** specific to friend events.
|
||||||
|
|
||||||
|
### Event Types
|
||||||
|
|
||||||
|
| eventType | Trigger | Authority | Relay Direction |
|
||||||
|
|-----------|---------|-----------|-----------------|
|
||||||
|
| `friend_request_create` | POST /api/social/requests | Sender's home instance | Sender -> Recipient's instance |
|
||||||
|
| `friend_request_update` | PATCH /api/social/requests/:id | Recipient's home instance | Recipient -> Sender's instance |
|
||||||
|
| `friend_request_cancel` | DELETE /api/social/requests/:id | Sender's home instance | Sender -> Recipient's instance |
|
||||||
|
| `friend_add` | PATCH /api/social/requests/:id (accepted) | Recipient's home instance | Recipient -> Sender's instance |
|
||||||
|
| `friend_remove` | DELETE /api/social/friends/:id | Either side's instance | Remover -> Other's instance |
|
||||||
|
|
||||||
|
### Relay Payload Structure
|
||||||
|
|
||||||
|
All friend events use the `friendship` field of `FederationRelayEvent`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface FederationFriendshipPayload {
|
||||||
|
from: { homeUserId: string; homeInstance: string }; // Request sender
|
||||||
|
to: { homeUserId: string; homeInstance: string }; // Request recipient
|
||||||
|
fromProfile?: FederationRelayProfileSnapshot; // Sender's profile data
|
||||||
|
toProfile?: FederationRelayProfileSnapshot; // Recipient's profile data
|
||||||
|
status?: 'pending' | 'accepted' | 'declined'; // Request status (omitted for add/remove/cancel)
|
||||||
|
createdAt: number; // Epoch ms
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Profile snapshots (`FederationRelayProfileSnapshot`) carry `{ username, displayName, avatar, avatarColor, banner, bio }` for hydrating replicated user stubs on the receiving instance.
|
||||||
|
|
||||||
|
### Identity Resolution for Relay
|
||||||
|
|
||||||
|
When building a relay event, each user's identity is resolved as:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const identity = {
|
||||||
|
homeUserId: user.homeUserId || user.id, // Canonical ID (local users have homeUserId=null)
|
||||||
|
homeInstance: user.homeInstance || getOurOrigin(), // Full URL for local users
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Target Peer Selection (`federationOutbox.ts:getFriendEventTargets`)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function getFriendEventTargets(fromHomeInstance, toHomeInstance): string[] {
|
||||||
|
const ourOrigin = getOurOrigin();
|
||||||
|
const targets = new Set<string>();
|
||||||
|
if (fromHomeInstance && fromHomeInstance !== ourOrigin) targets.add(fromHomeInstance);
|
||||||
|
if (toHomeInstance && toHomeInstance !== ourOrigin) targets.add(toHomeInstance);
|
||||||
|
return Array.from(targets);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns empty array if both users are local (no relay needed). Returns one or two peer origins if one or both users are federated.
|
||||||
|
|
||||||
|
### Context ID for Friend Events (`federationOutbox.ts:buildFriendContextId`)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function buildFriendContextId(homeUserIdA: string, homeUserIdB: string): string {
|
||||||
|
const sorted = [homeUserIdA, homeUserIdB].sort();
|
||||||
|
return `friend:${sorted[0]}:${sorted[1]}`;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Deterministic and direction-independent. Used for outbox coalescing and mutation log grouping.
|
||||||
|
|
||||||
|
### Entity ID Format
|
||||||
|
|
||||||
|
Friend events use two entity ID patterns:
|
||||||
|
- **Requests:** `friend_req:{sorted_homeUserIds}:{timestamp}` -- e.g., `friend_req:abc:xyz:1711619400000`
|
||||||
|
- **Friendships:** `friend:{sorted_homeUserIds}:{timestamp}` -- e.g., `friend:abc:xyz:1711619400000`
|
||||||
|
|
||||||
|
The sorted join ensures the same pair always produces the same prefix regardless of direction.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### End-to-End Relay Flow: Friend Request Create
|
||||||
|
|
||||||
|
**Outbound (origin instance -- `social.ts:POST /api/social/requests`):**
|
||||||
|
1. Friend request created locally (DB insert + WS broadcast to local recipient)
|
||||||
|
2. Build identity objects for sender and recipient using `homeUserId || id` / `homeInstance || getOurOrigin()`
|
||||||
|
3. Call `getFriendEventTargets(from.homeInstance, to.homeInstance)` -- if both local, returns `[]` (no relay)
|
||||||
|
4. Build `FederationRelayEvent` with `eventType: 'friend_request_create'`, `contextType: 'friend'`, friendship payload including both identities and profile snapshots
|
||||||
|
5. Call `appendMutationLog()` -- records in `federation_mutation_log` for sync protocol
|
||||||
|
6. Call `queueOutboxEvent()` -- inserts into `federation_outbox` for each target peer
|
||||||
|
|
||||||
|
**Delivery (federation worker -- `federationWorker.ts:processOutboxTick`):**
|
||||||
|
1. Worker polls outbox every 10 seconds
|
||||||
|
2. Groups pending entries by peer, builds batch `FederationRelayRequest`
|
||||||
|
3. Signs with HMAC, POSTs to `{peerOrigin}/api/federation/relay`
|
||||||
|
4. On success: deletes outbox entries. On failure: exponential backoff retry.
|
||||||
|
|
||||||
|
**Inbound (receiving instance -- `federation.ts:processFriendRequestCreateEvent`):**
|
||||||
|
1. **Validate:** `event.friendship` must exist, `from.homeInstance === sourceInstance` (authority check)
|
||||||
|
2. **Resolve sender:** `resolveOrCreateReplicatedUser(from.homeUserId, from.homeInstance)` -- creates stub if needed
|
||||||
|
3. **Hydrate sender profile:** `hydrateReplicatedUserProfile(fromUser, event.friendship.fromProfile)` -- updates stub fields
|
||||||
|
4. **Resolve recipient:** `resolveLocalUser(to.homeUserId)` -- must be a native user on this instance (returns `undefined` if not found -> reject)
|
||||||
|
5. **Idempotency checks:** If already friends -> accept as no-op. If pending request already exists from same sender -> accept as no-op.
|
||||||
|
6. **Create request:** Insert `friend_requests` row with local IDs
|
||||||
|
7. **WS broadcast:** `friend_request_received` sent to local recipient with sender's sanitized profile
|
||||||
|
8. Push `event.messageId` to accepted array
|
||||||
|
|
||||||
|
### End-to-End Relay Flow: Friend Request Update (Accept/Decline)
|
||||||
|
|
||||||
|
**Outbound (`social.ts:PATCH /api/social/requests/:id`):**
|
||||||
|
1. Local request status updated (+ friendship row if accepted)
|
||||||
|
2. Queues `friend_request_update` with `status: 'accepted' | 'declined'`
|
||||||
|
3. If accepted, also queues `friend_add` event (two separate outbox entries)
|
||||||
|
|
||||||
|
**Inbound (`federation.ts:processFriendRequestUpdateEvent`):**
|
||||||
|
1. **Authority:** `to.homeInstance === sourceInstance` -- the recipient's instance sends the update
|
||||||
|
2. **Resolve sender:** `resolveLocalUser(from.homeUserId)` -- must be local (they sent the original request from this instance)
|
||||||
|
3. **Resolve recipient:** `resolveOrCreateReplicatedUser(to.homeUserId, to.homeInstance)` -- create stub if needed
|
||||||
|
4. **Find pending request:** Matches `fromId = fromUser.id`, `toId = toUser.id`, `status = 'pending'`
|
||||||
|
5. If no pending request found -> accept idempotently (friend_add may have arrived first)
|
||||||
|
6. Update request status
|
||||||
|
7. **WS broadcast:** `friend_request_accepted` (with Friend payload) or `friend_request_declined` sent to local sender
|
||||||
|
|
||||||
|
### End-to-End Relay Flow: Friend Request Cancel
|
||||||
|
|
||||||
|
**Outbound (`social.ts:DELETE /api/social/requests/:id`):**
|
||||||
|
1. Local request deleted
|
||||||
|
2. Queues `friend_request_cancel`
|
||||||
|
|
||||||
|
**Inbound (`federation.ts:processFriendRequestCancelEvent`):**
|
||||||
|
1. **Authority:** `from.homeInstance === sourceInstance` -- the sender cancels their own request
|
||||||
|
2. **Resolve both users:** `resolveLocalUser()` for both -- if either doesn't exist, accept idempotently
|
||||||
|
3. Find and **delete** the pending request row
|
||||||
|
4. **WS broadcast:** `friend_request_cancelled` to local recipient
|
||||||
|
|
||||||
|
### End-to-End Relay Flow: Friend Add
|
||||||
|
|
||||||
|
**Outbound:** Queued alongside `friend_request_update` (accepted) from `social.ts:PATCH`.
|
||||||
|
|
||||||
|
**Inbound (`federation.ts:processFriendAddEvent`):**
|
||||||
|
1. **Authority:** `to.homeInstance === sourceInstance` -- the accepting side creates the friendship
|
||||||
|
2. **Resolve both users:** `resolveOrCreateReplicatedUser()` for both, hydrate profiles from snapshots
|
||||||
|
3. **Idempotency:** If friendship row already exists, accept as no-op
|
||||||
|
4. Insert `friends` row
|
||||||
|
5. **Auto-resolve pending requests:** Updates any pending request between these users to `'accepted'` (handles friend_add arriving before friend_request_update due to delivery ordering)
|
||||||
|
6. **Determine local user:** Compare `from.homeInstance` against `getOurOrigin()` to find who is local
|
||||||
|
7. **WS broadcast:** `friend_request_accepted` sent to local user with remote user's profile (uses empty string for `requestId` since the original request may not exist locally yet)
|
||||||
|
|
||||||
|
### End-to-End Relay Flow: Friend Remove
|
||||||
|
|
||||||
|
**Outbound (`social.ts:DELETE /api/social/friends/:id`):**
|
||||||
|
1. Local friendship deleted
|
||||||
|
2. Queues `friend_remove`
|
||||||
|
|
||||||
|
**Inbound (`federation.ts:processFriendRemoveEvent`):**
|
||||||
|
1. **Authority:** Either `from.homeInstance === sourceInstance` OR `to.homeInstance === sourceInstance` (either side can unfriend)
|
||||||
|
2. **Resolve both users:** `resolveLocalUser()` for both -- if either doesn't exist, accept idempotently
|
||||||
|
3. Delete friendship row in both directions
|
||||||
|
4. **Determine who was removed:** The removing user is on `sourceInstance`; broadcast `friend_removed` to the **other** (local) user
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Initial Sync: Friend Backfill
|
||||||
|
|
||||||
|
When a new peer is established (`federation_peers.lastSyncedAt = 0`), the federation worker runs `runInitialSyncForNewPeers()` on startup. This includes a dedicated friend sync pass.
|
||||||
|
|
||||||
|
**Flow (`federationWorker.ts:runInitialSyncForNewPeers`):**
|
||||||
|
|
||||||
|
1. Query all active peers with `lastSyncedAt = 0`
|
||||||
|
2. **First pass (DM events):** Paginates through `POST /federation/sync` with no `contextType` filter (defaults to DM events), relaying each batch through the local `/api/federation/relay` endpoint
|
||||||
|
3. **Second pass (friend events):** Paginates through `POST /federation/sync` with `contextType: 'friend'`, same relay-to-self pattern
|
||||||
|
4. After both passes complete, updates `lastSyncedAt = Date.now()` so the sync doesn't repeat
|
||||||
|
|
||||||
|
The sync endpoint (`POST /api/federation/sync`) returns events from the `federation_mutation_log` table, which retains entries for 90 days. This means friend relationships established within the last 90 days are backfilled when a new peer connection is created.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Client-Side: socialStore
|
||||||
|
|
||||||
|
Source: `packages/web/src/stores/socialStore.ts`
|
||||||
|
|
||||||
|
### Origin Tagging
|
||||||
|
|
||||||
|
All friends and requests are tagged with `_instanceOrigin: string` (empty string = home instance, full URL = remote instance). This enables the store to track which API client to use for mutations and to disambiguate users with the same local ID on different instances.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type TaggedFriend = Friend & { _instanceOrigin: string };
|
||||||
|
type TaggedFriendRequest = FriendRequest & { _instanceOrigin: string };
|
||||||
|
type TaggedUser = User & { _instanceOrigin: string };
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cross-Instance Friend Loading (`loadFriends`)
|
||||||
|
|
||||||
|
1. Gets connected instances from `instanceStore`
|
||||||
|
2. Fires `Promise.allSettled()` with:
|
||||||
|
- Home instance: `api.social.friends()`
|
||||||
|
- Each connected remote instance: `inst.api.social.friends()`
|
||||||
|
3. **Deduplication:** Uses a `Set<string>` keyed by `${friend.id}:${origin}` -- prevents duplicates within the same instance
|
||||||
|
4. **Asset normalization:** For remote-origin friends, calls `normalizeUserAssets(friend, origin)` to resolve relative avatar/banner URLs to absolute remote URLs
|
||||||
|
5. Stores the merged, tagged array as `friends`
|
||||||
|
|
||||||
|
### Cross-Instance Request Loading (`loadRequests`)
|
||||||
|
|
||||||
|
Same `Promise.allSettled()` fan-out pattern as `loadFriends`, with same dedup key: `${request.id}:${origin}`. Normalizes assets for remote request user profiles.
|
||||||
|
|
||||||
|
### Sending Friend Requests (Federation Routing)
|
||||||
|
|
||||||
|
`sendFriendRequest(username: string)` handles the `user@domain` routing:
|
||||||
|
|
||||||
|
1. **No `@` in username:** Send to home instance API directly
|
||||||
|
2. **`@` present:** Extract `baseName` and `domain` via `lastIndexOf('@')`
|
||||||
|
- If domain matches `window.location.host`: strip domain, send to home API
|
||||||
|
- Otherwise: find matching connected instance by comparing `new URL(inst.origin).host === domain`
|
||||||
|
- **Not found:** Throw `InstanceNotConnectedError(domain)` (UI prompts to connect)
|
||||||
|
- **Found but disconnected:** Throw `InstanceDisconnectedError(domain)` (UI prompts to reconnect)
|
||||||
|
- **Found and connected:** Send to remote instance API with just the `baseName` (no domain suffix)
|
||||||
|
3. After success, reloads requests via `loadRequests()`
|
||||||
|
|
||||||
|
### Cross-Instance Search (`searchUsers`)
|
||||||
|
|
||||||
|
1. Fires parallel searches to home + all connected instances
|
||||||
|
2. **Deduplication by canonical identity:** Uses `Map<string, number>` keyed by `user.homeUserId ?? user.id`
|
||||||
|
- First occurrence wins, but **native profiles replace replicated stubs**: if a native profile (`homeUserId` is null) is found for a canonical ID that was previously seen as a replicated stub, it replaces the entry
|
||||||
|
- This ensures the user sees the "real" profile rather than a replicated copy
|
||||||
|
|
||||||
|
### Instance API Resolution (`getApiForOrigin`)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function getApiForOrigin(origin: string) {
|
||||||
|
if (!origin) return api; // Home instance
|
||||||
|
const instance = useInstanceStore.getState().instances.find(i => i.origin === origin);
|
||||||
|
return instance?.api ?? api; // Fallback to home if not found
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Used by `updateFriendRequest`, `cancelFriendRequest`, and `removeFriend` to route mutations to the correct instance.
|
||||||
|
|
||||||
|
### WS Event Handlers
|
||||||
|
|
||||||
|
From `useWebSocket.ts`, social events are dispatched to store methods:
|
||||||
|
|
||||||
|
| WS Event | Store Method | Effect |
|
||||||
|
|----------|-------------|--------|
|
||||||
|
| `friend_request_received` | `addIncomingRequest(request, origin)` | Appends to requests (dedup check by `id:origin`) |
|
||||||
|
| `friend_request_accepted` | `addFriendFromAccepted(friend, requestId, origin)` | Appends to friends, removes matching request |
|
||||||
|
| `friend_removed` | `removeFriendLocally(userId, origin)` | Filters friend out by `id` + `origin` |
|
||||||
|
| `friend_request_cancelled` | `removeRequestById(requestId, origin)` | Filters request out by `id` + `origin` |
|
||||||
|
| `friend_request_declined` | `removeRequestById(requestId, origin)` | Filters request out by `id` + `origin` |
|
||||||
|
|
||||||
|
All handlers also update `discoverStore` relationship state via lazy import.
|
||||||
|
|
||||||
|
### Live Updates
|
||||||
|
|
||||||
|
| WS Event | Store Method | Effect |
|
||||||
|
|----------|-------------|--------|
|
||||||
|
| `presence_update` | `updateFriendPresence(userId, status)` | Updates `status` field on matching friend by ID (all origins) |
|
||||||
|
| `user_updated` | `updateFriendProfile(user)` | Updates displayName, avatar, banner, accentColor, avatarColor, bio, customStatus, status on matching friend by ID |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Client-Side: discoverStore
|
||||||
|
|
||||||
|
Source: `packages/web/src/stores/discoverStore.ts`
|
||||||
|
|
||||||
|
### Federation-Aware Initialization Guard
|
||||||
|
|
||||||
|
`fetchUsers()` includes a critical guard that waits for `instanceStore._autoConnectDone` before proceeding:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
if (!useInstanceStore.getState()._autoConnectDone) {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const unsub = useInstanceStore.subscribe((state) => {
|
||||||
|
if (state._autoConnectDone) { unsub(); resolve(); }
|
||||||
|
});
|
||||||
|
// Double-check (race condition guard)
|
||||||
|
if (useInstanceStore.getState()._autoConnectDone) { unsub(); resolve(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This ensures the discover page doesn't fire requests before all remote instance connections are established, which would miss remote users.
|
||||||
|
|
||||||
|
### Multi-Instance Fan-Out
|
||||||
|
|
||||||
|
1. Fires `Promise.allSettled()` to home + all connected instances' `api.social.discover(query)`
|
||||||
|
2. Tags each user with `_instanceOrigin`
|
||||||
|
3. **Deduplication:** By `${user.id}:${origin}` -- since the server already excludes replicated stubs from discover results, cross-instance dedup is minimal (only needed for edge cases)
|
||||||
|
4. Sums `total` from all instances
|
||||||
|
5. **Error handling:** If no instances respond, sets error `'Failed to reach any instance for discovery'`
|
||||||
|
|
||||||
|
### State Shape
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface DiscoverState {
|
||||||
|
users: TaggedDiscoverUser[]; // Origin-tagged discover users
|
||||||
|
searchQuery: string; // Current search term
|
||||||
|
isLoading: boolean;
|
||||||
|
total: number; // Sum across all instances
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Relationship Updates
|
||||||
|
|
||||||
|
`updateRelationship(userId, origin, relationship, requestId?)` -- Updates a specific user's relationship status in-place. Called from:
|
||||||
|
- `UserDiscoverCard` after sending/cancelling/accepting friend requests
|
||||||
|
- WS event handlers (friend_request_accepted, friend_removed, friend_request_cancelled, friend_request_declined)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Client-Side: Mutuals
|
||||||
|
|
||||||
|
Source: `packages/web/src/utils/mutuals.ts`
|
||||||
|
|
||||||
|
### `loadFederatedMutuals(targetUserId, targetHomeUserId?)`
|
||||||
|
|
||||||
|
Follows the same `Promise.allSettled()` fan-out pattern:
|
||||||
|
|
||||||
|
1. Computes `canonicalHomeId = targetHomeUserId ?? targetUserId`
|
||||||
|
2. Fires `api.users.getMutuals(targetUserId, canonicalHomeId)` to home + all connected instances
|
||||||
|
3. **Friend dedup:** By canonical identity `friend.homeUserId ?? friend.id` (prevents the same friend appearing from multiple instances)
|
||||||
|
4. **Space dedup:** By `${space.id}:${origin}` (spaces on different instances are distinct entities)
|
||||||
|
5. **Asset normalization:** Remote-origin friend avatars and space icons are resolved to absolute URLs
|
||||||
|
|
||||||
|
### Types
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type TaggedMutualFriend = User & { _instanceOrigin: string };
|
||||||
|
interface MutualSpace {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
icon: string | null;
|
||||||
|
avatarColor: string | null;
|
||||||
|
_instanceOrigin: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Client-Side: Identity Utilities
|
||||||
|
|
||||||
|
Source: `packages/web/src/utils/identity.ts`
|
||||||
|
|
||||||
|
### `parseFederatedUsername(username)`
|
||||||
|
|
||||||
|
Splits `"youruser@nova.ddns.net"` into `{ baseName: "youruser", domain: "nova.ddns.net" }`. Uses `indexOf('@')` (first occurrence). Returns `{ baseName: username, domain: null }` for non-federated usernames.
|
||||||
|
|
||||||
|
### `isSelf(user, homeUser)`
|
||||||
|
|
||||||
|
Determines if a user object represents the current user (including cross-instance replicas):
|
||||||
|
1. Same `id` -> true
|
||||||
|
2. `user.id` in `_knownSelfIds` set (populated from WS `ready` events) -> true
|
||||||
|
3. `user.homeInstance === window.location.host` AND base username matches -> true
|
||||||
|
|
||||||
|
### `canonicalUserMatch(a, b)`
|
||||||
|
|
||||||
|
Federation-safe identity comparison with cascading strategies:
|
||||||
|
1. Same `id` -> true
|
||||||
|
2. `homeUserId` cross-matching: `a.homeUserId === b.homeUserId`, or `a.homeUserId === b.id`, or `b.homeUserId === a.id` -> true
|
||||||
|
3. **Username + homeInstance fallback:** Parse base names, compare home instances (accounting for null = local)
|
||||||
|
|
||||||
|
Used by `UserProfileModal:getFriendshipStatus()` to find the correct friend/request for a viewed user across instances.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. FriendsPage UI
|
||||||
|
|
||||||
|
Source: `packages/web/src/components/chat/FriendsPage.tsx`
|
||||||
|
|
||||||
|
### Tabs
|
||||||
|
|
||||||
|
| Tab | Content | Key Behavior |
|
||||||
|
|-----|---------|--------------|
|
||||||
|
| Online | Online friends only | Filters by `status !== 'offline'` |
|
||||||
|
| All | Complete friend list | No filter |
|
||||||
|
| Pending | Incoming + outgoing requests | Split into sections; incoming shows badge count in tab |
|
||||||
|
| Add Friend | Search + discover grid | Unified search/discover with direct-add |
|
||||||
|
| Activity | Friends grouped by activity | Active (rich presence) / Online (no activity) / Offline sections |
|
||||||
|
|
||||||
|
### Add Friend Tab: Dual-Mode Search
|
||||||
|
|
||||||
|
The Add Friend tab merges search and discovery into a single UI:
|
||||||
|
|
||||||
|
1. **Empty query:** Shows discover grid (from `discoverStore.fetchUsers()`, loaded on mount)
|
||||||
|
2. **Query entered:** Switches to search mode (debounced 300ms, uses `socialStore.searchUsers()`)
|
||||||
|
3. **Direct Add detection:** If query contains `@` (at non-edge position), shows a "Send friend request to user@domain" action row
|
||||||
|
|
||||||
|
### Search Result Enrichment
|
||||||
|
|
||||||
|
Raw search results (`User[]`) are enriched at render time into `TaggedDiscoverUser[]` by checking against the current `friends` and `requests` arrays in `socialStore`:
|
||||||
|
- If friend -> `relationship: 'friends'`
|
||||||
|
- If outbound pending request -> `relationship: 'outbound_pending'` with `requestId`
|
||||||
|
- If inbound pending request -> `relationship: 'inbound_pending'` with `requestId`
|
||||||
|
- Otherwise -> `relationship: 'none'`
|
||||||
|
|
||||||
|
Self-exclusion uses a precomputed `Set<string>` of `${id}:${origin}` for the current user across all connected instances.
|
||||||
|
|
||||||
|
### UserDiscoverCard
|
||||||
|
|
||||||
|
Renders a card with banner, avatar, display name, username, bio, mutual counts, instance badge (for remote users), and a context-sensitive action button:
|
||||||
|
- `none`: "Send Friend Request"
|
||||||
|
- `outbound_pending`: "Request Pending" (click to cancel)
|
||||||
|
- `inbound_pending`: "Accept" / "Decline" buttons
|
||||||
|
- `friends`: "Message" button
|
||||||
|
|
||||||
|
When sending a request to a remote user, constructs `baseName@originHost` format for the username. Handles `InstanceNotConnectedError` / `InstanceDisconnectedError` by showing `ConnectInstanceModal`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. UserProfileModal
|
||||||
|
|
||||||
|
Source: `packages/web/src/components/modals/UserProfileModal.tsx`
|
||||||
|
|
||||||
|
### Friendship Status Resolution
|
||||||
|
|
||||||
|
Uses `getFriendshipStatus()` with `canonicalUserMatch()` for federation-safe matching:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function getFriendshipStatus(viewedUser, currentUser, friends, requests): FriendshipStatus
|
||||||
|
→ { state: 'self' } // isSelf() check
|
||||||
|
| { state: 'friends', friend } // canonicalUserMatch against friends list
|
||||||
|
| { state: 'outbound_pending', request } // request.user matches viewed user, user.id === toId
|
||||||
|
| { state: 'inbound_pending', request } // request.user matches viewed user, user.id === fromId
|
||||||
|
| { state: 'none' }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tabs
|
||||||
|
|
||||||
|
| Tab | Content |
|
||||||
|
|-----|---------|
|
||||||
|
| About | Bio (rendered as Markdown: p, strong, em, a, br), Member Since date |
|
||||||
|
| Mutual Friends | Grid of mutual friends (from `loadFederatedMutuals`), clickable to navigate to their profile |
|
||||||
|
| Mutual Spaces | List of mutual spaces with icons, clickable to navigate to space |
|
||||||
|
|
||||||
|
### Action Buttons
|
||||||
|
|
||||||
|
Displayed in footer based on friendship state:
|
||||||
|
- Always: "Send Message" (opens/creates DM)
|
||||||
|
- `none`: "Add Friend"
|
||||||
|
- `outbound_pending`: "Cancel Request"
|
||||||
|
- `inbound_pending`: "Accept" + "Ignore" (decline)
|
||||||
|
- `friends`: "Remove Friend"
|
||||||
|
|
||||||
|
All actions route through `socialStore` methods, which handle instance routing via origin tags.
|
||||||
|
|
||||||
|
### Federation Support
|
||||||
|
|
||||||
|
- User profile is loaded via `getApiForOrigin(origin)` to fetch from the correct instance
|
||||||
|
- Banner/avatar URLs resolved through the correct API client for remote users
|
||||||
|
- Mutuals loaded via `loadFederatedMutuals()` with cross-instance fan-out
|
||||||
|
- Friend actions use `sendFriendRequest(user.username)` which handles `user@domain` routing
|
||||||
|
- `ConnectInstanceModal` shown when trying to add a friend on an unconnected instance
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Data Types
|
||||||
|
|
||||||
|
### Friend (shared)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface Friend {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
displayName: string | null;
|
||||||
|
avatar: string | null;
|
||||||
|
banner: string | null;
|
||||||
|
accentColor: string | null;
|
||||||
|
avatarColor: AvatarColor | null;
|
||||||
|
bio: string | null;
|
||||||
|
status: UserStatus;
|
||||||
|
customStatus: string | null;
|
||||||
|
createdAt: number;
|
||||||
|
addedAt: number; // From friends.createdAt
|
||||||
|
homeUserId: string | null;
|
||||||
|
homeInstance: string | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### FriendRequest (shared)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface FriendRequest {
|
||||||
|
id: string;
|
||||||
|
fromId: string;
|
||||||
|
toId: string;
|
||||||
|
status: 'pending' | 'accepted' | 'declined';
|
||||||
|
createdAt: number;
|
||||||
|
user?: User; // The OTHER party (sender for incoming, recipient for outgoing)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### DiscoverUser (shared)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface DiscoverUser {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
displayName: string | null;
|
||||||
|
avatar: string | null;
|
||||||
|
banner: string | null;
|
||||||
|
avatarColor: AvatarColor | null;
|
||||||
|
bio: string | null;
|
||||||
|
status: UserStatus;
|
||||||
|
customStatus: string | null;
|
||||||
|
createdAt: number;
|
||||||
|
homeInstance: string | null;
|
||||||
|
homeUserId: string | null;
|
||||||
|
mutualFriendCount: number;
|
||||||
|
mutualSpaceCount: number;
|
||||||
|
relationship: 'none' | 'friends' | 'outbound_pending' | 'inbound_pending';
|
||||||
|
requestId?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,705 @@
|
|||||||
|
# Space & Membership System
|
||||||
|
|
||||||
|
Source files:
|
||||||
|
- `packages/server/src/routes/spaces.ts` — Space CRUD, invite, join, members, roles, bans, ownership transfer, invite preview
|
||||||
|
- `packages/server/src/routes/channels.ts` — Channel CRUD, category CRUD, channel layout reordering, channel/category permission overrides
|
||||||
|
- `packages/server/src/routes/explore.ts` — Discovery listing, public join, join request workflow
|
||||||
|
- `packages/server/src/routes/users.ts` — Space layout (sidebar folders/ordering) persistence via `PUT /api/users/@me/space-layout`
|
||||||
|
- `packages/web/src/stores/spaceStore.ts` — Client-side space state, multi-instance merge, LWW layout sync
|
||||||
|
- `packages/web/src/stores/exploreStore.ts` — Explore page state, multi-instance discovery aggregation
|
||||||
|
- `packages/web/src/components/modals/CreateSpace.tsx` — Space creation modal (icon crop, color, visibility)
|
||||||
|
- `packages/web/src/components/modals/JoinSpace.tsx` — Join-by-code modal with federation connect phases
|
||||||
|
- `packages/web/src/components/modals/InviteModal.tsx` — Invite link generation and copy
|
||||||
|
- `packages/web/src/components/modals/TransferOwnershipModal.tsx` — Ownership transfer member picker
|
||||||
|
- `packages/web/src/components/modals/SpaceSettings.tsx` — Space settings: overview, discovery, members, roles, bans
|
||||||
|
- `packages/web/src/components/JoinPage.tsx` — Public invite landing page with federation redirect
|
||||||
|
- `packages/web/src/hooks/useDragManager.ts` — Channel/category/voice-user drag-and-drop
|
||||||
|
- `packages/web/src/utils/inviteParser.ts` — Invite code/URL/qualified-code parser
|
||||||
|
|
||||||
|
Cross-references: [database.md](database.md) (table schemas), [permissions.md](permissions.md) (resolution algorithm, override tiers), [websocket.md](websocket.md) (event types), [federation.md](federation.md) (peer relay), [voice.md](voice.md) (voice channel join)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Space Lifecycle
|
||||||
|
|
||||||
|
### Creation
|
||||||
|
|
||||||
|
**Endpoint:** `POST /api/spaces` (`spaces.ts:spaceRoutes`)
|
||||||
|
**Auth:** Required
|
||||||
|
**Permission:** Any authenticated user
|
||||||
|
|
||||||
|
**Request body (`CreateSpaceRequest`):**
|
||||||
|
|
||||||
|
| Field | Type | Required | Validation |
|
||||||
|
|-------|------|----------|------------|
|
||||||
|
| name | string | yes | trimmed, 1-100 chars |
|
||||||
|
| icon | string | no | Upload filename |
|
||||||
|
| banner | string | no | Upload filename |
|
||||||
|
| avatarColor | AvatarColor | no | Must be in `AVATAR_COLORS`; random if omitted |
|
||||||
|
| visibility | SpaceVisibility | no | `'public'` / `'request'` / `'private'`; defaults to `'private'` |
|
||||||
|
| description | string | no | trimmed, max 200 chars |
|
||||||
|
|
||||||
|
**AVATAR_COLORS:** `['mint', 'sky', 'lavender', 'coral', 'rose', 'teal', 'amber']`
|
||||||
|
|
||||||
|
**Atomic transaction creates:**
|
||||||
|
|
||||||
|
| Entity | ID | Details |
|
||||||
|
|--------|----|---------|
|
||||||
|
| Space | `spaceId` (snowflake) | With `inviteCode` = `crypto.randomBytes(4).toString('hex')` (8 hex chars) |
|
||||||
|
| Owner membership | `(spaceId, userId)` | Creator auto-joined |
|
||||||
|
| Category: "text-channels" | `textCategoryId` (snowflake) | position 0 |
|
||||||
|
| Category: "voice-channels" | `voiceCategoryId` (snowflake) | position 1 |
|
||||||
|
| Channel: "general" (text) | `channelId` (snowflake) | position 0, in text-channels category |
|
||||||
|
| Channel: "voice" (voice) | `voiceChannelId` (snowflake) | position 0, in voice-channels category |
|
||||||
|
| @everyone role | id = `spaceId` | `DEFAULT_EVERYONE_PERMISSIONS`, position 0, color `#b9bbbe` |
|
||||||
|
|
||||||
|
**DEFAULT_EVERYONE_PERMISSIONS bits:** VIEW_CHANNEL, SEND_MESSAGES, CREATE_INVITE, CONNECT, SPEAK, ATTACH_FILES, READ_MESSAGE_HISTORY, ADD_REACTIONS, STREAM
|
||||||
|
|
||||||
|
**Post-transaction:**
|
||||||
|
1. `connectionManager.addUserSpace(userId, spaceId)` — registers creator for WS broadcasts
|
||||||
|
2. Icon/banner attachment records cleaned up (reference now in `spaces` table)
|
||||||
|
3. Icon/banner resized via `resizeProfileImage(filePath, 'icon'|'banner')`
|
||||||
|
|
||||||
|
**Response:** 201, `Space` object
|
||||||
|
|
||||||
|
### Read
|
||||||
|
|
||||||
|
**List user's spaces:** `GET /api/spaces` — returns all spaces where user is a member. Response: `Space[]`.
|
||||||
|
|
||||||
|
**Get space detail:** `GET /api/spaces/:id` — membership required. Returns `SpaceWithChannelsAndMembers`:
|
||||||
|
- Channels filtered by `VIEW_CHANNEL` permission per-channel (computed per-user)
|
||||||
|
- Each channel includes `isPrivate` (true if @everyone has VIEW_CHANNEL deny override) and `myPermissions`
|
||||||
|
- Categories include `isPrivate` flag
|
||||||
|
- Roles include `permissions` field only if requesting user has `MANAGE_ROLES`
|
||||||
|
- `myPermissions` at space level included
|
||||||
|
|
||||||
|
### Update
|
||||||
|
|
||||||
|
**Endpoint:** `PATCH /api/spaces/:id`
|
||||||
|
**Permission:** `MANAGE_SPACE`
|
||||||
|
|
||||||
|
**Updatable fields:** name (1-100 chars), icon, banner, avatarColor (validated against AVATAR_COLORS), visibility (public/request/private), description (max 200 chars).
|
||||||
|
|
||||||
|
**Side effects:**
|
||||||
|
- Old icon/banner files deleted from disk when replaced
|
||||||
|
- New icon/banner resized via `resizeProfileImage`
|
||||||
|
- Attachment records cleaned up for newly-set images
|
||||||
|
- `space_updated` WS event broadcast to all space members
|
||||||
|
|
||||||
|
### Delete
|
||||||
|
|
||||||
|
**Endpoint:** `DELETE /api/spaces/:id`
|
||||||
|
**Permission:** Owner only (`isSpaceOwner` check)
|
||||||
|
|
||||||
|
**Transaction deletes (in order):**
|
||||||
|
1. `read_states` for all channels in the space (no FK cascade)
|
||||||
|
2. All channels (messages cascade via FK)
|
||||||
|
3. All `space_members`
|
||||||
|
4. All `space_folder_members` referencing this space
|
||||||
|
5. The space itself
|
||||||
|
|
||||||
|
**Post-transaction:** All attachment files and space icon/banner files deleted from disk.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Invite System
|
||||||
|
|
||||||
|
### Invite Code Generation
|
||||||
|
|
||||||
|
**Endpoint:** `POST /api/spaces/:id/invite`
|
||||||
|
**Permission:** `CREATE_INVITE`
|
||||||
|
|
||||||
|
**Behavior:** Returns existing `inviteCode` if one exists. Only generates a new one (`crypto.randomBytes(4).toString('hex')`) if the space has no invite code. Invite codes are permanent (no expiration).
|
||||||
|
|
||||||
|
**Response:** `{ inviteCode: string }`
|
||||||
|
|
||||||
|
### Invite URL Format
|
||||||
|
|
||||||
|
Generated by `InviteModal.tsx`:
|
||||||
|
- **Web:** `{instanceOrigin}/join/{inviteCode}` (e.g., `https://nova.ddns.net/join/a3f1b2c4`)
|
||||||
|
- **Deep link (Electron):** `backspace://join/{inviteCode}` or `backspace://join/{inviteCode}@{host}` for remote instances
|
||||||
|
|
||||||
|
### Invite Code Parser (`inviteParser.ts`)
|
||||||
|
|
||||||
|
Parses three input formats into `{ code: string; origin?: string }`:
|
||||||
|
|
||||||
|
| Format | Example | Parsed |
|
||||||
|
|--------|---------|--------|
|
||||||
|
| Bare code | `a3f1b2c4` | `{ code: 'a3f1b2c4' }` |
|
||||||
|
| Full URL | `https://remote.com/join/a3f1b2c4` | `{ code: 'a3f1b2c4', origin: 'https://remote.com' }` |
|
||||||
|
| Qualified code | `a3f1b2c4@remote.com` | `{ code: 'a3f1b2c4', origin: 'https://remote.com' }` |
|
||||||
|
|
||||||
|
If the parsed origin matches `window.location.origin`, it is treated as a bare code (origin stripped).
|
||||||
|
|
||||||
|
### Invite Preview
|
||||||
|
|
||||||
|
**Endpoint:** `GET /api/spaces/invite/:code/preview` (no auth required)
|
||||||
|
|
||||||
|
**Response (`InvitePreview`):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
spaceId: string;
|
||||||
|
spaceName: string;
|
||||||
|
description: string | null;
|
||||||
|
icon: string | null;
|
||||||
|
avatarColor: AvatarColor | null;
|
||||||
|
memberCount: number; // live count from space_members
|
||||||
|
instanceName: string; // from instance_settings
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Join by Invite Code
|
||||||
|
|
||||||
|
Two endpoints serve the same purpose:
|
||||||
|
|
||||||
|
| Endpoint | Use case |
|
||||||
|
|----------|----------|
|
||||||
|
| `POST /api/spaces/:id/join` | Join when spaceId is known (body: `{ inviteCode }`) |
|
||||||
|
| `POST /api/spaces/join` | Join by code only, spaceId looked up from `inviteCode` |
|
||||||
|
|
||||||
|
**Validations:** invite code match, not banned, not already a member.
|
||||||
|
|
||||||
|
**Side effects:**
|
||||||
|
1. Insert `space_members` row
|
||||||
|
2. `connectionManager.addUserSpace` for WS broadcasts
|
||||||
|
3. `member_joined` WS event broadcast to space
|
||||||
|
4. Response: `Space` object
|
||||||
|
|
||||||
|
### Join Page (`JoinPage.tsx`)
|
||||||
|
|
||||||
|
Public route at `/join/:inviteCode`. Handles five phases:
|
||||||
|
|
||||||
|
| Phase | Trigger | UI |
|
||||||
|
|-------|---------|-----|
|
||||||
|
| `preview` | Initial load | Space preview card + join button (auth) or login/register links (unauth) |
|
||||||
|
| `connect` | `NotConnectedError` on join attempt | Password prompt for federation connect |
|
||||||
|
| `fallback` | `DifferentPasswordError` on connect | Username + password for existing remote account |
|
||||||
|
| `other-instance` | User clicks "I use another instance" | Domain input for federation redirect |
|
||||||
|
| `already-member` | Join returns "already a member" | Green checkmark + auto-redirect (2s timer) |
|
||||||
|
|
||||||
|
**Federation redirect flow (other-instance):**
|
||||||
|
1. User enters their home domain (e.g., `my-instance.com`)
|
||||||
|
2. Constructs qualified invite: `{code}@{currentHost}`
|
||||||
|
3. Redirects to `https://{domain}/join/{qualifiedCode}`
|
||||||
|
4. Their home instance's JoinPage receives the qualified code, parses origin, and handles federation connect
|
||||||
|
|
||||||
|
**Preview fetching:** For remote invites, creates a temporary API client via `createApiClient(origin, () => null)` to fetch the preview without authentication.
|
||||||
|
|
||||||
|
### Join Space Modal (`JoinSpaceModal`)
|
||||||
|
|
||||||
|
Modal with three phases matching JoinPage but in-app:
|
||||||
|
- `input` — text field for invite code or URL
|
||||||
|
- `connect` — password prompt for federation
|
||||||
|
- `fallback` — different-password login for remote instance
|
||||||
|
|
||||||
|
Uses `parseInviteInput` to parse, then `joinByCode(code, origin?)` from spaceStore.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Discovery System
|
||||||
|
|
||||||
|
### Space Visibility
|
||||||
|
|
||||||
|
| Value | Explore listing | Join mechanism |
|
||||||
|
|-------|----------------|----------------|
|
||||||
|
| `private` | Not listed | Invite code only |
|
||||||
|
| `request` | Listed | Submit join request, requires approval |
|
||||||
|
| `public` | Listed | Instant join, no invite needed |
|
||||||
|
|
||||||
|
### Explore Endpoint
|
||||||
|
|
||||||
|
**Endpoint:** `GET /api/spaces/explore` (`explore.ts:exploreRoutes`)
|
||||||
|
**Auth:** Required
|
||||||
|
**Query params:** `q` (search), `limit` (1-100, default 50), `offset` (default 0)
|
||||||
|
|
||||||
|
**Instance-level gate:** Checks `instance_settings.discoveryEnabled`. If false, returns `{ spaces: [], total: 0, discoveryEnabled: false }`.
|
||||||
|
|
||||||
|
**Query:** Raw SQL with LEFT JOIN on `space_members` for member count. Filters to `visibility IN ('public', 'request')`. Search matches `name` or `description` (LIKE, case-insensitive). Ordered by `member_count DESC, created_at DESC`.
|
||||||
|
|
||||||
|
**Response (`ExploreSpace[]`):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
icon: string | null;
|
||||||
|
banner: string | null;
|
||||||
|
avatarColor: AvatarColor | null;
|
||||||
|
description: string | null;
|
||||||
|
visibility: 'public' | 'request';
|
||||||
|
memberCount: number;
|
||||||
|
createdAt: number;
|
||||||
|
joined: boolean; // true if requesting user is already a member
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Also returns `total` (filtered count), `totalAll` (all discoverable), `discoveryEnabled`.
|
||||||
|
|
||||||
|
### Multi-Instance Discovery (`exploreStore.ts`)
|
||||||
|
|
||||||
|
`fetchSpaces()` queries home + all connected remote instances in parallel:
|
||||||
|
1. Waits for `instanceStore._autoConnectDone` to avoid querying with incomplete instance list
|
||||||
|
2. `Promise.allSettled` across home API + all connected instance APIs
|
||||||
|
3. Deduplicates by `spaceId:origin` key
|
||||||
|
4. Normalizes remote asset URLs via `resolveAssetUrl`
|
||||||
|
5. Merges into `TaggedExploreSpace[]` with `_instanceOrigin`
|
||||||
|
|
||||||
|
### Public Join
|
||||||
|
|
||||||
|
**Endpoint:** `POST /api/spaces/:id/public-join`
|
||||||
|
**Validation:** Space must have `visibility === 'public'`, not banned, not already member.
|
||||||
|
|
||||||
|
**Side effects:** Same as invite join (insert member, WS broadcast, add to connectionManager).
|
||||||
|
**Response:** Full `SpaceWithChannelsAndMembers` (not just `Space`), so the client can immediately populate the store without a follow-up `GET /api/spaces/:id`.
|
||||||
|
|
||||||
|
### Join Request Workflow
|
||||||
|
|
||||||
|
**Submit request:** `POST /api/spaces/:id/request-join`
|
||||||
|
- Space must have `visibility === 'request'`
|
||||||
|
- Rate limited: 5 per minute
|
||||||
|
- Message: optional, max 500 chars
|
||||||
|
- Checks for existing pending request (409 if exists)
|
||||||
|
- Creates `join_requests` row with status `'pending'`
|
||||||
|
- Sends `join_request_received` WS event to all space managers (owner + `MANAGE_SPACE` holders)
|
||||||
|
|
||||||
|
**List requests:** `GET /api/spaces/:id/join-requests?status=pending`
|
||||||
|
- Permission: owner or `MANAGE_SPACE`
|
||||||
|
- Status filter: `pending` (default), `accepted`, `declined`
|
||||||
|
- Returns `{ requests: JoinRequest[] }` with populated user data
|
||||||
|
|
||||||
|
**Decide request:** `PATCH /api/spaces/:id/join-requests/:requestId`
|
||||||
|
- Permission: owner or `MANAGE_SPACE`
|
||||||
|
- Body: `{ action: 'accept' | 'decline' }`
|
||||||
|
- Must be pending (400 if already decided)
|
||||||
|
|
||||||
|
Accept flow (atomic transaction):
|
||||||
|
1. Insert `space_members` row
|
||||||
|
2. Update request status to `'accepted'`, set `decidedBy` and `decidedAt`
|
||||||
|
3. `connectionManager.addUserSpace`
|
||||||
|
4. Broadcast `member_joined` to space
|
||||||
|
5. Build full `SpaceWithChannelsAndMembers` for accepted user
|
||||||
|
6. Send `join_request_accepted` WS event to requesting user (includes full space data)
|
||||||
|
|
||||||
|
Decline flow:
|
||||||
|
1. Update request status to `'declined'`
|
||||||
|
2. Send `join_request_declined` WS event to requesting user
|
||||||
|
|
||||||
|
**User's own requests:** `GET /api/users/@me/join-requests?status=<optional>`
|
||||||
|
- Returns all requests for the current user, optionally filtered by status
|
||||||
|
|
||||||
|
### Space Managers Resolution (`explore.ts:getSpaceManagers`)
|
||||||
|
|
||||||
|
Used to target `join_request_received` events. Iterates all space members and returns IDs where:
|
||||||
|
- `userId === space.ownerId`, OR
|
||||||
|
- `hasPermission(userId, spaceId, PermissionBits.MANAGE_SPACE)` returns true
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Membership
|
||||||
|
|
||||||
|
### Join
|
||||||
|
|
||||||
|
Three join paths:
|
||||||
|
1. **Invite code** — `POST /api/spaces/:id/join` or `POST /api/spaces/join`
|
||||||
|
2. **Public join** — `POST /api/spaces/:id/public-join` (visibility=public)
|
||||||
|
3. **Request accept** — `PATCH /api/spaces/:id/join-requests/:requestId` with `action: 'accept'`
|
||||||
|
|
||||||
|
All paths: insert `space_members`, register in `connectionManager`, broadcast `member_joined`.
|
||||||
|
|
||||||
|
### Leave / Kick
|
||||||
|
|
||||||
|
**Endpoint:** `DELETE /api/spaces/:id/members/:uid`
|
||||||
|
|
||||||
|
| Scenario | Condition | Permission |
|
||||||
|
|----------|-----------|------------|
|
||||||
|
| Self-leave | `uid === request.userId` | Any member (unless owner) |
|
||||||
|
| Kick | `uid !== request.userId` | `KICK_MEMBERS` |
|
||||||
|
|
||||||
|
**Owner restriction:** Owner cannot leave. Must transfer ownership or delete the space.
|
||||||
|
**Owner protection:** Cannot kick the owner.
|
||||||
|
|
||||||
|
**Cleanup on removal:**
|
||||||
|
1. Delete `space_members` row
|
||||||
|
2. Delete `voice_restrictions` for the member in this space
|
||||||
|
3. Delete `read_states` for the member in all space channels
|
||||||
|
4. Broadcast `member_left` WS event
|
||||||
|
|
||||||
|
### Ban
|
||||||
|
|
||||||
|
**Endpoint:** `POST /api/spaces/:id/bans`
|
||||||
|
**Permission:** `BAN_MEMBERS`
|
||||||
|
**Body:** `{ userId: string, reason?: string }`
|
||||||
|
|
||||||
|
**Protections:** Cannot ban owner, cannot ban self, 409 if already banned.
|
||||||
|
|
||||||
|
**Atomic transaction:**
|
||||||
|
1. Insert `bans` row (with `reason`, `bannedBy`, `createdAt`)
|
||||||
|
2. Delete `space_members`
|
||||||
|
3. Delete `member_roles`
|
||||||
|
4. Delete `read_states` for all space channels
|
||||||
|
5. Delete `voice_restrictions`
|
||||||
|
|
||||||
|
**WS events:**
|
||||||
|
- `member_left` to space (so other members update their list)
|
||||||
|
- `member_banned` to the banned user (with `reason`)
|
||||||
|
|
||||||
|
**List bans:** `GET /api/spaces/:id/bans` — requires `BAN_MEMBERS`. Returns ban records with both banned user and moderator user objects.
|
||||||
|
|
||||||
|
**Unban:** `DELETE /api/spaces/:id/bans/:uid` — requires `BAN_MEMBERS`. 404 if no ban found.
|
||||||
|
|
||||||
|
### Ownership Transfer
|
||||||
|
|
||||||
|
**Endpoint:** `PATCH /api/spaces/:id/transfer-ownership`
|
||||||
|
**Permission:** Owner only
|
||||||
|
**Body:** `{ newOwnerId: string }`
|
||||||
|
**Validation:** New owner must be a member, cannot transfer to self.
|
||||||
|
|
||||||
|
Updates `spaces.ownerId`, broadcasts `space_updated` WS event.
|
||||||
|
|
||||||
|
**Client (`TransferOwnershipModal`):** Member picker with search, two-step confirm. Shows warning "You will become a regular member." Uses toast notification on success.
|
||||||
|
|
||||||
|
### Member Role Management
|
||||||
|
|
||||||
|
**Set roles (replace):** `PATCH /api/spaces/:id/members/:uid`
|
||||||
|
- Permission: `MANAGE_ROLES`
|
||||||
|
- Body: `{ roleIds: string[] }`
|
||||||
|
- Cannot change own roles
|
||||||
|
- Cannot modify owner's roles (unless you are the owner)
|
||||||
|
- @everyone role (id=spaceId) cannot be assigned
|
||||||
|
- Atomically deletes all existing `member_roles` then inserts new ones
|
||||||
|
- Triggers `connectionManager.pushReadyPayload(uid)` to force re-sync
|
||||||
|
- Triggers `checkVoicePermissions(spaceId)` to enforce voice changes
|
||||||
|
|
||||||
|
**Add single role:** `POST /api/spaces/:id/members/:uid/roles` — body `{ roleId }`, requires `MANAGE_ROLES`
|
||||||
|
|
||||||
|
**Remove single role:** `DELETE /api/spaces/:id/members/:uid/roles/:roleId` — requires `MANAGE_ROLES`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Role Management
|
||||||
|
|
||||||
|
### Create Role
|
||||||
|
|
||||||
|
**Endpoint:** `POST /api/spaces/:id/roles`
|
||||||
|
**Permission:** `MANAGE_ROLES`
|
||||||
|
**Body:** `{ name: string, color?: string, permissions?: string }`
|
||||||
|
|
||||||
|
- Name defaults to `'new role'` if empty
|
||||||
|
- Duplicate name check (case-insensitive, raw SQL COLLATE NOCASE)
|
||||||
|
- Permissions default to `DEFAULT_EVERYONE_PERMISSIONS` if not provided
|
||||||
|
- Position defaults to 0
|
||||||
|
- Color defaults to `'#b9bbbe'`
|
||||||
|
- After creation: pushes ready payload to all space members, checks voice permissions
|
||||||
|
|
||||||
|
### Update Role
|
||||||
|
|
||||||
|
**Endpoint:** `PATCH /api/spaces/:id/roles/:roleId`
|
||||||
|
**Permission:** `MANAGE_ROLES`
|
||||||
|
**Body:** `{ name?, color?, position?, permissions? }`
|
||||||
|
|
||||||
|
- Name: trimmed, non-empty, duplicate check (case-insensitive, excludes self)
|
||||||
|
- Permissions: validated as valid bigint string
|
||||||
|
- After update: pushes ready payload to all members, checks voice permissions
|
||||||
|
|
||||||
|
### Delete Role
|
||||||
|
|
||||||
|
**Endpoint:** `DELETE /api/spaces/:id/roles/:roleId`
|
||||||
|
**Permission:** `MANAGE_ROLES`
|
||||||
|
|
||||||
|
- Cannot delete @everyone role (roleId === spaceId)
|
||||||
|
- Deletes channel overrides referencing this role
|
||||||
|
- After delete: pushes ready payload to all members, checks voice permissions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Channel Management
|
||||||
|
|
||||||
|
### Channel Types
|
||||||
|
|
||||||
|
| Type | Semantics |
|
||||||
|
|------|-----------|
|
||||||
|
| `text` | Message-based channel with read states, embeds, reactions |
|
||||||
|
| `voice` | Voice/video channel (LiveKit integration, see voice.md) |
|
||||||
|
|
||||||
|
### Create Channel
|
||||||
|
|
||||||
|
**Endpoint:** `POST /api/spaces/:id/channels`
|
||||||
|
**Permission:** `MANAGE_CHANNELS`
|
||||||
|
|
||||||
|
| Field | Validation |
|
||||||
|
|-------|------------|
|
||||||
|
| name | Required, trimmed, lowercased, spaces→hyphens, 1-100 chars |
|
||||||
|
| type | Required, `'text'` or `'voice'` |
|
||||||
|
| topic | Optional, trimmed |
|
||||||
|
| categoryId | Optional, validated against space's categories |
|
||||||
|
|
||||||
|
Position: `max(existing positions) + 1`.
|
||||||
|
|
||||||
|
**Broadcast:** `channel_created` sent per-user (only to users with VIEW_CHANNEL on the new channel). Each user's event includes their computed `myPermissions`.
|
||||||
|
|
||||||
|
### Update Channel
|
||||||
|
|
||||||
|
**Endpoint:** `PATCH /api/channels/:id`
|
||||||
|
**Permission:** `MANAGE_CHANNELS` (checked with channel-level override context)
|
||||||
|
**Body:** `{ name?, topic?, position?, categoryId? }`
|
||||||
|
|
||||||
|
- Name: same normalization as create
|
||||||
|
- Position: non-negative number
|
||||||
|
- categoryId: `null` to unassign, or valid category ID in same space
|
||||||
|
|
||||||
|
**Broadcast behavior:**
|
||||||
|
- If `categoryId` changed: calls `broadcastOverrideChange` (per-user VIEW_CHANNEL recheck, may send `channel_deleted` to users who lost access)
|
||||||
|
- Otherwise: simple `channel_updated` broadcast to channel viewers
|
||||||
|
|
||||||
|
### Delete Channel
|
||||||
|
|
||||||
|
**Endpoint:** `DELETE /api/channels/:id`
|
||||||
|
**Permission:** `MANAGE_CHANNELS`
|
||||||
|
|
||||||
|
**Cleanup sequence:**
|
||||||
|
1. Disconnect all voice participants (if voice channel)
|
||||||
|
2. Collect viewer IDs before deletion (for targeted broadcast)
|
||||||
|
3. Collect attachment filenames before cascade
|
||||||
|
4. Delete `read_states` (no FK)
|
||||||
|
5. Delete `messages` (attachments cascade)
|
||||||
|
6. Delete `channels` row
|
||||||
|
7. Delete attachment files from disk
|
||||||
|
8. Broadcast `channel_deleted` only to users who could see the channel
|
||||||
|
|
||||||
|
### Category Management
|
||||||
|
|
||||||
|
**Create:** `POST /api/spaces/:id/categories` — permission: `MANAGE_CHANNELS`, name 1-100 chars, auto-position. Broadcasts `category_created`.
|
||||||
|
|
||||||
|
**Update:** `PATCH /api/categories/:id` — permission: `MANAGE_CHANNELS`, updatable: name, position. Broadcasts `category_updated` (includes `isPrivate` flag).
|
||||||
|
|
||||||
|
**Delete:** `DELETE /api/categories/:id` — permission: `MANAGE_CHANNELS`. Transaction nulls `categoryId` on child channels, then deletes category. Broadcasts `category_deleted` then `channel_layout_updated` (per-user filtered).
|
||||||
|
|
||||||
|
### Channel Layout Reorder
|
||||||
|
|
||||||
|
**Endpoint:** `PATCH /api/spaces/:id/channel-layout`
|
||||||
|
**Permission:** `MANAGE_CHANNELS`
|
||||||
|
|
||||||
|
**Body:**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
channels: Array<{ id: string; position: number; categoryId: string | null }>;
|
||||||
|
categories: Array<{ id: string; position: number }>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Validation:**
|
||||||
|
- All channel IDs must belong to the space
|
||||||
|
- All category IDs must belong to the space
|
||||||
|
- All positions must be non-negative numbers
|
||||||
|
- Channel categoryId references must point to valid space categories
|
||||||
|
|
||||||
|
Applied atomically in a transaction. Broadcasts via `broadcastChannelLayout()` which sends `channel_layout_updated` per-user (each user sees only channels they have VIEW_CHANNEL on).
|
||||||
|
|
||||||
|
### Channel/Category Permission Overrides
|
||||||
|
|
||||||
|
OUT OF SCOPE for this document. See [permissions.md](permissions.md) for the three-tier override system (category overrides, channel overrides, member overrides) and the `computePermissions` algorithm.
|
||||||
|
|
||||||
|
Override endpoints documented here for API completeness:
|
||||||
|
|
||||||
|
| Endpoint | Permission | Notes |
|
||||||
|
|----------|------------|-------|
|
||||||
|
| `GET /api/channels/:id/overrides` | `MANAGE_ROLES` | List channel overrides |
|
||||||
|
| `PUT /api/channels/:id/overrides` | `MANAGE_ROLES` | Upsert (delete+insert in tx). Privilege escalation guard. |
|
||||||
|
| `DELETE /api/channels/:id/overrides/:targetType/:targetId` | `MANAGE_ROLES` | Remove override |
|
||||||
|
| `GET /api/categories/:id/overrides` | `MANAGE_ROLES` | List category overrides |
|
||||||
|
| `PUT /api/categories/:id/overrides` | `MANAGE_ROLES` | Upsert with escalation guard |
|
||||||
|
| `DELETE /api/categories/:id/overrides/:targetType/:targetId` | `MANAGE_ROLES` | Remove override |
|
||||||
|
|
||||||
|
All override mutations call `broadcastOverrideChange` (channel) or `broadcastCategoryOverrideChange` (category) which re-evaluates VIEW_CHANNEL per-user and sends `channel_updated` (gained access) or `channel_deleted` (lost access). Voice permission enforcement via `checkVoicePermissions` runs after every override change.
|
||||||
|
|
||||||
|
### Drag-and-Drop (`useDragManager.ts`)
|
||||||
|
|
||||||
|
Client-side hook managing three drag types:
|
||||||
|
|
||||||
|
| Type | Draggable | Drop target | Permission |
|
||||||
|
|------|-----------|-------------|------------|
|
||||||
|
| `channel` | Channel items | Before/after channels or categories | `MANAGE_CHANNELS` (`canManage`) |
|
||||||
|
| `category` | Category headers | Before/after channels or categories | `MANAGE_CHANNELS` (`canManage`) |
|
||||||
|
| `voiceUser` | Voice participant | Different voice channel | `MOVE_MEMBERS` (`canMoveMembers`) |
|
||||||
|
|
||||||
|
**Drop position normalization:** "before B" is normalized to "after A" (the preceding item in `orderedItems`) to prevent double drop-indicator rendering.
|
||||||
|
|
||||||
|
**Auto-scroll:** When dragging near top/bottom edges (40px), scrolls the sidebar container proportionally to edge distance.
|
||||||
|
|
||||||
|
**Self-drop guard:** Dropping on the same item is a no-op.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Space Layout (Sidebar Ordering & Folders)
|
||||||
|
|
||||||
|
### Data Model
|
||||||
|
|
||||||
|
**Layout items (`SpaceLayoutItem`):**
|
||||||
|
```typescript
|
||||||
|
type SpaceLayoutItem =
|
||||||
|
| { t: 's'; id: string } // space reference
|
||||||
|
| { t: 'f'; id: string }; // folder reference
|
||||||
|
```
|
||||||
|
|
||||||
|
**Folders (`SpaceFolder`):**
|
||||||
|
```typescript
|
||||||
|
interface SpaceFolder {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
name: string | null;
|
||||||
|
color: string | null;
|
||||||
|
position: number;
|
||||||
|
spaceIds: string[]; // ordered list of spaces in the folder
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Server Persistence
|
||||||
|
|
||||||
|
**Tables (see database.md):** `user_space_layout`, `space_folders`, `space_folder_members`
|
||||||
|
|
||||||
|
**Endpoint:** `PUT /api/users/@me/space-layout` (`users.ts`)
|
||||||
|
|
||||||
|
**Request body:**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
items: SpaceLayoutItem[];
|
||||||
|
folders: Record<string, {
|
||||||
|
name: string | null;
|
||||||
|
color: string | null;
|
||||||
|
spaceIds: string[];
|
||||||
|
}>;
|
||||||
|
updatedAt?: number; // LWW timestamp
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Server-generated folder IDs:** Clients use `new:*` prefixed keys for new folders. The server maps each `new:*` key to a `generateSnowflake()` ID. The response returns resolved IDs so the client can update references.
|
||||||
|
|
||||||
|
**Remote folder adoption:** If a folder ID does not match an existing folder and does not start with `new:`, the server creates it with the provided ID (handles folders created on another instance being pushed to this one).
|
||||||
|
|
||||||
|
**Transaction:**
|
||||||
|
1. For each folder in request: create new / update existing / adopt remote
|
||||||
|
2. Clear and re-insert `space_folder_members` with ordered positions
|
||||||
|
3. Delete folders not in request (with their members)
|
||||||
|
4. Replace `new:*` keys in items array with resolved IDs
|
||||||
|
5. Upsert `user_space_layout` row (JSON string of items)
|
||||||
|
|
||||||
|
**WS broadcast:** `space_layout_updated` sent to the user's other connections (multi-tab sync).
|
||||||
|
|
||||||
|
**Response:** `{ items, folders, updatedAt }`
|
||||||
|
|
||||||
|
### LWW Conflict Resolution
|
||||||
|
|
||||||
|
**Server-side guard** (`users.ts`): If the request includes `updatedAt` and it is older than the stored `updatedAt`, the write is rejected and the current layout is returned without modification.
|
||||||
|
|
||||||
|
**Client-side algorithm** (`spaceStore.ts:populateFromReady`):
|
||||||
|
|
||||||
|
```
|
||||||
|
On receiving ready payload from any instance:
|
||||||
|
incomingTs = payload.layoutUpdatedAt ?? 0
|
||||||
|
currentTs = store._layoutUpdatedAt
|
||||||
|
|
||||||
|
if incomingTs >= currentTs:
|
||||||
|
Accept incoming layout (overwrite local)
|
||||||
|
_layoutUpdatedAt = incomingTs
|
||||||
|
else:
|
||||||
|
Keep local layout
|
||||||
|
Push local layout to the stale instance via pushLayoutToOrigin()
|
||||||
|
```
|
||||||
|
|
||||||
|
**`pushLayoutToOrigin(origin, layout, folders, updatedAt)`:** Calls `targetApi.spaceLayout.update()` on the specific instance that had the stale layout. This ensures all instances converge to the newest layout.
|
||||||
|
|
||||||
|
### Multi-Instance Layout Push
|
||||||
|
|
||||||
|
**`updateSpaceLayout(items, folders)`** in spaceStore:
|
||||||
|
1. Optimistically applies the layout with `Date.now()` timestamp
|
||||||
|
2. Collects all targets: home API + all connected remote instance APIs
|
||||||
|
3. `Promise.allSettled` pushes to all targets in parallel
|
||||||
|
4. Uses the first successful response to resolve `new:*` folder IDs
|
||||||
|
5. Updates store with resolved layout
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## WS Ready Payload
|
||||||
|
|
||||||
|
The space layout and folder data are delivered in the WS `ready` event (`handler.ts:buildReadyPayload`):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
spaces: SpaceWithChannelsAndMembers[];
|
||||||
|
folders: SpaceFolder[];
|
||||||
|
spaceLayout: SpaceLayoutItem[] | null;
|
||||||
|
layoutUpdatedAt: number | null;
|
||||||
|
dmChannels: DmChannel[];
|
||||||
|
// ... other fields
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`populateFromReady` merges incoming data by origin:
|
||||||
|
- Replaces all spaces from the incoming origin, keeps spaces from other origins
|
||||||
|
- Populates `channelToSpaceMap`, `channelOriginMap`, `channelPermissions`, `voiceChannelIds`, `categoryOriginMap`
|
||||||
|
- DM channels: removes existing DMs from this origin, appends incoming, deduplicates 1-on-1 DMs by canonical member pair (prefers home-origin copy)
|
||||||
|
- Applies LWW layout merge as described above
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## WS Events Summary
|
||||||
|
|
||||||
|
| Event | Direction | Trigger |
|
||||||
|
|-------|-----------|---------|
|
||||||
|
| `space_updated` | S→C (space) | Space metadata changed |
|
||||||
|
| `member_joined` | S→C (space) | New member via any join path |
|
||||||
|
| `member_left` | S→C (space) | Member left, kicked, or banned |
|
||||||
|
| `member_banned` | S→C (user) | Sent to the banned user with reason |
|
||||||
|
| `join_request_received` | S→C (user) | Sent to space managers when request submitted |
|
||||||
|
| `join_request_accepted` | S→C (user) | Sent to requester with full space data |
|
||||||
|
| `join_request_declined` | S→C (user) | Sent to requester |
|
||||||
|
| `channel_created` | S→C (per-user) | New channel, per-user VIEW_CHANNEL filter |
|
||||||
|
| `channel_updated` | S→C (per-user/channel) | Channel or override changed |
|
||||||
|
| `channel_deleted` | S→C (per-user) | Channel deleted or user lost VIEW_CHANNEL |
|
||||||
|
| `category_created` | S→C (space) | New category |
|
||||||
|
| `category_updated` | S→C (space) | Category name/position/privacy changed |
|
||||||
|
| `category_deleted` | S→C (space) | Category removed |
|
||||||
|
| `channel_layout_updated` | S→C (per-user) | Batch reorder, per-user channel filtering |
|
||||||
|
| `space_layout_updated` | S→C (user) | Sidebar layout changed (multi-tab sync) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Federation Considerations
|
||||||
|
|
||||||
|
### Instance Origin Tagging
|
||||||
|
|
||||||
|
Every space in the client store has `_instanceOrigin: string`:
|
||||||
|
- `''` (empty) = home instance
|
||||||
|
- `'https://remote.com'` = remote federated instance
|
||||||
|
|
||||||
|
All store actions resolve the correct API client via `getApiForOrigin(origin)` before making HTTP requests. The resolver is registered by `instanceStore` on import (breaks circular dependency).
|
||||||
|
|
||||||
|
### User ID Resolution
|
||||||
|
|
||||||
|
`getMyUserIdForOrigin(origin)` returns the user's ID on a specific instance:
|
||||||
|
- Home (`''`): returns `authStore.user.id`
|
||||||
|
- Remote: checks `_myUserIdByOrigin` cache (populated from WS ready events), falls back to `instanceStore` resolver
|
||||||
|
|
||||||
|
Used for self-leave (`leaveSpace` calls `removeMember` with the correct user ID for the instance).
|
||||||
|
|
||||||
|
### Remote Invite Join Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
1. User enters invite URL pointing to remote instance
|
||||||
|
2. parseInviteInput extracts code + origin
|
||||||
|
3. joinByCode(code, origin) called
|
||||||
|
4. If not connected → NotConnectedError thrown
|
||||||
|
5. JoinSpaceModal/JoinPage enters 'connect' phase
|
||||||
|
6. User provides password → connectToRemote(origin, password)
|
||||||
|
7. If password mismatch → DifferentPasswordError → 'fallback' phase
|
||||||
|
8. On success: joinByCode retried, space added to store with _instanceOrigin
|
||||||
|
```
|
||||||
|
|
||||||
|
### Asset URL Normalization
|
||||||
|
|
||||||
|
Remote space icons, banners, and member avatars are resolved via `resolveAssetUrl(path, origin)` when:
|
||||||
|
- `populateFromReady` processes spaces from a remote origin
|
||||||
|
- `loadSpaceDetail` loads a remote space
|
||||||
|
- `joinByCode` returns a remote space
|
||||||
|
- `exploreStore.fetchSpaces` processes remote explore results
|
||||||
@@ -0,0 +1,612 @@
|
|||||||
|
# File & Upload System
|
||||||
|
|
||||||
|
Source files:
|
||||||
|
- `packages/server/src/routes/uploads.ts` -- Upload endpoint (multipart reception, size enforcement) and file serving (cache, security, Range)
|
||||||
|
- `packages/server/src/utils/thumbnail.ts` -- Image thumbnail generation (sharp), video thumbnail extraction (ffmpeg), image dimension probing, profile image resizing, media metadata extraction
|
||||||
|
- `packages/server/src/utils/fileCleanup.ts` -- File deletion helpers (disk + thumbnail + attachment record cleanup)
|
||||||
|
- `packages/server/src/utils/storageJanitor.ts` -- Storage stats, orphan detection, cleanup routines (orphaned files, unlinked attachments, dangling references, old media), federation GC, soft-deleted DM channel purge
|
||||||
|
- `packages/web/src/utils/imageActions.ts` -- Client-side image save-to-disk and copy-to-clipboard actions
|
||||||
|
- `packages/web/src/utils/cropImage.ts` -- Client-side image cropping pipeline (canvas-based, WebP output)
|
||||||
|
- `packages/web/src/components/chat/AttachmentRenderer.tsx` -- Attachment display component (images, video, audio, generic files, federation badges)
|
||||||
|
- `packages/web/src/components/chat/ImagePreview.tsx` -- Full-screen image preview modal with save/copy toolbar
|
||||||
|
|
||||||
|
DB tables: `attachments`, `instance_settings` (maxUploadSizeBytes). See `docs/systems/database.md` for full schemas.
|
||||||
|
|
||||||
|
**Out of scope:** Federation file replication/download queue (see `docs/systems/federation.md`), admin storage stats UI, admin user management.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Upload Pipeline
|
||||||
|
|
||||||
|
### Endpoint: `POST /api/uploads`
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| Auth | JWT required (`authenticate` preHandler) |
|
||||||
|
| Content type | Multipart (`request.file()`) |
|
||||||
|
| Rate limit | 30 requests / 1 minute (keyed by `userId` or IP) |
|
||||||
|
| Size limit | Dynamic from `instance_settings.maxUploadSizeBytes`, fallback `config.maxUploadSize` (env `MAX_UPLOAD_SIZE`, default 100 MB) |
|
||||||
|
| Response | `201` with `Attachment` object |
|
||||||
|
|
||||||
|
### Upload Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Client (multipart POST)
|
||||||
|
-> authenticate JWT
|
||||||
|
-> read maxUploadSizeBytes from instance_settings (id=1)
|
||||||
|
-> request.file({ limits: { fileSize: maxSize } })
|
||||||
|
-> generate snowflake ID
|
||||||
|
-> derive filename: "${snowflakeId}${originalExtension}"
|
||||||
|
-> stream to disk via pipeline(data.file, writeStream)
|
||||||
|
-> check truncation (file exceeded limit) -> 413 + delete file
|
||||||
|
-> fs.statSync for actual file size
|
||||||
|
-> media processing (image/video/audio)
|
||||||
|
-> insert attachments record (messageId=NULL, dmMessageId=NULL)
|
||||||
|
-> return Attachment { id, filename, originalName, mimetype, size, thumbnailFilename?, width?, height?, duration? }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key details:**
|
||||||
|
- Filename: snowflake ID + original file extension (e.g., `1234567890123456.png`)
|
||||||
|
- The attachment is created with `messageId = NULL` -- it is linked to a message later when the message is sent via `POST /channels/:id/messages` or `POST /dm/:id/messages`
|
||||||
|
- Returned `Attachment.messageId` is set to `''` (empty string) in the response, not null
|
||||||
|
- Upload directory: `config.uploadDir` (default `packages/server/data/uploads/`), created on route registration if missing
|
||||||
|
|
||||||
|
### Error Responses
|
||||||
|
|
||||||
|
| Code | Condition |
|
||||||
|
|------|-----------|
|
||||||
|
| `400` | No file in multipart request |
|
||||||
|
| `413` | File exceeds dynamic size limit (file deleted from disk after truncation detection) |
|
||||||
|
| `429` | Rate limit exceeded |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. MIME Type Handling
|
||||||
|
|
||||||
|
### Extension-to-MIME Map (`EXT_MIMETYPES`)
|
||||||
|
|
||||||
|
Used as fallback when serving files without a DB record (thumbnails, orphans).
|
||||||
|
|
||||||
|
| Category | Extensions | MIME types |
|
||||||
|
|----------|-----------|------------|
|
||||||
|
| Images | `.webp`, `.jpg`, `.jpeg`, `.png`, `.gif`, `.svg`, `.avif`, `.tiff`, `.bmp`, `.ico` | `image/webp`, `image/jpeg`, `image/png`, `image/gif`, `image/svg+xml`, `image/avif`, `image/tiff`, `image/bmp`, `image/x-icon` |
|
||||||
|
| Video | `.mp4`, `.webm`, `.mov` | `video/mp4`, `video/webm`, `video/quicktime` |
|
||||||
|
| Audio | `.mp3`, `.ogg`, `.wav`, `.flac`, `.aac`, `.opus` | `audio/mpeg`, `audio/ogg`, `audio/wav`, `audio/flac`, `audio/aac`, `audio/opus` |
|
||||||
|
| Documents | `.pdf` | `application/pdf` |
|
||||||
|
|
||||||
|
Fallback MIME for unknown extensions: `application/octet-stream`.
|
||||||
|
|
||||||
|
### Resizable Image Types (`RESIZABLE_MIMETYPES`)
|
||||||
|
|
||||||
|
Only these MIME types receive thumbnail generation:
|
||||||
|
|
||||||
|
```
|
||||||
|
image/jpeg, image/png, image/webp, image/gif, image/avif, image/tiff
|
||||||
|
```
|
||||||
|
|
||||||
|
**Not resizable:** `image/svg+xml`, `image/bmp`, `image/x-icon` -- these are served as-is.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Media Processing
|
||||||
|
|
||||||
|
Processing occurs inline during upload, before the response is sent. All processing is non-fatal -- failures are logged but the upload still succeeds.
|
||||||
|
|
||||||
|
### Image Processing
|
||||||
|
|
||||||
|
**Condition:** `isResizableImage(mimetype)` returns true
|
||||||
|
|
||||||
|
1. **Thumbnail generation** (`thumbnail.ts:generateThumbnail`)
|
||||||
|
- Skip if width <= 800px (`THUMBNAIL_MAX_WIDTH`)
|
||||||
|
- Skip if animated (GIF with `metadata.pages > 1` -- Sharp would flatten to single frame)
|
||||||
|
- Resize to max 800px width, `withoutEnlargement: true`
|
||||||
|
- Output: WebP at quality 80 (`THUMBNAIL_QUALITY`)
|
||||||
|
- Filename: `${snowflakeId}_thumb.webp` (via `thumbFilename()`)
|
||||||
|
- Returns `null` if skipped or on error
|
||||||
|
|
||||||
|
2. **Dimension probing** (`thumbnail.ts:probeImageDimensions`)
|
||||||
|
- Uses `sharp(filepath).metadata()` to extract `width` and `height`
|
||||||
|
- Works for all formats including animated GIFs
|
||||||
|
|
||||||
|
### Video Processing
|
||||||
|
|
||||||
|
**Condition:** `mimetype.startsWith('video/')`
|
||||||
|
|
||||||
|
1. **Thumbnail extraction** (`thumbnail.ts:generateVideoThumbnail`)
|
||||||
|
- Requires ffmpeg on system PATH (availability cached on first check)
|
||||||
|
- Extracts a single frame using ffmpeg, trying seek times `['1', '0']` (falls back to 0s for short clips)
|
||||||
|
- ffmpeg command: `-ss {time} -i {filepath} -frames:v 1 -f image2pipe -vcodec png -`
|
||||||
|
- Frame is piped to stdout as PNG buffer (max 50 MB)
|
||||||
|
- The PNG frame is then processed through sharp:
|
||||||
|
- Dimensions read from frame metadata (rotation-corrected by ffmpeg)
|
||||||
|
- Resized to max 800px width, converted to WebP quality 80
|
||||||
|
- Returns `{ thumbnailFilename, width, height }` (dimensions are from the original frame, not the thumbnail)
|
||||||
|
|
||||||
|
2. **Metadata probing** (`thumbnail.ts:probeMediaMeta`)
|
||||||
|
- ffprobe for dimensions: `-select_streams v:0 -show_entries stream=width,height -of json`
|
||||||
|
- ffprobe for duration: `-show_entries format=duration -of json`
|
||||||
|
- Duration rounded to 2 decimal places
|
||||||
|
- If thumbnail extraction failed, dimensions fall back to ffprobe values
|
||||||
|
|
||||||
|
### Audio Processing
|
||||||
|
|
||||||
|
**Condition:** `mimetype.startsWith('audio/')`
|
||||||
|
|
||||||
|
1. **Duration probing** (`thumbnail.ts:probeMediaMeta`)
|
||||||
|
- ffprobe for duration only (same command as video duration)
|
||||||
|
- No thumbnail or dimension extraction
|
||||||
|
|
||||||
|
### ffmpeg Availability
|
||||||
|
|
||||||
|
- Checked once via `execFile('ffprobe', ['-version'])` with 5s timeout
|
||||||
|
- Result cached in module-level `ffmpegAvailable` variable
|
||||||
|
- If unavailable: video thumbnails and all media metadata extraction are silently disabled
|
||||||
|
- All ffmpeg/ffprobe calls use 10-second timeout (`FFMPEG_TIMEOUT`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Profile Image Resizing
|
||||||
|
|
||||||
|
Profile images (avatars, banners, space icons) use the general upload pipeline but are additionally resized server-side after the user/space update.
|
||||||
|
|
||||||
|
### `thumbnail.ts:resizeProfileImage(filepath, type)`
|
||||||
|
|
||||||
|
| Type | Max dimension (px) |
|
||||||
|
|------|--------------------|
|
||||||
|
| `avatar` | 256 |
|
||||||
|
| `icon` | 256 |
|
||||||
|
| `banner` | 1280 |
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
- Uses sharp with `{ animated: true }` to preserve GIF animation
|
||||||
|
- No-op if image width is already <= max dimension
|
||||||
|
- Writes to temp file (`filepath + '.tmp'`), then atomically renames (`fs.renameSync`) to avoid corruption
|
||||||
|
- Non-fatal: on error, original file is preserved, temp file cleaned up
|
||||||
|
|
||||||
|
### Profile Upload Lifecycle
|
||||||
|
|
||||||
|
When a user sets an avatar/banner or a space sets an icon/banner:
|
||||||
|
|
||||||
|
1. Client uploads file via `POST /api/uploads` (creates attachment record)
|
||||||
|
2. Client sends `PATCH /users/@me` or `PATCH /spaces/:id` with the filename
|
||||||
|
3. Server strips `/api/uploads/` prefix if present
|
||||||
|
4. Old file deleted from disk (`deleteUploadFile`)
|
||||||
|
5. Old attachment record cleaned up (`deleteAttachmentByFilename`)
|
||||||
|
6. New attachment record cleaned up (reference now lives in `users`/`spaces` table)
|
||||||
|
7. File resized in-place via `resizeProfileImage`
|
||||||
|
|
||||||
|
The attachment record for profile images is intentionally deleted -- the authoritative reference moves to the `users.avatar`/`users.banner` or `spaces.icon`/`spaces.banner` column.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Thumbnail Generation Details
|
||||||
|
|
||||||
|
### Filename Convention
|
||||||
|
|
||||||
|
```
|
||||||
|
thumbnail.ts:thumbFilename(original)
|
||||||
|
input: "1234567890123456.png"
|
||||||
|
output: "1234567890123456_thumb.webp"
|
||||||
|
```
|
||||||
|
|
||||||
|
Strips the original extension, appends `_thumb.webp`.
|
||||||
|
|
||||||
|
### Parameters
|
||||||
|
|
||||||
|
| Parameter | Value |
|
||||||
|
|-----------|-------|
|
||||||
|
| Max width | 800px (`THUMBNAIL_MAX_WIDTH`) |
|
||||||
|
| Format | WebP |
|
||||||
|
| Quality | 80 (`THUMBNAIL_QUALITY`) |
|
||||||
|
| Enlargement | Disabled (`withoutEnlargement: true`) |
|
||||||
|
|
||||||
|
### When Thumbnails Are NOT Generated
|
||||||
|
|
||||||
|
- Image width <= 800px (already small enough)
|
||||||
|
- Animated images (GIF with multiple pages) -- Sharp would strip animation
|
||||||
|
- SVG, BMP, ICO (not in `RESIZABLE_MIMETYPES`)
|
||||||
|
- ffmpeg unavailable (video thumbnails only)
|
||||||
|
- Any processing error (non-fatal, logged)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. File Serving
|
||||||
|
|
||||||
|
### Endpoint: `GET /api/uploads/:filename`
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| Auth | None (public) |
|
||||||
|
| Path safety | `path.basename(filename)` prevents directory traversal |
|
||||||
|
|
||||||
|
### MIME Resolution Priority
|
||||||
|
|
||||||
|
1. `attachments.mimetype` from DB (lookup by filename)
|
||||||
|
2. `EXT_MIMETYPES` map (extension-based fallback for thumbnails/orphans)
|
||||||
|
3. `application/octet-stream` (final fallback)
|
||||||
|
|
||||||
|
### Response Headers
|
||||||
|
|
||||||
|
| Header | Value | Purpose |
|
||||||
|
|--------|-------|---------|
|
||||||
|
| `Cache-Control` | `public, max-age=31536000, immutable` | 1-year cache, immutable (filenames are snowflake-based, never reused) |
|
||||||
|
| `Content-Type` | Resolved MIME type | |
|
||||||
|
| `X-Content-Type-Options` | `nosniff` | Prevents MIME sniffing |
|
||||||
|
| `Content-Security-Policy` | `default-src 'none'; style-src 'unsafe-inline'; img-src 'self'` | Prevents script execution in uploaded files |
|
||||||
|
| `X-Frame-Options` | `DENY` | Prevents iframe embedding |
|
||||||
|
| `Accept-Ranges` | `bytes` | Advertises Range support |
|
||||||
|
| `Content-Length` | File size in bytes | |
|
||||||
|
|
||||||
|
### Content-Disposition (Forced Download)
|
||||||
|
|
||||||
|
Files that trigger `Content-Disposition: attachment`:
|
||||||
|
- **SVGs** (`image/svg+xml`) -- prevents XSS via inline SVG rendering
|
||||||
|
- **Non-media files** (anything not `image/*`, `video/*`, or `audio/*`)
|
||||||
|
|
||||||
|
Filename is URI-encoded: `attachment; filename="${encodeURIComponent(originalName)}"`.
|
||||||
|
|
||||||
|
### Range Requests (A/V Seeking)
|
||||||
|
|
||||||
|
When `Range` header is present:
|
||||||
|
1. Parse `bytes=start-end` (end defaults to `fileSize - 1` if omitted)
|
||||||
|
2. Set `Content-Range: bytes start-end/totalSize`
|
||||||
|
3. Set `Content-Length` to chunk size
|
||||||
|
4. Return `206 Partial Content` with `fs.createReadStream({ start, end })`
|
||||||
|
|
||||||
|
Without Range header: streams entire file with `200 OK`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. File Cleanup Utilities
|
||||||
|
|
||||||
|
### `fileCleanup.ts:deleteUploadFile(filename)`
|
||||||
|
|
||||||
|
Deletes a file and its thumbnail from disk.
|
||||||
|
|
||||||
|
```
|
||||||
|
path.basename(filename) -- directory traversal prevention
|
||||||
|
fs.unlinkSync(filePath) -- tolerates ENOENT
|
||||||
|
fs.unlinkSync(thumbPath) -- always attempted, silently ignored if missing
|
||||||
|
```
|
||||||
|
|
||||||
|
### `fileCleanup.ts:deleteAttachmentFiles(rows)`
|
||||||
|
|
||||||
|
Batch deletion: calls `deleteUploadFile` for each `{ filename }` in the array.
|
||||||
|
|
||||||
|
### `fileCleanup.ts:deleteAttachmentByFilename(filename)`
|
||||||
|
|
||||||
|
Deletes the attachment DB record and its thumbnail from disk. Used when profile images are set/replaced (the reference moves to `users`/`spaces` tables).
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Look up attachment record by filename
|
||||||
|
2. Delete thumbnail file from disk (if thumbnailFilename is set)
|
||||||
|
3. Delete attachment DB record
|
||||||
|
```
|
||||||
|
|
||||||
|
Idempotent: no-op if no record exists.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Storage Janitor
|
||||||
|
|
||||||
|
### File Classification
|
||||||
|
|
||||||
|
`storageJanitor.ts:classifyFile()` categorizes files by extension for storage stats:
|
||||||
|
|
||||||
|
| Category | Extensions |
|
||||||
|
|----------|-----------|
|
||||||
|
| `image` | `.jpg`, `.jpeg`, `.png`, `.gif`, `.webp`, `.svg`, `.ico`, `.bmp`, `.avif` |
|
||||||
|
| `video` | `.mp4`, `.webm`, `.mov`, `.avi`, `.mkv` |
|
||||||
|
| `audio` | `.mp3`, `.ogg`, `.wav`, `.flac`, `.aac`, `.m4a`, `.opus` |
|
||||||
|
| `document` | `.pdf`, `.doc`, `.docx`, `.xls`, `.xlsx`, `.ppt`, `.pptx`, `.txt`, `.csv`, `.json`, `.xml` |
|
||||||
|
| `other` | Everything else |
|
||||||
|
|
||||||
|
### Referenced File Detection
|
||||||
|
|
||||||
|
**`getReferencedFilenames()`** builds a set of all filenames that should exist on disk:
|
||||||
|
- User avatars (`users.avatar` where not null)
|
||||||
|
- User banners (`users.banner` where not null)
|
||||||
|
- Space icons (`spaces.icon` where not null)
|
||||||
|
- Space banners (`spaces.banner` where not null)
|
||||||
|
- Attachment filenames (`attachments.filename`)
|
||||||
|
- Attachment thumbnails (`attachments.thumbnailFilename` where not null)
|
||||||
|
|
||||||
|
All values are `path.basename()`-normalized.
|
||||||
|
|
||||||
|
**`getProfileReferencedFilenames()`** is a subset: only user avatars/banners and space icons/banners. Used to protect profile images during cleanup.
|
||||||
|
|
||||||
|
### Unlinked Attachment Detection
|
||||||
|
|
||||||
|
`getUnlinkedAttachments()` finds attachment records where:
|
||||||
|
- `messageId IS NULL` AND `dmMessageId IS NULL` (never linked to a message)
|
||||||
|
- `createdAt < (now - 1 hour)` (`UNLINKED_AGE_MS = 3,600,000ms`)
|
||||||
|
- Filename is NOT in profile-referenced set (protects in-use profile images)
|
||||||
|
|
||||||
|
These are files uploaded but never sent in a message (abandoned uploads, profile images whose attachment record should have been cleaned up).
|
||||||
|
|
||||||
|
### Dangling Attachment Detection
|
||||||
|
|
||||||
|
`getDanglingAttachments()` finds attachment records whose message no longer exists:
|
||||||
|
- Space messages: `attachments.message_id IS NOT NULL` but no matching `messages.id`
|
||||||
|
- DM messages: `attachments.dm_message_id IS NOT NULL` but no matching `dm_messages.id`
|
||||||
|
|
||||||
|
Uses raw SQL with LEFT JOIN for efficiency.
|
||||||
|
|
||||||
|
### Storage Stats (`getStorageStats()`)
|
||||||
|
|
||||||
|
Returns `StorageStats` object:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
totalFiles: number; // All files on disk
|
||||||
|
totalSize: number; // Total bytes on disk
|
||||||
|
referencedFiles: number; // Files with DB references
|
||||||
|
referencedSize: number; // Bytes of referenced files
|
||||||
|
orphanedFiles: number; // Files on disk with no DB reference
|
||||||
|
orphanedSize: number; // Bytes of orphaned files
|
||||||
|
unlinkedAttachments: number; // DB records with no message link (>1h old)
|
||||||
|
unlinkedSize: number;
|
||||||
|
danglingAttachments: number; // DB records pointing to deleted messages
|
||||||
|
danglingSize: number;
|
||||||
|
breakdown: StorageBreakdown[]; // Per-category {type, count, size}, sorted by size desc
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cleanup Routines
|
||||||
|
|
||||||
|
#### `cleanupStorage(dryRun: boolean) -> CleanupResult`
|
||||||
|
|
||||||
|
Three-phase cleanup:
|
||||||
|
|
||||||
|
**Phase 1: Orphaned disk files** -- Files on disk not referenced by any DB record (attachment, profile).
|
||||||
|
- Deletes file + thumbnail via `deleteUploadFile`
|
||||||
|
|
||||||
|
**Phase 2: Unlinked attachment records** -- Attachment DB records with no message link, older than 1 hour.
|
||||||
|
- If file is used as a profile image: keep file, delete only the thumbnail and DB record
|
||||||
|
- If file is not a profile image: delete file, thumbnail, and DB record
|
||||||
|
|
||||||
|
**Phase 3: Dangling attachment records** -- Attachment DB records pointing to deleted messages.
|
||||||
|
- Deletes file, thumbnail, and DB record
|
||||||
|
|
||||||
|
#### `cleanupOldMedia(maxAgeDays: number, dryRun: boolean) -> CleanupResult`
|
||||||
|
|
||||||
|
Age-based media cleanup:
|
||||||
|
- Finds attachments where `(message_id IS NOT NULL OR dm_message_id IS NOT NULL) AND created_at < cutoff`
|
||||||
|
- Skips files used as profile images
|
||||||
|
- Deletes file, thumbnail, and DB record
|
||||||
|
|
||||||
|
#### `CleanupResult`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
dryRun: boolean;
|
||||||
|
deletedFiles: number;
|
||||||
|
freedBytes: number;
|
||||||
|
deletedAttachmentRecords: number;
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When `dryRun = true`: counts are computed but no files/records are deleted.
|
||||||
|
|
||||||
|
### Federation GC (`runFederationJanitor()`)
|
||||||
|
|
||||||
|
Periodic cleanup of federation data (called by background worker):
|
||||||
|
|
||||||
|
| Task | Function | Criteria |
|
||||||
|
|------|----------|---------|
|
||||||
|
| Expired outbox entries | `cleanupFederationOutbox()` | `expiresAt < now` |
|
||||||
|
| Old mutation log | `cleanupFederationMutationLog(90)` | `mutatedAt < (now - 90 days)` |
|
||||||
|
| Stale file queue | `cleanupFederationFileQueue()` | Completed entries > 7 days old, OR `expiresAt < now` |
|
||||||
|
| Soft-deleted DM channels | `cleanupSoftDeletedDmChannels()` | `deletedAt < (now - 24 hours)` |
|
||||||
|
|
||||||
|
#### Soft-Deleted DM Channel Purge
|
||||||
|
|
||||||
|
`cleanupSoftDeletedDmChannels()` hard-deletes DM channels that were soft-deleted more than 24 hours ago. Cascades in transaction:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Collect message IDs and attachment filenames
|
||||||
|
2. Transaction:
|
||||||
|
- Delete dm_reactions (by message IDs)
|
||||||
|
- Delete embeds (by message IDs)
|
||||||
|
- Delete attachments DB records (by message IDs)
|
||||||
|
- Delete federation_file_queue entries (by message IDs)
|
||||||
|
- Delete dm_messages
|
||||||
|
- Delete dm_members
|
||||||
|
- Delete read_states
|
||||||
|
- Delete federation_outbox entries (by channel ID as contextId)
|
||||||
|
- Delete federation_mutation_log entries (by channel ID as contextId)
|
||||||
|
- Delete dm_channels record
|
||||||
|
3. Delete attachment files from disk (outside transaction)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Admin Endpoints
|
||||||
|
|
||||||
|
The admin routes (`routes/admin.ts`) expose the janitor functions via REST:
|
||||||
|
|
||||||
|
| Endpoint | Method | Function |
|
||||||
|
|----------|--------|----------|
|
||||||
|
| `GET /api/admin/storage/stats` | GET | `getStorageStats()` |
|
||||||
|
| `GET /api/admin/storage/orphans` | GET | `getOrphanedFiles()` |
|
||||||
|
| `POST /api/admin/storage/cleanup` | POST | `cleanupStorage(dryRun)` |
|
||||||
|
| `POST /api/admin/storage/cleanup-media` | POST | `cleanupOldMedia(maxAgeDays, dryRun)` |
|
||||||
|
|
||||||
|
All require JWT + admin role. See `docs/systems/api.md` for request/response formats.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Client-Side: Upload & Attachment Display
|
||||||
|
|
||||||
|
### Upload Flow (`MessageInput.tsx`)
|
||||||
|
|
||||||
|
1. Files added via:
|
||||||
|
- **File picker button** (`<input type="file">` triggered by attach button click)
|
||||||
|
- **Drag and drop** (`onDrop` handler on input container) -- adds `e.dataTransfer.files` to state
|
||||||
|
- **Paste** (`onPaste` handler) -- extracts `image/*` items from clipboard `DataTransferItemList`
|
||||||
|
2. Files stored in component state as `File[]`
|
||||||
|
3. Preview rendered inline: images as `<img>` with `URL.createObjectURL`, non-images as file icon + name
|
||||||
|
4. On submit:
|
||||||
|
- Resolves correct API client for channel origin (`getApiForOrigin(getChannelOrigin(channelId))`)
|
||||||
|
- Uploads files sequentially via `uploadClient.uploads.uploadWithProgress(file, onProgress)`
|
||||||
|
- Progress tracked per-file in a `Map<number, number>` (file index -> percentage 0-100)
|
||||||
|
- Collects `attachment.id` from each successful upload
|
||||||
|
- Failed uploads: toast notification, skipped (partial sends allowed if text or other attachments present)
|
||||||
|
- Sends message with collected `attachmentIds` array
|
||||||
|
|
||||||
|
### Progress Tracking (`api/client.ts`)
|
||||||
|
|
||||||
|
Two upload methods:
|
||||||
|
|
||||||
|
| Method | Transport | Timeout | Progress |
|
||||||
|
|--------|-----------|---------|----------|
|
||||||
|
| `uploads.upload(file)` | `fetch` API | 120 seconds | No |
|
||||||
|
| `uploads.uploadWithProgress(file, onProgress)` | `XMLHttpRequest` | 10 minutes | Yes, via `xhr.upload.progress` event |
|
||||||
|
|
||||||
|
Both send multipart `FormData` with the file under key `'file'`.
|
||||||
|
|
||||||
|
The progress callback receives `(loaded: number, total: number)` from the XHR progress event.
|
||||||
|
|
||||||
|
### Attachment Rendering (`AttachmentRenderer.tsx`)
|
||||||
|
|
||||||
|
URL resolution for attachment/thumbnail:
|
||||||
|
- If filename starts with `http` or `/`: used as-is (federated or absolute path)
|
||||||
|
- Otherwise: prefixed with `/api/uploads/`
|
||||||
|
|
||||||
|
| MIME category | Rendering |
|
||||||
|
|---------------|-----------|
|
||||||
|
| `image/*` | `<img>` with click-to-preview, lazy loading, aspect ratio from width/height, max 400x300px, uses thumbnail if available |
|
||||||
|
| `video/*` | `<video>` with native controls, poster from thumbnail, preload `none` (with dimensions) or `metadata` (without), max 400px wide / 300px tall |
|
||||||
|
| `audio/*` | Audio card with icon, filename, size, `<audio>` with native controls, preload `metadata`, max 420px wide |
|
||||||
|
| Other | Download link card with file icon, filename (link-styled), size |
|
||||||
|
|
||||||
|
#### Federation Status Badges
|
||||||
|
|
||||||
|
Inline badges shown for federated attachments:
|
||||||
|
|
||||||
|
| `federationStatus` | Badge | Tooltip |
|
||||||
|
|--------------------|-------|---------|
|
||||||
|
| `remote` | Cloud icon (muted) | "Hosted on {username}'s instance. Download to keep a local copy." |
|
||||||
|
| `remote_partial` | Warning triangle (amber) | "File couldn't be cached on {username}'s instance (limit: {N} MB). They can still view it from yours." |
|
||||||
|
|
||||||
|
The `federationMeta` JSON is parsed for display details (source username, rejection limits).
|
||||||
|
|
||||||
|
### Image Preview (`ImagePreview.tsx`)
|
||||||
|
|
||||||
|
Full-screen overlay (`z-[200]`) with `bg-surface-overlay` backdrop.
|
||||||
|
|
||||||
|
- Opens via `useUIStore.openImagePreview(url)` (triggered by clicking an image in `AttachmentRenderer`)
|
||||||
|
- Shows full-resolution image (not thumbnail) -- max 90vw x 90vh, `object-contain`
|
||||||
|
- Toolbar (top-right): Save, Copy, Close buttons
|
||||||
|
- Click backdrop to close, click image to prevent close propagation
|
||||||
|
- Managed by `activeModal === 'imagePreview'` state
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Client-Side: Image Actions
|
||||||
|
|
||||||
|
### `imageActions.ts:saveImage(url, filename?)`
|
||||||
|
|
||||||
|
Downloads an image by fetching as blob and creating a temporary download link:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Derive filename from URL (last path segment) or use provided name
|
||||||
|
2. fetch(url) -> blob -> URL.createObjectURL -> <a download> click -> revoke
|
||||||
|
3. Fallback on error: window.open(url) + toast "Opened in new tab"
|
||||||
|
```
|
||||||
|
|
||||||
|
### `imageActions.ts:copyImageToClipboard(url)`
|
||||||
|
|
||||||
|
Copies image to clipboard as PNG:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. GIF detection (by URL extension or Tenor/Klipy domain pattern):
|
||||||
|
- If GIF: copy URL as text instead (preserves animation)
|
||||||
|
2. fetch(url) -> blob
|
||||||
|
3. If response type is image/gif: copy URL as text
|
||||||
|
4. If PNG: use blob directly
|
||||||
|
5. Otherwise: convert to PNG via canvas (drawImage -> toBlob('image/png'))
|
||||||
|
6. navigator.clipboard.write([ClipboardItem({ 'image/png': pngBlob })])
|
||||||
|
7. Fallback on error: copy URL as text + toast
|
||||||
|
```
|
||||||
|
|
||||||
|
**GIF detection patterns (`isGifUrl`):**
|
||||||
|
- URL path ends with `.gif`
|
||||||
|
- URL matches `media.tenor.com` or `static.klipy.com`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Client-Side: Image Cropping
|
||||||
|
|
||||||
|
### `cropImage.ts:cropImage(imageSrc, pixelCrop, outputType?, options?)`
|
||||||
|
|
||||||
|
Used by profile image editors (avatar/banner crop dialogs, integrated with `react-easy-crop`).
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Param | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| `imageSrc` | `string` | required | Image URL or data URI |
|
||||||
|
| `pixelCrop` | `PixelCrop` | required | `{ x, y, width, height }` in pixels |
|
||||||
|
| `outputType` | `string` | `'image/webp'` | MIME type for output |
|
||||||
|
| `options.maxDimension` | `number?` | none | Max width/height (downscale if exceeded) |
|
||||||
|
| `options.quality` | `number?` | `0.85` | Compression quality (0-1) |
|
||||||
|
| `options.outputType` | `string?` | uses `outputType` param | Overrides the positional param |
|
||||||
|
|
||||||
|
**Pipeline:**
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Load image with crossOrigin='anonymous'
|
||||||
|
2. Draw crop region at original size onto canvas
|
||||||
|
3. If maxDimension set and crop exceeds it:
|
||||||
|
- Scale uniformly: scale = maxDimension / max(width, height)
|
||||||
|
- Draw scaled onto second canvas
|
||||||
|
4. Export via canvas.toBlob(finalType, quality)
|
||||||
|
5. WebP fallback: if toBlob returns null for WebP, retry as PNG (old Safari compatibility)
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns a `Blob` (Promise).
|
||||||
|
|
||||||
|
### `PixelCrop` Interface
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface PixelCrop {
|
||||||
|
x: number; // Left offset in source image pixels
|
||||||
|
y: number; // Top offset in source image pixels
|
||||||
|
width: number; // Crop width in pixels
|
||||||
|
height: number; // Crop height in pixels
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Asset URL Resolution (Federation)
|
||||||
|
|
||||||
|
### `assetUrls.ts`
|
||||||
|
|
||||||
|
Handles URL rewriting for federated content:
|
||||||
|
|
||||||
|
| Function | Purpose |
|
||||||
|
|----------|---------|
|
||||||
|
| `stripUploadPrefix(filename)` | Strips `/api/uploads/` prefix from filename; no-op for bare filenames and absolute URLs |
|
||||||
|
| `resolveAssetUrl(filename, origin)` | Converts relative filename to absolute URL for remote origins; pass-through for `http`-prefixed URLs |
|
||||||
|
| `normalizeUserAssets(user, origin)` | Rewrites `user.avatar` and `user.banner` for remote origins; also sets `homeInstance`/`homeUserId` for users local to the remote instance |
|
||||||
|
| `normalizeMessageAssets(message, origin)` | Rewrites user assets + attachment filenames/thumbnails for remote origins; recurses into `replyTo` |
|
||||||
|
|
||||||
|
These functions are called client-side when displaying content from federated instances, ensuring relative upload paths are resolved to the correct remote server.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Boundary with Federation
|
||||||
|
|
||||||
|
This spec covers **local** file storage and serving. The boundary:
|
||||||
|
|
||||||
|
| This spec (uploads.md) | Federation spec (federation.md) |
|
||||||
|
|------------------------|---------------------------------|
|
||||||
|
| Multipart upload reception | File download queue (`federation_file_queue`) |
|
||||||
|
| Thumbnail/metadata generation | Size validation against remote peer limits |
|
||||||
|
| Disk storage and serving | `file_rejected` relay event |
|
||||||
|
| Orphan detection and cleanup | File queue worker (background download) |
|
||||||
|
| Storage stats and admin cleanup | `remoteMaxUploadSize` on peer records |
|
||||||
|
| `attachments` record creation | `sourceUrl`, `federationStatus`, `federationMeta` fields |
|
||||||
|
|
||||||
|
The `attachments` table contains federation-specific columns (`sourceUrl`, `federationStatus`, `federationMeta`) that are populated by the federation file download worker, not by the upload pipeline.
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
# Voice, Video & Calls System
|
||||||
|
|
||||||
|
Source files:
|
||||||
|
- Server: `routes/livekit.ts`, `ws/handler.ts`, `ws/events.ts`
|
||||||
|
- Client: `hooks/useLiveKit.ts`, `stores/voiceStore.ts`, `utils/voice.ts`, `utils/voiceActions.ts`, `utils/screenShare.ts`
|
||||||
|
- Shared: `packages/shared/src/constants.ts` (bitrate matrix, resolutions)
|
||||||
|
- Audio: `audio/AudioManager.ts`, `audio/SpeakingDetector.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Voice Channel Join Flow
|
||||||
|
|
||||||
|
1. Client sends `voice_join { channelId }` via WS
|
||||||
|
2. Server checks CONNECT permission, enforces one-room-per-user
|
||||||
|
3. Server loads voice restrictions from DB (space mute/deafen)
|
||||||
|
4. Server broadcasts `voice_state_update { action: 'join' }` to space
|
||||||
|
5. Client calls `POST /api/livekit/token { channelId }` → gets JWT + LiveKit URL
|
||||||
|
6. Client connects to LiveKit room with token
|
||||||
|
|
||||||
|
**Token grants (space channels):**
|
||||||
|
- SPEAK → can publish MICROPHONE + CAMERA
|
||||||
|
- STREAM → can publish SCREEN_SHARE + SCREEN_SHARE_AUDIO
|
||||||
|
- Missing permission → grant excludes those sources
|
||||||
|
|
||||||
|
**Token grants (DM calls):** Always full (canSpeak=true, canStream=true)
|
||||||
|
|
||||||
|
**Identity format:** `{userId}:{username}`, TTL: 1 hour, Room: `{channelId}` or `dm-{dmChannelId}`
|
||||||
|
|
||||||
|
**Multi-tab:** Each user has one `voiceWs` binding. New tab → old socket gets `voice_disconnected { reason: 'displaced' }`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DM Call State Machine
|
||||||
|
|
||||||
|
States: `ringing` → `active` → destroyed
|
||||||
|
|
||||||
|
| Event | Action | State |
|
||||||
|
|-------|--------|-------|
|
||||||
|
| `dm_call_start` | Room created, caller bound, 60s timeout starts | ringing |
|
||||||
|
| `dm_call_incoming` | Broadcast to DM members (excludes caller) | ringing |
|
||||||
|
| `dm_call_accept` | First accept: ringing→active. Late joins welcome (group DM) | active |
|
||||||
|
| `dm_call_reject` | Room destroyed, caller unbound | — |
|
||||||
|
| `dm_call_end` | All participants unbound, room destroyed | — |
|
||||||
|
| Timeout (60s) | Auto-cleanup if still ringing, broadcast `dm_call_ended` | — |
|
||||||
|
|
||||||
|
**Edge cases:**
|
||||||
|
- Starting new call cancels any other ringing calls by same caller
|
||||||
|
- Socket close during ringing → auto-cleanup
|
||||||
|
- Participants drop to 0 in active state → room destroyed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Voice Moderation
|
||||||
|
|
||||||
|
Three independent muting mechanisms:
|
||||||
|
|
||||||
|
### 1. User Self-Mute/Deafen
|
||||||
|
- Client toggles in `voiceStore`
|
||||||
|
- Broadcasts via `voice_status` WS event
|
||||||
|
- If also space-muted, remains effectively muted
|
||||||
|
|
||||||
|
### 2. Space Mute/Deafen (moderator, persisted)
|
||||||
|
- Requires MUTE_MEMBERS / DEAFEN_MEMBERS permission
|
||||||
|
- Stored in `voice_restrictions` table (survives reconnect)
|
||||||
|
- In-memory: `spaceMutedUsers` / `spaceDeafenedUsers` sets (`"spaceId:userId"` keys)
|
||||||
|
- On voice_join: restrictions loaded from DB into memory
|
||||||
|
- Broadcasts `voice_space_muted` / `voice_space_deafened` to all space members
|
||||||
|
|
||||||
|
### 3. Permission Mute (automatic, ephemeral)
|
||||||
|
- Triggered when user loses SPEAK permission (role update)
|
||||||
|
- `checkVoicePermissions(spaceId)` re-evaluates all users in space voice
|
||||||
|
- NOT persisted — derived from role permissions on demand
|
||||||
|
- Broadcasts `voice_permission_muted`
|
||||||
|
|
||||||
|
**Effective state:** `effectiveMuted = isMuted || spaceMuted || permissionMuted`
|
||||||
|
|
||||||
|
### Move & Disconnect
|
||||||
|
- `voice_move`: Requires MOVE_MEMBERS. Same space only. Preserves voice status.
|
||||||
|
- `voice_disconnect`: Requires DISCONNECT_MEMBERS. Full teardown.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Screen Sharing
|
||||||
|
|
||||||
|
### Resolution & Framerate Options
|
||||||
|
```
|
||||||
|
Standard resolutions: 540, 720, 1080, 1440, 2160 (+ 'native')
|
||||||
|
Standard framerates: 30, 45, 60, 75, 90, 120
|
||||||
|
Width map: 540→960, 720→1280, 1080→1920, 1440→2560, 2160→3840
|
||||||
|
```
|
||||||
|
|
||||||
|
### VP9 Bitrate Matrix (kbps)
|
||||||
|
```
|
||||||
|
30 45 60 75 90 120
|
||||||
|
540: 1500 2000 2500 2800 3200 4000
|
||||||
|
720: 3000 3500 4000 4500 5000 6000
|
||||||
|
1080: 6000 7000 8000 9000 10000 12000
|
||||||
|
1440: 10000 12000 14000 16000 18000 22000
|
||||||
|
2160: 20000 24000 28000 32000 38000 45000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Config Object
|
||||||
|
```typescript
|
||||||
|
ScreenShareConfig {
|
||||||
|
height: number | 'native', // Resolution or capture at display res
|
||||||
|
fps: number, // 30-120
|
||||||
|
mode: 'gaming' | 'text', // Affects bitrate & content hint
|
||||||
|
customBitrateKbps: number | null, // Admin override (if allowed)
|
||||||
|
shareAudio: boolean // System audio (disabled in Electron)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build Pipeline (`buildScreenShareOptions()`)
|
||||||
|
1. Resolve bitrate from matrix (custom > override > default > native estimate)
|
||||||
|
2. Clamp to instance limits (minBitrateKbps, maxBitrateKbps)
|
||||||
|
3. Compute min bitrate = 25% of max
|
||||||
|
4. Codec: VP9 (default) or H.264 (hardware overdrive)
|
||||||
|
5. VP8 simulcast backup at reduced framerate/bitrate
|
||||||
|
6. Content hint: `'detail'` (text) or `'motion'` (gaming)
|
||||||
|
|
||||||
|
### Native Mode
|
||||||
|
- Captures at display's full resolution
|
||||||
|
- Snaps to nearest known tier for bitrate lookup
|
||||||
|
- Scales proportionally: `baseKbps * (capturedPixels / knownPixels) * (fps / knownFps)`
|
||||||
|
|
||||||
|
### Hardware Overdrive
|
||||||
|
- Forces H.264 hardware encoder via SDP profile override
|
||||||
|
- Applied 2s after stream starts (after WebRTC negotiation), re-applied at 5s
|
||||||
|
- 4s: detects if using software fallback, warns user
|
||||||
|
|
||||||
|
### Instance-Level Limits (admin-configured)
|
||||||
|
- `allowedResolutions`, `allowedFramerates` (CSV in instance_settings)
|
||||||
|
- `maxResolution`, `maxFramerate`, `maxBitrateKbps`, `minBitrateKbps`
|
||||||
|
- `allowCustomBitrate` toggle
|
||||||
|
- `bitrateMatrixOverrides` (JSON sparse overrides)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Audio Processing
|
||||||
|
|
||||||
|
| Feature | Default | User Control | Notes |
|
||||||
|
|---------|---------|-------------|-------|
|
||||||
|
| Echo Cancellation | on | yes | Stays on during screen share (Chrome AEC handles it) |
|
||||||
|
| Noise Suppression | overridden | — | Managed by RNNoise state |
|
||||||
|
| Auto Gain Control | on | yes | |
|
||||||
|
| RNNoise (ML) | on | yes | When enabled: browser NS forced off |
|
||||||
|
|
||||||
|
**Audio constraints applied to mic track:**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
echoCancellation: userSetting, // stays on during screen share
|
||||||
|
noiseSuppression: rnnoiseEnabled ? false : userSetting,
|
||||||
|
autoGainControl: userSetting,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Screen share audio (when enabled):**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
restrictOwnAudio: true, // Chrome 141+: exclude own tab audio
|
||||||
|
echoCancellation: false,
|
||||||
|
noiseSuppression: false,
|
||||||
|
autoGainControl: false,
|
||||||
|
channelCount: 2 // Stereo
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Persistence:** `voiceStore` with Zustand localStorage. Keys: `echoCancellation`, `autoGainControl`, `rnnoiseEnabled`, `screenShareConfig`.
|
||||||
|
|
||||||
|
**Camera preset:** 1280x720, 2Mbps, 30fps, H.264
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
# WebSocket Protocol Reference
|
||||||
|
|
||||||
|
Endpoint: `GET /ws` (upgrade to WebSocket)
|
||||||
|
Transport: JSON messages over WebSocket
|
||||||
|
Source: `packages/server/src/ws/handler.ts`, `packages/server/src/ws/events.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Auth Flow
|
||||||
|
|
||||||
|
1. Client connects to `/ws`
|
||||||
|
2. Client sends `{ type: 'auth', token: '<jwt>' }` within 10 seconds
|
||||||
|
3. Server validates token (rejects deleted users, tokens issued before `passwordChangedAt`)
|
||||||
|
4. Server responds with `ready` event containing full client state
|
||||||
|
5. Server updates user status to `online`, broadcasts `presence_update` to all user's spaces
|
||||||
|
6. Heartbeat: server pings every 30s (RFC 6455 ping frames), dead connections detected after ~65s
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Client → Server
|
||||||
|
|
||||||
|
### Messages
|
||||||
|
| type | fields | notes |
|
||||||
|
|------|--------|-------|
|
||||||
|
| `message_create` | channelId, content, replyToId? | SEND_MESSAGES perm |
|
||||||
|
| `message_edit` | messageId, content | author only |
|
||||||
|
| `message_delete` | messageId | author or MANAGE_MESSAGES |
|
||||||
|
| `typing_start` | channelId | 5s auto-expire |
|
||||||
|
|
||||||
|
### DM Messages
|
||||||
|
| type | fields | notes |
|
||||||
|
|------|--------|-------|
|
||||||
|
| `dm_message_create` | dmChannelId, content?, attachments?, replyToId? | member |
|
||||||
|
| `dm_message_edit` | messageId, content | author only |
|
||||||
|
| `dm_message_delete` | messageId | author only |
|
||||||
|
| `dm_typing_start` | dmChannelId | 5s auto-expire |
|
||||||
|
|
||||||
|
### Reactions (space + DM, auto-detected)
|
||||||
|
| type | fields | notes |
|
||||||
|
|------|--------|-------|
|
||||||
|
| `reaction_add` | messageId, emoji | ADD_REACTIONS perm (space) |
|
||||||
|
| `reaction_remove` | messageId, emoji | own reactions only |
|
||||||
|
|
||||||
|
### Read State
|
||||||
|
| type | fields | notes |
|
||||||
|
|------|--------|-------|
|
||||||
|
| `channel_ack` | channelId, messageId | mark read up to message |
|
||||||
|
| `mark_unread` | channelId, messageId | `'0'` to clear all |
|
||||||
|
|
||||||
|
### Presence & Activity
|
||||||
|
| type | fields | notes |
|
||||||
|
|------|--------|-------|
|
||||||
|
| `presence_update` | status: online/idle/dnd | persisted to DB |
|
||||||
|
| `activity_update` | activities: Activity[] | rate-limited 3s, respects showActivity |
|
||||||
|
|
||||||
|
### Voice (Space Channels)
|
||||||
|
| type | fields | notes |
|
||||||
|
|------|--------|-------|
|
||||||
|
| `voice_join` | channelId | one room per user enforced |
|
||||||
|
| `voice_leave` | — | |
|
||||||
|
| `voice_status` | isMuted, isDeafened, isCameraOn, isScreenSharing | server enforces space/permission mute |
|
||||||
|
|
||||||
|
### Voice Moderation
|
||||||
|
| type | fields | permission |
|
||||||
|
|------|--------|------------|
|
||||||
|
| `voice_space_mute` | userId, muted | MUTE_MEMBERS |
|
||||||
|
| `voice_space_deafen` | userId, deafened | DEAFEN_MEMBERS |
|
||||||
|
| `voice_move` | userId, targetChannelId | MOVE_MEMBERS |
|
||||||
|
| `voice_disconnect` | userId | DISCONNECT_MEMBERS |
|
||||||
|
|
||||||
|
### DM Calls
|
||||||
|
| type | fields | notes |
|
||||||
|
|------|--------|-------|
|
||||||
|
| `dm_call_start` | dmChannelId | 60s auto-timeout if not accepted |
|
||||||
|
| `dm_call_accept` | dmChannelId | ringing→active |
|
||||||
|
| `dm_call_reject` | dmChannelId | |
|
||||||
|
| `dm_call_end` | dmChannelId | |
|
||||||
|
|
||||||
|
### System
|
||||||
|
| type | fields |
|
||||||
|
|------|--------|
|
||||||
|
| `auth` | token |
|
||||||
|
| `ping` | — (gets `pong`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Server → Client
|
||||||
|
|
||||||
|
### System
|
||||||
|
| type | fields | scope |
|
||||||
|
|------|--------|-------|
|
||||||
|
| `ready` | (see Ready Payload below) | user |
|
||||||
|
| `pong` | — | user |
|
||||||
|
| `error` | message | user |
|
||||||
|
|
||||||
|
### Messages
|
||||||
|
| type | fields | scope |
|
||||||
|
|------|--------|-------|
|
||||||
|
| `message_created` | message: MessageWithUser | channel (VIEW_CHANNEL) |
|
||||||
|
| `message_updated` | message: MessageWithUser | channel |
|
||||||
|
| `message_deleted` | messageId, channelId | channel |
|
||||||
|
| `typing` | channelId, userId, username | channel (excludes sender) |
|
||||||
|
| `reaction_added` | messageId, reaction (includes user) | channel |
|
||||||
|
| `reaction_removed` | messageId, userId, emoji | channel |
|
||||||
|
| `embeds_resolved` | messageId, channelId, embeds[] | channel |
|
||||||
|
|
||||||
|
### DM Messages
|
||||||
|
| type | fields | scope |
|
||||||
|
|------|--------|-------|
|
||||||
|
| `dm_message_created` | message: DmMessageWithUser | DM members |
|
||||||
|
| `dm_message_updated` | message: DmMessageWithUser | DM members |
|
||||||
|
| `dm_message_deleted` | messageId, dmChannelId | DM members |
|
||||||
|
| `dm_typing` | dmChannelId, userId, username | DM members (excludes sender) |
|
||||||
|
| `dm_embeds_resolved` | messageId, dmChannelId, embeds[] | DM members |
|
||||||
|
|
||||||
|
### Read State
|
||||||
|
| type | fields | scope |
|
||||||
|
|------|--------|-------|
|
||||||
|
| `channel_ack` | channelId, messageId | user (multi-tab sync) |
|
||||||
|
| `mark_unread` | channelId, messageId | user (multi-tab sync) |
|
||||||
|
|
||||||
|
### Presence & Activity
|
||||||
|
| type | fields | scope |
|
||||||
|
|------|--------|-------|
|
||||||
|
| `presence_update` | userId, status, activities? | space (all members) |
|
||||||
|
| `user_updated` | user | user |
|
||||||
|
|
||||||
|
### Space / Channel Management
|
||||||
|
| type | fields | scope |
|
||||||
|
|------|--------|-------|
|
||||||
|
| `space_updated` | space | space |
|
||||||
|
| `member_joined` | spaceId, member: MemberWithUser | space |
|
||||||
|
| `member_left` | spaceId, userId | space |
|
||||||
|
| `member_banned` | spaceId, reason | user (banned) |
|
||||||
|
| `channel_created` | channel, spaceId | space |
|
||||||
|
| `channel_updated` | channel, spaceId | space |
|
||||||
|
| `channel_deleted` | channelId, spaceId | space |
|
||||||
|
| `category_created` | category, spaceId | space |
|
||||||
|
| `category_updated` | category, spaceId | space |
|
||||||
|
| `category_deleted` | categoryId, spaceId | space |
|
||||||
|
| `channel_layout_updated` | spaceId, channels[], categories[] | space |
|
||||||
|
| `space_layout_updated` | layout[], folders[], updatedAt? | user |
|
||||||
|
|
||||||
|
### DM Channel Management
|
||||||
|
| type | fields | scope |
|
||||||
|
|------|--------|-------|
|
||||||
|
| `dm_channel_created` | dmChannel | user |
|
||||||
|
| `dm_channel_closed` | dmChannelId | user |
|
||||||
|
| `dm_member_added` | dmChannelId, user | DM members |
|
||||||
|
| `dm_member_removed` | dmChannelId, userId | DM members |
|
||||||
|
| `dm_owner_updated` | dmChannelId, newOwnerId | DM members |
|
||||||
|
|
||||||
|
### Voice
|
||||||
|
| type | fields | scope |
|
||||||
|
|------|--------|-------|
|
||||||
|
| `voice_state_update` | channelId, userId, action: join/leave | space |
|
||||||
|
| `voice_status_update` | userId, channelId, isMuted, isDeafened, isCameraOn, isScreenSharing | room |
|
||||||
|
| `voice_space_muted` | userId, channelId, spaceId, muted | space |
|
||||||
|
| `voice_space_deafened` | userId, channelId, spaceId, deafened | space |
|
||||||
|
| `voice_permission_muted` | userId, spaceId, muted | space |
|
||||||
|
| `voice_moved` | userId, oldChannelId, newChannelId | user (target) |
|
||||||
|
| `voice_disconnected` | userId, channelId, reason? | user (target) |
|
||||||
|
reason: `'displaced'` (new tab) | `'session_closed'`
|
||||||
|
|
||||||
|
### DM Calls
|
||||||
|
| type | fields | scope |
|
||||||
|
|------|--------|-------|
|
||||||
|
| `dm_call_incoming` | dmChannelId, callerId, callerName | DM members (excludes caller) |
|
||||||
|
| `dm_call_accepted` | dmChannelId | DM members |
|
||||||
|
| `dm_call_rejected` | dmChannelId | DM members |
|
||||||
|
| `dm_call_ended` | dmChannelId | DM members |
|
||||||
|
|
||||||
|
### Social
|
||||||
|
| type | fields | scope |
|
||||||
|
|------|--------|-------|
|
||||||
|
| `friend_request_received` | request | user (target) |
|
||||||
|
| `friend_request_accepted` | friend, requestId | user (requester) |
|
||||||
|
| `friend_request_declined` | requestId, userId | user (requester) |
|
||||||
|
| `friend_request_cancelled` | requestId, userId | user (target) |
|
||||||
|
| `friend_removed` | userId | user |
|
||||||
|
|
||||||
|
### Discovery
|
||||||
|
| type | fields | scope |
|
||||||
|
|------|--------|-------|
|
||||||
|
| `join_request_received` | request | space (managers) |
|
||||||
|
| `join_request_accepted` | request, space | user (requester) |
|
||||||
|
| `join_request_declined` | request | user (requester) |
|
||||||
|
|
||||||
|
### Federation
|
||||||
|
| type | fields | scope |
|
||||||
|
|------|--------|-------|
|
||||||
|
| `federation_file_rejected` | messageId, dmChannelId, attachmentId, affectedUsers[] | DM members |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ready Payload
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
type: 'ready',
|
||||||
|
user: User,
|
||||||
|
spaces: SpaceWithChannelsAndMembers[],
|
||||||
|
dmChannels: DmChannel[],
|
||||||
|
folders?: SpaceFolder[],
|
||||||
|
spaceLayout?: SpaceLayoutItem[] | null,
|
||||||
|
layoutUpdatedAt?: number,
|
||||||
|
voiceStates?: Record<channelId, userId[]>,
|
||||||
|
voiceUserStates?: Record<string, { isMuted, isDeafened, isCameraOn, isScreenSharing }>,
|
||||||
|
spaceVoiceStates?: Record<string, { spaceMuted, spaceDeafened, permissionMuted }>,
|
||||||
|
readStates?: ReadState[],
|
||||||
|
activeCalls?: ActiveCallInfo[],
|
||||||
|
userActivities?: Record<userId, Activity[]>
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -18,6 +18,7 @@ import { sanitizeUser } from '../utils/sanitize.js';
|
|||||||
|
|
||||||
function buildProfileSnapshot(user: typeof schema.users.$inferSelect): FederationRelayProfileSnapshot {
|
function buildProfileSnapshot(user: typeof schema.users.$inferSelect): FederationRelayProfileSnapshot {
|
||||||
return {
|
return {
|
||||||
|
username: user.username ?? null,
|
||||||
displayName: user.displayName ?? null,
|
displayName: user.displayName ?? null,
|
||||||
avatar: user.avatar ?? null,
|
avatar: user.avatar ?? null,
|
||||||
avatarColor: user.avatarColor ?? null,
|
avatarColor: user.avatarColor ?? null,
|
||||||
|
|||||||
@@ -797,6 +797,7 @@ export interface FederationGroupPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface FederationRelayProfileSnapshot {
|
export interface FederationRelayProfileSnapshot {
|
||||||
|
username?: string | null;
|
||||||
displayName?: string | null;
|
displayName?: string | null;
|
||||||
avatar?: string | null;
|
avatar?: string | null;
|
||||||
avatarColor?: string | null;
|
avatarColor?: string | null;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { useSpaceStore } from '../../stores/spaceStore';
|
|||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
import { useSocialStore } from '../../stores/socialStore';
|
import { useSocialStore } from '../../stores/socialStore';
|
||||||
import { api } from '../../api/client';
|
import { api } from '../../api/client';
|
||||||
import { isSelf } from '../../utils/identity';
|
import { isSelf, parseFederatedUsername } from '../../utils/identity';
|
||||||
|
|
||||||
export function AddDmMemberModal() {
|
export function AddDmMemberModal() {
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
@@ -150,7 +150,7 @@ export function AddDmMemberModal() {
|
|||||||
key={f.id}
|
key={f.id}
|
||||||
className="flex items-center gap-1 px-2.5 py-1 rounded-full text-[12px] bg-accent-mint/15 text-accent-mint"
|
className="flex items-center gap-1 px-2.5 py-1 rounded-full text-[12px] bg-accent-mint/15 text-accent-mint"
|
||||||
>
|
>
|
||||||
{f.displayName ?? f.username}
|
{f.displayName ?? parseFederatedUsername(f.username).baseName}
|
||||||
<button
|
<button
|
||||||
onClick={() => removeFriend(f.id)}
|
onClick={() => removeFriend(f.id)}
|
||||||
className="opacity-60 hover:opacity-100 transition-opacity text-[14px] leading-none"
|
className="opacity-60 hover:opacity-100 transition-opacity text-[14px] leading-none"
|
||||||
@@ -193,6 +193,8 @@ export function AddDmMemberModal() {
|
|||||||
const isInDm = currentMemberIds.has(friend.id);
|
const isInDm = currentMemberIds.has(friend.id);
|
||||||
const isSelected = selected.has(friend.id);
|
const isSelected = selected.has(friend.id);
|
||||||
const atCapacity = !isSelected && selected.size >= remainingSlots;
|
const atCapacity = !isSelected && selected.size >= remainingSlots;
|
||||||
|
const { baseName, domain } = parseFederatedUsername(friend.username);
|
||||||
|
const friendDisplayName = friend.displayName ?? baseName;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -209,23 +211,18 @@ export function AddDmMemberModal() {
|
|||||||
>
|
>
|
||||||
<Avatar
|
<Avatar
|
||||||
src={friend.avatar}
|
src={friend.avatar}
|
||||||
name={friend.displayName ?? friend.username}
|
name={friendDisplayName}
|
||||||
size={30}
|
size={30}
|
||||||
status={friend.status as any}
|
status={friend.status as any}
|
||||||
userId={friend.homeUserId ?? friend.id}
|
userId={friend.homeUserId ?? friend.id}
|
||||||
|
avatarColor={friend.avatarColor}
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-[13px] font-medium text-txt-primary truncate">
|
<div className="text-[13px] font-medium text-txt-primary truncate">
|
||||||
{friend.displayName ?? friend.username}
|
{friendDisplayName}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[11px] text-txt-tertiary truncate">
|
<div className="text-[11px] text-txt-tertiary truncate">
|
||||||
{isInDm
|
{isInDm ? 'Already in this DM' : `@${friend.username}`}
|
||||||
? 'Already in this DM'
|
|
||||||
: friend.username.includes('@')
|
|
||||||
? `@${friend.username}`
|
|
||||||
: friend._instanceOrigin
|
|
||||||
? `@${friend.username}@${(() => { try { return new URL(friend._instanceOrigin).host; } catch { return friend._instanceOrigin; } })()}`
|
|
||||||
: `@${friend.username}`}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!isInDm && (
|
{!isInDm && (
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useUIStore } from '../../stores/uiStore';
|
|||||||
import { useSpaceStore, getApiForOrigin, resolveUserOrigin } from '../../stores/spaceStore';
|
import { useSpaceStore, getApiForOrigin, resolveUserOrigin } from '../../stores/spaceStore';
|
||||||
import { api } from '../../api/client';
|
import { api } from '../../api/client';
|
||||||
import type { User } from '@backspace/shared';
|
import type { User } from '@backspace/shared';
|
||||||
|
import { parseFederatedUsername } from '../../utils/identity';
|
||||||
|
|
||||||
export function NewDmModal() {
|
export function NewDmModal() {
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
@@ -103,21 +104,25 @@ export function NewDmModal() {
|
|||||||
<div className="py-4 text-center text-txt-tertiary text-[14px]">No users found</div>
|
<div className="py-4 text-center text-txt-tertiary text-[14px]">No users found</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{results.map((user) => (
|
{results.map((user) => {
|
||||||
<button
|
const { baseName } = parseFederatedUsername(user.username);
|
||||||
key={user.id}
|
const displayName = user.displayName ?? baseName;
|
||||||
onClick={() => handleSelectUser(user)}
|
return (
|
||||||
className="w-full flex items-center gap-3 px-3 py-2 rounded-[4px] hover:bg-interactive-hover transition-colors text-left"
|
<button
|
||||||
>
|
key={user.id}
|
||||||
<Avatar src={user.avatar} name={user.displayName ?? user.username} size={36} status={user.status as any} userId={user.homeUserId ?? user.id} />
|
onClick={() => handleSelectUser(user)}
|
||||||
<div className="flex-1 min-w-0">
|
className="w-full flex items-center gap-3 px-3 py-2 rounded-[4px] hover:bg-interactive-hover transition-colors text-left"
|
||||||
<div className="text-[14px] font-medium text-txt-primary truncate">
|
>
|
||||||
{user.displayName ?? user.username}
|
<Avatar src={user.avatar} name={displayName} size={36} status={user.status as any} userId={user.homeUserId ?? user.id} avatarColor={user.avatarColor} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-[14px] font-medium text-txt-primary truncate">
|
||||||
|
{displayName}
|
||||||
|
</div>
|
||||||
|
<div className="text-[12px] text-txt-tertiary truncate">@{user.username}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[12px] text-txt-tertiary truncate">@{user.username}</div>
|
</button>
|
||||||
</div>
|
);
|
||||||
</button>
|
})}
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ export function UserSettingsModal() {
|
|||||||
name={user?.displayName || user?.username || ''}
|
name={user?.displayName || user?.username || ''}
|
||||||
size={36}
|
size={36}
|
||||||
userId={user?.id}
|
userId={user?.id}
|
||||||
|
avatarColor={user?.avatarColor}
|
||||||
/>
|
/>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-sm font-medium text-txt-primary truncate">{user?.displayName || user?.username}</div>
|
<div className="text-sm font-medium text-txt-primary truncate">{user?.displayName || user?.username}</div>
|
||||||
@@ -160,6 +161,7 @@ export function UserSettingsModal() {
|
|||||||
name={user?.displayName || user?.username || ''}
|
name={user?.displayName || user?.username || ''}
|
||||||
size={36}
|
size={36}
|
||||||
userId={user?.id}
|
userId={user?.id}
|
||||||
|
avatarColor={user?.avatarColor}
|
||||||
/>
|
/>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-sm font-medium text-txt-primary truncate">{user?.displayName || user?.username}</div>
|
<div className="text-sm font-medium text-txt-primary truncate">{user?.displayName || user?.username}</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user