Commit Graph
650 Commits
Author SHA1 Message Date
Jannis Braun 538519fcd2 feat(federation): exchange + store peer epoch on handshake 2026-07-01 21:35:20 +02:00
Jannis Braun 7acf48d0a4 feat(federation): shared epoch types + getInstanceId() 2026-07-01 21:19:25 +02:00
Jannis Braun d8f2b1a9c7 feat(federation): instance epoch schema + minting 2026-07-01 21:11:34 +02:00
Jannis Braun f481e1fe9e license: relicense to AGPL-3.0-only with commercial dual-license
- LICENSE -> verbatim GNU AGPL-3.0; add LICENSE-COMMERCIAL.md + SECURITY.md
- CLA -> exclusive-license grant (contributors keep copyright); add README
  anti-rugpull covenant + relicense record
- NOTICE / README / CONTRIBUTING / CLAUDE.md / package.json x5 updated;
  contact routed through GitHub (no email placeholders)
- AGPL section 13 source offer: operator-configurable BACKSPACE_SOURCE_URL +
  build-injected commit; sourceCodeUrl+commit on /api/instance/info;
  SourceCodeLink on login/register/settings/desktop; docs + .env.example updated
2026-07-01 16:38:22 +02:00
Jannis Braun 209aef7e9d fix(uploads): graceful fallback for browser-unplayable video (HEVC .mov)
macOS screen recordings are HEVC inside a .mov container, which Chromium,
Firefox and stock Electron can't decode. The file uploaded fine and a
server-side ffmpeg poster was generated, but inline <video> playback failed
silently — stuck at 0:00 with no error, since AttachmentRenderer had no error
handling. Root cause: the system had no concept of web-playability.

Server detects, client degrades:
- mediaPlayable.ts: classifyVideoPlayable(mimetype, codec) — tri-state
  (false = known-undecodable e.g. HEVC/ProRes, true = web codec in web
  container, null = unknown/optimistic). Never widens `false` beyond codecs
  that fail everywhere, so ffmpeg-less instances keep prior behaviour.
- probeMediaMeta now captures the video codec_name; the upload finish hook
  stores the verdict in the new attachments.playable column (migration 0007).
- Flag propagated through every serializer: space messages, DMs, WS, and
  federation relay (outbound + inbound) — federation-compatible.
- VideoAttachment component: playable===false renders a download card (poster
  + "Can't play here — download" + name/duration/size) with no dead-player
  flash; otherwise plays inline with an onError fallback to the same card.

Specs updated: uploads.md, database.md, federation.md.
2026-06-30 17:38:11 +02:00
Jannis Braun e84daf57aa fix(voice): push voice presence to user on mid-session space join
Voice presence (voiceStates/voiceUserStates/spaceVoiceStates) was only ever
delivered in the WS `ready` payload — i.e. at connect/reload. A user joining a
space mid-session got `member_joined` (no voice data) and a bare space object;
`GET /api/spaces/:id` (the channel-sidebar hydrator) carries no voice state
either. So members already sitting in a voice channel stayed invisible in the
new member's sidebar until a full page reload.

Fix at the systemic root: ConnectionManager.addUserSpace — the single chokepoint
every join path funnels through (invite, public join, join-request approval),
and which is NOT used on reconnect (that path uses setUserSpaces) — now pushes a
scoped `space_voice_state` snapshot to the joining user. The snapshot is built by
a new buildSpaceVoiceState(spaceId, userId) helper that is also the single source
of truth feeding buildReadyPayload (refactored to use it), so the connect-time
and join-time paths can never drift.

Robustness:
- Delivered over the same ordered WebSocket as voice_state_update deltas — no
  REST snapshot-vs-event-stream race.
- VIEW_CHANNEL-filtered via computePermissions exactly like `ready`: a joiner is
  never told who occupies a voice channel they cannot see.
- Client applies it scoped to the space (utils/voiceStateSync.applySpaceVoiceState):
  merges occupants/statuses and rebuilds only that space's restriction keys,
  never disturbing voice state in other spaces.
- Skipped when the space has no active voice and no restrictions (e.g. space
  creation).

