diff --git a/docs/systems/federation.md b/docs/systems/federation.md index 06b475e0..6cfd3a67 100644 --- a/docs/systems/federation.md +++ b/docs/systems/federation.md @@ -743,6 +743,7 @@ Body limit: 10 MB. Max 50 events per batch. Rate-limited to 90 requests/min per | `dm_typing_stop` | `processDmTypingStopEvent` | dm (fire-and-forget, no outbox) | | `dm_close` | `processDmCloseEvent` | dm | | `dm_reopen` | `processDmReopenEvent` | dm | +| `group_metadata_update` | `processGroupMetadataUpdateEvent` | dm | After processing all events, the relay endpoint updates the peer's `lastSeenAt` and resets `consecutiveFailures`, then returns accepted/rejected arrays plus `maxUploadSize`. @@ -757,11 +758,29 @@ After processing all events, the relay endpoint updates the peer's `lastSeenAt` **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 +1. Requires `event.group` metadata (owner + full member roster + group metadata snapshot) +2. Creates `dm_channels` row with `federatedId`, `ownerId` (resolved via `resolveOrCreateReplicatedUser`), `ownerHomeUserId`, `ownerHomeInstance`, plus the bootstrap `name`, `icon`, and `metadataUpdatedAt` from `event.group` +3. When `event.group.icon` is non-null, mirrors `processGroupMetadataUpdateEvent` and calls `downloadProfileAsset(icon, sourceInstance)` — stores the local bare filename on success or the absolute URL on failure +4. Adds ALL roster members from `event.group.members` (each resolved via `resolveOrCreateReplicatedUser`) +5. Sends `dm_channel_created` to **local-only members** (home instance matches `getOurOrigin()`, with normalization for bare domain) +6. Sets `bootstrapped = true` to skip redundant system messages and member_add broadcasts below + +#### `FederationGroupPayload` (extended) + +The `event.group` payload that bootstraps a brand-new replica carries the current metadata snapshot, so a peer that has never seen the channel materializes it with the right name and icon — no separate `group_metadata_update` round-trip needed: + +```typescript +interface FederationGroupPayload { + owner: FederationRelayParticipant; + members: FederationRelayParticipant[]; + // Extended for the polish pass: + name: string | null; // explicit null = no custom name + icon: string | null; // absolute URL on the wire; null = no custom icon + metadataUpdatedAt: number; // 0 for legacy/unset rows +} +``` + +Older peers that omit these fields fall back to safe defaults (null name/icon, `metadataUpdatedAt = 0`). Receivers never re-relay these fields — only the owner's home instance authors `group_metadata_update` events. **Incremental path** (channel already exists): 1. Validates authority: any HMAC-verified peer is accepted (relaxed — the `sourceInstance === channel.ownerHomeInstance` check was removed to support cross-instance access). The per-user attribution check (`verifyAttribution`) still applies. @@ -790,6 +809,62 @@ After processing all events, the relay endpoint updates the peer's `lastSeenAt` 5. Broadcast `dm_owner_updated` WebSocket event 6. Insert system message with previous owner as actor +Triggered by both auto-transfer-on-leave and the manual `POST /api/dm/:id/transfer` endpoint — the receiver path is the same. + +### group_metadata_update (`processGroupMetadataUpdateEvent`) + +Owner-authored update of a group DM's `name` and/or `icon`. Mirrors the `profile_update` shape — every payload carries both fields (`null` is unambiguously "cleared"), and a server-side version vector handles dedup; there is no partial-update wire form. + +**Payload:** `FederationGroupMetadataPayload`: +- `name: string | null` — trimmed, length-checked at the owner instance and re-checked by the receiver +- `icon: string | null` — absolute http(s) URL on the wire (the owner instance normalizes its bare-filename storage via `normalizeIconForWire`); receivers download and store a bare filename, or fall back to the absolute URL on download failure +- `metadataUpdatedAt: number` — captured at the moment of the owner-instance DB write +- `actor: FederationRelayParticipant` — owner by authority invariant; used only for system-message rendering + +**Authority:** `extractDomain(sourceInstance) === extractDomain(channel.ownerHomeInstance)`. Otherwise rejected as `attribution_mismatch`. Receivers never re-relay this event — the owner instance is the only emitter. + +**Targeting:** `getGroupDmTargetOrigins(channelId)` — every peer that hosts a member of the channel. + +**Coalescing:** queued via `queueGroupMetadataRelay`; rapid edits coalesce per peer with `entityId = channelId`. + +**Receiver flow (`processGroupMetadataUpdateEvent`):** +1. Lookup channel by `event.federatedId`. If missing → accepted (idempotent — no replica to update). +2. Authority: domain match against `channel.ownerHomeInstance`. Mismatch → rejected as `attribution_mismatch`. +3. Receiver hardening (don't trust remote peers): + - missing `event.metadata` → rejected as `missing_metadata_payload` + - `payload.name` non-null with trimmed length outside `[GROUP_DM_NAME_MIN_LENGTH, GROUP_DM_NAME_MAX_LENGTH]` → rejected as `invalid_payload` + - `payload.icon` non-null without `http://` or `https://` prefix → rejected as `invalid_payload` +4. Version check: `payload.metadataUpdatedAt > channel.metadataUpdatedAt`. Stale or duplicate → accepted silently (no side effects). +5. Diff against the stored row. If neither `name` nor `icon` actually changed → accepted; no system message; no broadcast. +6. Idempotency dedup pre-check on `(sourceInstance, sourceMessageId)` for both suffixes (see below). If every changed field already has its corresponding system row → accepted silently (outbox retry / initial-sync replay path). +7. Resolve icon: when `iconChanged` and `payload.icon !== null`, `downloadProfileAsset(payload.icon, sourceInstance)`. Local filename on success; absolute URL fallback on failure (mirrors `processProfileUpdateEvent`). +8. Resolve actor → local user id via `resolveOrCreateReplicatedUser`. On failure (e.g. tombstoned identity), fall back to `channel.ownerId`. If both are null → rejected as `actor_not_found`. +9. Single transaction: update `dm_channels.{name, icon, metadataUpdatedAt}`; insert one or two system messages tagged with `(sourceInstance, sourceMessageId)` using the suffix scheme `${event.messageId}:name` / `${event.messageId}:icon` so two changes in a single event yield two distinct dedup keys. +10. Broadcast `dm_channel_updated` to local members via `sendToDmMembers`. +11. Broadcast each new system message via `dm_message_created`. +12. Cleanup old local icon file when the icon changed away from a bare filename (matches the avatar precedent at `users.ts:463-466`). + +**Wire dump (illustrative):** + +```json +{ + "messageId": "1840000000000000123", + "eventType": "group_metadata_update", + "federatedId": "9b8c2f4e-1a2b-4c3d-9e8f-0a1b2c3d4e5f", + "metadata": { + "name": "weekend plans", + "icon": "https://nova.ddns.net/api/uploads/abc123.png", + "metadataUpdatedAt": 1746864000000, + "actor": { + "userId": "...", + "homeUserId": "...", + "homeInstance": "https://nova.ddns.net", + "profile": { "username": "jannis" } + } + } +} +``` + ### 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**: @@ -812,6 +887,10 @@ System messages (`type = 'system'` in `dm_messages`) are **instance-local** -- t | `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 | +| `name_changed` | `{event, oldName, newName}` | Owner who renamed | +| `icon_changed` | `{event}` | Owner who set/cleared the icon | + +The `name_changed` and `icon_changed` rows are created by both `PATCH /api/dm/:id` (origin instance) and `processGroupMetadataUpdateEvent` (receivers). On receivers they are dedup-tagged with `(sourceInstance, ${event.messageId}:name)` and `(sourceInstance, ${event.messageId}:icon)` so retries and initial-sync replay don't double-insert. ### Outbound Queuing (Origin Instance -- `dm.ts`) @@ -831,9 +910,14 @@ When a group DM is created or modified locally, the origin instance queues feder - 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`): +**Ownership transfer** (auto-on-leave; manual via `POST /api/dm/:id/transfer`): - Queues `ownership_transfer` event with `previousOwner` and `newOwner` +**Group metadata update** (`PATCH /api/dm/:id`): +- Owner-only. Queues `group_metadata_update` via `queueGroupMetadataRelay(channelId, { name, icon, metadataUpdatedAt, actor })` after the local DB write +- `targetOrigins = getGroupDmTargetOrigins(channelId)` — every peer hosting a member +- Receivers re-validate name length / icon URL scheme on inbound (see `processGroupMetadataUpdateEvent` above) + --- ## 7. File Replication