Tests: server helper behavior, the join push, and private-channel exclusion;
client scoped-apply. Specs updated (websocket.md, voice.md, spaces.md).
2026-06-30 17:00:37 +02:00
Jannis Braun 4ad83a7cc1 feat(federation): POST /peers/:id/recheck — manual reachability probe 2026-06-26 13:53:57 +02:00
Jannis Braun 603973a02e feat(federation): demand-driven recovery tick; health-check tick keeps rotation only 2026-06-26 13:49:40 +02:00
Jannis Braun a6c7584e0c feat(federation): lean federationRecovery module (probe + markPeerRecovered) 2026-06-26 13:44:37 +02:00
Jannis Braun bafc7fbb47 feat(federation): add last_probe_at/probe_attempts columns for peer recovery pacing 2026-06-26 13:41:29 +02:00
Jannis Braun 00a2876e96 fix(channels): newly created channel sometimes hidden until space reopened
The sidebar's visibleChannels filter is keyed on the channelPermissions
Map. Creating a channel raced two state updates: the optimistic create
(added to channels with no permission entry) and the channel_created WS
event (the only thing that set the permission). When the optimistic add
won the race, the WS handler hit its dedup guard, skipped setChannels,
and set the permission by mutating the Map in place — no new reference,
so visibleChannels never recomputed and the channel stayed hidden until
loadSpace rebuilt the maps (i.e. leaving and returning to the space).

Centralize the logic in a new upsertChannel store action that replaces
channels and channelPermissions with fresh references, used by both the
create path and the channel_created handler. Also return the creator's
computed myPermissions (and isPrivate) from POST so the channel renders
immediately from the response, independent of WS timing.

Adds spaceStore.upsertChannel.test.ts covering the reference-identity
regression and the optimistic-reconcile path.
2026-06-25 12:34:11 +02:00
Jannis Braun 8dd76f3435 Public-release prep: ELv2 license, README/CLA/NOTICE, SSRF safeFetch, identifier genericization, export tooling 2026-06-22 16:04:03 +02:00
Jannis Braun a91702a255 fix(backup): manual snapshot CLI must open its own DB handle (getRawDb undefined in standalone process) 2026-06-20 02:58:46 +02:00
Jannis Braun 5107f63e60 harden(security): chmod seed-admin-rotated.txt to 0600 (guarantee perms on overwrite) 2026-06-20 02:51:13 +02:00
Jannis Braun 70da1ede64 feat(backup): manual-snapshot CLI + host-side backup.sh and restore.sh (safe restore) 2026-06-20 02:34:30 +02:00
Jannis Braun 9407e48293 feat(backup): scheduled snapshot worker wired into server lifecycle 2026-06-20 02:32:17 +02:00
Jannis Braun 154feb2dd6 feat(backup): pre-migration snapshot gated on pending migrations; checkpoint WAL on shutdown 2026-06-20 02:26:51 +02:00
Jannis Braun 48bcd69031 feat(backup): VACUUM INTO snapshot core (create/list/prune) + config + off-box hook 2026-06-20 02:21:22 +02:00
Jannis Braun 4b44996aac feat(security): idempotent remediation script to rotate seeded admin123 on existing instances 2026-06-20 02:15:39 +02:00
Jannis Braun ae61786335 feat(security): remove hardcoded admin/admin123 seed; first registered user is admin 2026-06-20 02:07:47 +02:00
Jannis Braun 7351b3d90d fix(dm): surface dm.name on chat header/placeholder; collapse unnamed-group placeholder
Three layered bugs all manifesting as "the group name doesn't show / the
placeholder is a 40-character wall of names":

1. **WS ready payload was missing `name`/`icon`.** The handler serialized
   DmChannel rows with only `id, federatedId, ownerId, createdAt, members,
   lastMessage`. The optional metadata fields were silently dropped, so
   the client store never received `dm.name` until a subsequent
   `dm_channel_updated` event fired (i.e. only mid-session renames worked,
   never the initial render). `ownerHomeUserId`, `ownerHomeInstance`, and
   `metadataUpdatedAt` were also missing — added too because federated
   routing depends on `ownerHomeInstance` (`getDmOwnerHomeInstance`).

2. **Header surfaces silently dropped `dm.name`.** `MainContent` (desktop
   chat header) and `MobileChatScreen` always rendered the joined member
   names, even when `dm.name` was set. Five other surfaces (`DmListItem`,
   `MobileDmsScreen`, `MessageList` welcome hero, `MobileGroupDmInfo`,
   `GroupDmSettings`) honored it correctly, so a renamed group showed
   different titles depending on which surface you looked at.

3. **Message-input placeholder rendered joined names.** Once a group
   has 4+ members "Message #Alice, Bob, Charlie, Dave" overflows the
   textarea and obscures the call-to-action.

Consolidates the display-name logic behind two utilities in
`dmFormatters.ts`:

  - `formatDmHeaderName(dm, currentUser)` — `dm.name` verbatim if set,
    else joined names (excluding self); falls back to `'Group'` /
    `'Direct Message'`. Used by all 5 header surfaces (was inlined
    5 different ways).
  - `formatDmInputLabel(dm, currentUser)` — `'#<name>'` if set,
    `'the group'` for unnamed groups (collapses the unreadable
    joined-names form), `'@<partner>'` for 1-on-1.

`MessageInput` accepts an optional `placeholder` prop that bypasses the
default `Message {#|@}<channelName>` derivation; DM call sites use it
to inject the `formatDmInputLabel`-based form. 1-on-1 DMs keep the
canonical-view lookup so replicated aliases still surface the home
account's displayName; the placeholder reuses the canonical `dmName`
so header + placeholder stay aligned even when raw partner ≠ canonical.

13 new unit tests covering `formatDmHeaderName` (8 cases: named, blank,
joined, federated-username base, empty group, 1-on-1, no-displayName,
no-partner) and `formatDmInputLabel` (4 cases: named, unnamed,
whitespace-only, 1-on-1). 365 → 377 web tests, 1053 server tests,
typecheck clean.
2026-05-10 23:41:11 +02:00
Jannis Braun 3c7bb02901 fix(dm): ownership transfer divergence after back-and-forth — canonicalize ownerHomeInstance + normalize authority checks
Manual ownership transfers between two federated instances diverged because
`dm_channels.ownerHomeInstance` was stored as a BARE host (`orbit.ddns.net`)
for federated owners — via `transferGroupDmOwnership` copying `users.homeInstance`
verbatim — while `sourceInstance` always arrives as a full URL on the wire.
`processOwnershipTransferEvent` and `processMemberRemoveEvent` then compared the
two with strict equality and rejected legitimate inbound events as
`unauthorized_source`, keeping ownership permanently divergent across peers.
Live DB inspection on the two test instances confirmed both rows (nova + orbit)
had a BARE `owner_home_instance`, matching the bug report exactly.

Three compounding fixes:

1. Receiver authority checks now compare via `normalizeOriginForCompare` so
   legacy bare-vs-full rows accept legitimate transfers (and kicks).
2. New `canonicalizeHomeInstance` helper in `federationAuth.ts`; every write
   site that persists `ownerHomeInstance` (`transferGroupDmOwnership`, group DM
   creation, lazy federation in member-add, `processMemberAddEvent` bootstrap,
   `processOwnershipTransferEvent` receiver storage) routes through it. Full URL
   is the canonical storage form, matching how `sourceInstance` arrives.
3. `dm_owner_updated` WS event extended with optional `newOwnerHomeUserId` and
   `newOwnerHomeInstance` fields. Client `updateDmOwner` writes them when
   present and leaves existing values untouched otherwise (legacy-server safe).
   Without this, `getOwnerInstanceForDm` returned the previous owner's home
   after a successful WS broadcast, routing the next owner-only op to the wrong
   instance.

Coverage: new `federation.ownershipTransfer.test.ts` (7 receiver tests including
the headline bare-vs-full regression and the dedup replay guard); new bare-vs-full
case in `federation.kick.test.ts`; two new client-side cases in
`groupDm.ownerRouting.test.ts` covering both the extended-payload write path and
the legacy-server passthrough. Tests: 1053 server + 364 web, all green.

Specs updated: `dm-system.md` historical bugs + frontend handler table + WS
state-change events table; `federation.md` `ownership_transfer` receiver flow;
`websocket.md` event-fields table.
2026-05-10 22:38:03 +02:00
Jannis Braun b6842c5590 fix(dm): owner-only group DM ops accept federated target identification
Transferring ownership or kicking a member surfaced "Target user is not a
member of this DM channel" whenever the target was a federated user.

Root cause: the client passed `canonical.id` from `useCanonicalUserView`,
which returns the user's HOME id when the home view is in the userViews
cache. After owner-routing the request to the owner instance, that
instance's `dm_members.userId` (its own local replicated id) never
matched the home id, so `isDmMember` returned false. The same failure
mode applied across any cross-instance scenario where the
channel-serving instance and the owner-serving instance disagree on the
local replicated user id for the same federated user.

Fix: both endpoints now accept federated identification, mirroring the
existing pattern on `POST /api/dm/:id/members`:

- `POST /api/dm/:id/transfer` body: `{ newOwnerId? } | { homeUserId, homeInstance }`.
  Federated args win when both are supplied (strictly more specific).
- `DELETE /api/dm/:id/members/:targetUserId` reads optional
  `?homeInstance=<origin>` query; when present, the URL segment is
  treated as a homeUserId and resolved via `resolveOrCreateReplicatedUser`.

Client `api.dm.kickMember` and `api.dm.transferOwnership` gain an
optional `federated` argument; `DmRosterPanel` and `MobileGroupDmInfo`
pass it whenever the target has `homeUserId` + `homeInstance` populated.

Adds 5 server tests (3 transfer + 2 kick) covering federated targets,
the federated-wins-over-local precedence rule, and federated-non-member
rejection. Updates 2 client routing tests and 2 DmRosterPanel test
assertions for the new signature. Updates `docs/systems/dm-system.md`
and `docs/systems/api.md`.

Server: 965 tests pass (was 960). Web: 362 tests pass (was 360).
2026-05-10 22:09:14 +02:00
Jannis Braun 24240f24f4 fix(janitor): protect dm_channels.icon files from cleanup (owner + receiver)
getProfileReferencedFilenames() didn't include dm_channels.icon, so the
storage janitor deleted group DM icons within ~1 hour:

- Owner instance: PATCH /api/dm/:id leaves an attachments row with
  messageId=null and dmMessageId=null. After 1h, getUnlinkedAttachments
  flags it and cleanupStorage phase 2 deletes the file because the
  filename isn't in profileReferenced.

- Receiver instance: downloadProfileAsset writes the icon directly to
  uploadDir with no attachments row. cleanupStorage phase 1 treats it
  as orphaned and deletes it.

Fix: include dm_channels.icon (non-null, not soft-deleted, not http://)
in the profile-referenced set. Soft-deleted DMs are excluded so their
files still get reaped by cleanupSoftDeletedDmChannels. Absolute URLs
are skipped because they live on a remote instance.

Also mirror the avatar precedent at the PATCH endpoint by deleting the
new icon's standalone attachment row — the file is now protected via
dm_channels.icon, matching users.ts:473.
2026-05-10 21:18:58 +02:00
Jannis Braun 063ed2dd64 refactor(federation): extract normalizeIconForWire helper 2026-05-10 19:31:06 +02:00
Jannis Braun 04d7910077 feat(federation): bootstrap carries group name + icon + metadataUpdatedAt 2026-05-10 19:23:47 +02:00
Jannis Braun 7c8de29eff test(federation): lock kick authority behavior in member_remove receiver 2026-05-10 19:14:48 +02:00
Jannis Braun 78d2ab496d refactor(federation): drop dead export on downloadProfileAsset 2026-05-10 19:10:56 +02:00
Jannis Braun 240df49d1d feat(federation): processGroupMetadataUpdateEvent with receiver hardening 2026-05-10 19:04:00 +02:00
Jannis Braun 7f8d650cab refactor(server): extract transferGroupDmOwnership helper + wrap in transaction 2026-05-10 18:56:01 +02:00
Jannis Braun 35c720429e feat(server): POST /api/dm/:id/transfer — manual ownership transfer 2026-05-10 18:46:41 +02:00
Jannis Braun ce11fd12b5 refactor(server): extract evictUserFromDmVoiceRoom helper + drop redundant optional chains 2026-05-10 18:42:25 +02:00
Jannis Braun 78035bfc64 feat(server): DELETE /api/dm/:id/members/:targetUserId — owner kick
Refactors the leave-DM destructive core into a shared `removeDmMember`
helper and adds a kick endpoint that reuses it. Both endpoints write the
`member_removed` system message, delete the dm_members + read_states
rows, broadcast `dm_member_removed`, and queue a federation
`member_remove` event with the appropriate `reason` ('leave' | 'kick').

Branching invariants preserved by the helper:
- Ownership transfer fires only on self-leave when the leaver was the
  owner. Kicks cannot orphan a group (the owner is still present), so
  the transfer block is skipped.
- Soft-delete on last-member-empty fires only on self-leave. Kicks are
  guaranteed to leave the owner behind, so the channel can never be
  empty after a kick.

Endpoint validation:
- 1-on-1 DM → 400 'Cannot kick from a 1-on-1 DM'
- Caller not the owner → 403 'Only the group owner can remove members'
- Self-target → 400 'Owners cannot kick themselves; use leave instead'
- Target not a member → 404
- Channel missing or soft-deleted → 404

The kicked user is also evicted from the DM voice room (mirroring the
self-leave path) and receives `dm_channel_closed` so the client closes
the channel locally.
2026-05-10 18:35:29 +02:00
Jannis Braun a06776fd86 feat(server): PATCH /api/dm/:id — group name + icon update 2026-05-10 18:23:48 +02:00
Jannis Braun 096f12daf2 fix(federation): queueGroupMetadataRelay tighter http(s) check + caller contract note 2026-05-10 18:13:12 +02:00
Jannis Braun 4d97dd1253 feat(federation): queueGroupMetadataRelay helper + outbox-worker reconstruction 2026-05-10 18:07:32 +02:00
Jannis Braun 2afe230de0 feat(shared): group_metadata_update event + extended FederationGroupPayload (with safe defaults at producers) 2026-05-10 18:00:07 +02:00
Jannis Braun f2542fd849 feat(db): dm_channels.name/icon/metadata_updated_at 2026-05-10 17:58:00 +02:00
Jannis Braun 724ba31da0 test(server): hermetic env defaults — tests no longer require local .env
config.ts requires JWT_SECRET at module-load time. Since `.env` is gitignored
it doesn't propagate to git worktrees, fresh clones, or CI without secrets —
producing 200+ confusing cascade failures (vitest module-mock errors that
look unrelated to the real cause).

Add a vitest setupFile that sets JWT_SECRET to a fixed test-only value via
`??=`, so a real .env still wins where one exists. Tests are now hermetic:
clone the repo, `pnpm install`, `pnpm --filter server test` → 477/477 pass.
2026-05-10 17:42:22 +02:00
Jannis Braun 1effb1c53f fix: live presence on freshly-friended remotes + green dot in same session
Two follow-on bugs from the initial S2S presence rollout:

(1) New friend stuck offline until they reload: presence_update fires only on
    transitions, so a remote user already online when their stub is created
    locally never receives a relay event seeding their actual status. The
    stub defaulted to 'offline' at creation and stayed there until the next
    transition. Fix: extend FederationRelayProfileSnapshot +
    FederationUserLookupProfile with status. Sender-side buildProfileSnapshot,
    getDmParticipants, and lookup endpoint responses populate it for native
    users only (replicated stubs hold stale status owned elsewhere).
    resolveOrCreateReplicatedUser uses hints.status to seed the new row's
    status column. Threaded through every call site (DM participants, group
    bootstrap, friend events, ownership transfer). Stub backfill worker also
    heals existing rows whose status was stuck at 'offline' from creation.

(2) 'Online' text updates but green avatar dot stays grey on the same page:
    spaceStore.updateMemberPresence patches members[] (which feeds space UIs)
    but never patches userViews — the cache useCanonicalUserView reads from.
    The Avatar in FriendItem reads canonical.status; the text reads
    friend.status (socialStore). Two sources, one stale until full
    user_updated arrives. Fix: updateMemberPresence now mirrors status into
    matching userViews entries, so canonical-view consumers re-render with
    fresh status the moment the WS event lands.
2026-05-05 16:44:22 +02:00
Jannis Braun d2bd7987c1 fix(federation): include presenceUpdate in outbox-to-relay event rebuild
The outbox worker rebuilds FederationRelayEvent objects from stored JSON via
an allowlist of known fields. presenceUpdate was missed when presence_update
events shipped, so peers received events with eventType='presence_update' but
no payload — rejected with missing_presence_update_payload on every tick.
2026-05-05 16:28:07 +02:00
Jannis Braun 1cb151d3a9 feat(federation): peer-lifecycle presence hooks (snapshot on activate, mark-offline on deactivate)
onPeerActivated re-emits a presence_update for every relationship-related
online native to the activating peer (relationship = friend with peer-stub /
DM-mate with peer-stub / replicatedInstances opt-in for peer origin). Scope
bounded by relationship count, not native count — flap recovery cost stays
proportional to actual interaction surface.

onPeerDeactivated flips every replicated stub from that peer to offline and
broadcasts a local presence_update so connected friends/DM-mates/space-co-members
see them disappear immediately, instead of seeing stale online until next signal.

Re-snapshot on every activation (incl. health-check unreachable→active flap)
is load-bearing for correctness — markPeerStubsOffline ran on the prior
deactivation and presence is not in the mutation log.
2026-05-05 16:12:22 +02:00
Jannis Braun ad1a0f7164 fix(presence): broadcast presence_update to friends + DM members + space members
Six WS sites that previously broadcast presence_update to spaces only now use
collectProfileBroadcastTargetIds (the same recipient set as user_updated):
  - ws/handler.ts finalizeDisconnect (offline)
  - ws/handler.ts auth path (online)
  - ws/events.ts handlePresenceUpdate (manual idle/dnd/online)
  - ws/events.ts handleActivityUpdate (rich activity changes)
  - routes/users.ts showActivity-toggle clear
  - routes/users.ts status PATCH

Friends with no shared space + DM-only co-members now see each other's
online/offline transitions live, matching user_updated semantics. Federated
stub presence broadcasts (Task B3) use the same helper, so cross-instance
recipients are uniform.

Updates one assertion in social.federated.test.ts that asserted the old
snowflake-style stub username (now realname-based per A1).
2026-05-05 16:06:45 +02:00
Jannis Braun 53fe7d2b53 feat(federation): process inbound presence_update relay events
processPresenceUpdateEvent updates the local stub's status and broadcasts a
WS presence_update to friends + DM members + space co-members of that stub
via collectProfileBroadcastTargetIds. Closes the doc/code drift in
activity-presence.md:147 — federated stubs now have their status projected
by the home instance as documented.

Strict attribution: payload.homeInstance domain must equal source peer
domain. Silently no-ops when no local replica exists (peer broadcast fanout
covers all peers, not all hold a stub).
2026-05-05 16:03:18 +02:00
Jannis Braun 613424e1c7 feat(federation): queue S2S presence_update on auth/disconnect/status/activity changes
New FederationPresenceUpdatePayload + queuePresenceRelay() helper. Five WS
sites now project the native user's status (and optional activities) to all
active peers via the outbox: WS auth-success, finalizeDisconnect,
manual presence_update, activity_update, showActivity-toggle clear.

Outbox-only (no mutation-log entry) — presence is ephemeral; the upcoming
peer-activation hook re-emits a fresh snapshot so peers recovering from
unreachable converge without history replay. No-op for replicated users.
2026-05-05 16:01:28 +02:00
Jannis Braun bdbc90ebd2 feat(federation): backfill snowflake-named replicated-user stubs
Heals existing legacy stubs (created when resolveOrCreateReplicatedUser used
homeUserId@domain) by asking the peer for the canonical username via
lookupRemoteUserByHomeId and rewriting the local row. Idempotent and
collision-safe.

Wired into onPeerActivated (per-origin) so future peer flaps re-attempt for
stubs whose home was unreachable on a prior pass, and into a one-shot pass at
startupBootstrapSync for all currently-active peers (not just first-time
lastSyncedAt=0 peers).
2026-05-05 15:57:37 +02:00
Jannis Braun b2faf5afaa feat(federation): add /users/by-home-id reverse lookup for stub backfill
HMAC-authenticated, rate-limited (60/min/peer) endpoint that resolves a
homeUserId on this instance to its canonical username + profile snapshot.
Native non-deleted users only. Mirrors /users/lookup's auth shape.

Adds lookupRemoteUserByHomeId to federationLookup.ts as the client-side
helper. Used by the upcoming stub-backfill worker on peers that hold legacy
snowflake-named replicas of users now visible by their real handle.
2026-05-05 15:54:06 +02:00
Jannis Braun 097eb9a2ef feat(federation): preserve effective displayName across profile_update relay
FederationProfileUpdatePayload gains `username`: the home user's canonical
handle. Receiver applies displayName ?? username so stubs whose home user has
no displayName show the real handle instead of getting clobbered to null.
Mirrors the existing fallback in hydrateReplicatedUserProfile. Username itself
is immutable on the home instance, so the receiver does not rewrite the stub's
username column on profile_update.
2026-05-05 15:48:56 +02:00
Jannis Braun 1d353c994b fix(federation): create replicated-user stubs with realname@domain when hint provided
When friend_request_create / friend_add / DM relay carries a profile snapshot,
the canonical-username hint is now used as the stub's local-part. Stubs created
purely from S2S (no client-federation) now display the human-readable handle,
not the homeUserId snowflake. Falls back to the snowflake-id scheme only when
no hint is available.
2026-05-05 15:46:26 +02:00
Jannis Braun c7eb14d38e test(federation-identity): #18 unrelated federated user untouched 2026-05-04 00:32:48 +02:00