Commit Graph
1423 Commits
Author SHA1 Message Date
Jannis Braun 732d146396 feat(federation): quarantine real accounts on reset heal (freeze + free-handle) 2026-07-02 01:39:26 +02:00
Jannis Braun e0a0d92fe7 feat(federation): login self-heal epoch guard (fetchPeerEpoch, fail-closed) 2026-07-02 01:33:42 +02:00
Jannis Braun 9b945ba5b7 feat(federation): freeze login for reset-orphaned federated accounts 2026-07-02 01:27:47 +02:00
Jannis Braun d8fec00905 feat(federation): detect peer reset on needs_attention peers (§4.1)
A reset peer can reach needs_attention via the auth-failure path (HTTP up,
401/403 from a new incarnation crossing AUTH_FAILURE_THRESHOLD) without ever
passing through unreachable, so the unreachable-only recovery probe never
observes its epoch change and no reset journal is created — leaving a later
manual Re-peer with nothing to heal.

Add detectResetOnNeedsAttentionPeers() to the 15-minute health-check tick:
probe needs_attention peers with a non-null baseline (excluding those already
peer_reset_detected) and call markPeerReset on an observed epoch mismatch.
Detection only — never recovers a needs_attention peer to active; baseline
(peer_instance_id) and hmac_secret untouched.
2026-07-02 00:34:33 +02:00
Jannis Braun 7d8c9c9d8d feat(federation): peer_reset_pending guard during limbo window 2026-07-01 22:32:40 +02:00
Jannis Braun 7ef1ded116 test(federation): lock reset admissibility for peer_reset_detected peers 2026-07-01 22:22:43 +02:00
Jannis Braun 8ae8dcfd86 feat(federation): heal on re-peer with false-positive guard
Add healResetIncarnation (federationReset.ts): fires from onPeerActivated after
an authenticated re-peer to soft-tombstone the flagged pure S2S stubs of a reset
peer's dead incarnation, clearing stale friendships/DMs so the reported bug is
fixed. Two mandatory guards: a reason gate (allow-list of 8 genuine handshake
activation reasons; excludes health_check_recovery + startup_bootstrap so their
stale baseline can never silently resolve a journal without healing) and an
epoch comparison (dead_epoch === newEpoch => false alarm, no tombstone). Uses
tombstoneUser(uid, { purgeContent: false }); real federated accounts are left
flagged + intact for Phase 2. Runs outside any transaction. Wire into
onPeerActivated before the mutation-log re-sync.
2026-07-01 22:17:14 +02:00
Jannis Braun 45e1c88bdc feat(federation): reset detection (markPeerReset) via handshake + probe 2026-07-01 22:09:43 +02:00
Jannis Braun 3b1a0b64a3 feat(federation): relay envelope populates peer epoch baseline 2026-07-01 21:58:03 +02:00
Jannis Braun 8f60e92f94 feat(federation): deterministic baseline epoch-refresh worker 2026-07-01 21:49:18 +02:00
Jannis Braun bf74aa8bb2 feat(federation): signed /api/federation/epoch endpoint + caller 2026-07-01 21:43:02 +02:00
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 807b2f6234 refactor(web): fold final-review polish into join modal feature 2026-07-01 19:06:10 +02:00
Jannis Braun 309abd86e2 feat(web): discovery-first Join a Space modal 2026-07-01 18:56:56 +02:00
Jannis Braun fc8df54cb0 feat(web): compact ExploreSpacePreviewCard for join modal 2026-07-01 18:51:31 +02:00
Jannis Braun e840dcbe40 refactor(web): SpaceCard uses shared useSpaceJoin hook 2026-07-01 18:47:16 +02:00
Jannis Braun 6c66af7a88 feat(web): add shared useSpaceJoin hook over exploreStore 2026-07-01 18:42:45 +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 ac20f22c72 fix(voice): consistent state on DM-call ↔ space-channel transitions
Two mirror-image bugs from voice/DM-call transitions leaving stale state.

DM call → space channel (stuck "Connecting…"):
The last participant to leave a DM call for a space channel receives a
`dm_call_ended` echo (server empties the DM room on their `voice_join`).
The handlers called `disconnectFn()` unconditionally, tearing down the
space room they had just connected to. Route `dm_call_ended` /
`dm_call_rejected` / terminal `dm_call_undeliverable` through a new
`teardownDmCall()` that only disconnects LiveKit when not in a space
channel (`currentVoiceChannelId` null).

Space channel → DM call (still shown as "in" the voice channel):
1. Entering a DM call never cleared `currentVoiceChannelId`, so
   `VoiceChannel` mapped the DM call's live LiveKit participants onto the
   old space channel. Add `clearSpaceVoiceForDmCall()`, called in
   `connect()` when `isDm`, restoring the invariant that a DM call has no
   `currentVoiceChannelId`.
2. `dm_call_accepted` gated the caller's connect on `!isLiveKitConnected`,
   so a caller already in a space channel was never connected to the DM
   room. Gate on `wasOutgoingCall` only (connect() de-dupes same-room).

Tests: teardownDmCall.test.ts, clearSpaceVoiceForDmCall.test.ts.
Docs: docs/systems/voice.md.
2026-07-01 02:08:53 +02:00
Jannis Braun cfe4fd80c4 fix(icons): render small favicons from 3D raster to kill white tab-border
The flat app-icon.svg's gradient B mark has a bright (#fff) sheen that runs
to the badge perimeter with no dark separation. At favicon sizes (16/32px)
that edge anti-aliases into a white halo that reads as a border around the
icon — visible in Safari browser tabs, and the same defect in the small
Windows .ico / Linux launcher reps that also rendered from the flat SVG.

The committed 3D raster masters (used by every >=128px output already) frame
the mark in a dark surround and stay clean down to 16px. Set RASTER_THRESHOLD
0 so all app-icon sizes route through the raster path; the flat SVG is kept
as a gated source, re-enablable only with a corrected flat mark. Regenerated
favicons + small desktop reps; output remains byte-deterministic. Updated the
generator header/comments, README source matrix, and the dated icon spec.
2026-07-01 00:58:51 +02:00
Jannis Braun 0eb65b6608 fix(uploads): keep HEVC inline playback for Safari/WebKit
The server's `playable` flag is computed Chromium-first, but HEVC
web-playability is browser-dependent: WebKit (Safari on macOS/iOS) decodes
HEVC via the OS while Chromium/Firefox/Electron can't. Treating the flag as
global wrongly showed Safari users the download fallback for files they can
play inline.

The client now treats `playable === false` as "needs a capability check": it
pre-renders the fallback only when the current browser also can't decode the
format, gated on a one-time canPlayType probe (BROWSER_SUPPORTS_HEVC). Capable
browsers attempt inline playback; the <video> onError handler remains the
safety net for genuine failures.
2026-06-30 17:41:42 +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 e372a7a571 feat(web): Check now button to manually recover unreachable federation peers 2026-06-26 13:58:38 +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 a4af708a41 fix(web): uniform category spacing in channel sidebar
The scroll container's space-y-[2px] utility set margin-bottom:0 (via
Tailwind's space-y reverse mechanism) on every category wrapper except
the first, with higher specificity (.space-y > :not ~ :not = 0,3,0) than
each category's mb-[19px] (0,1,0). This zeroed the 19px separator for all
but the first category, so the inter-category gap rendered only below the
first category and shifted when categories were reordered.

Remove the redundant container-level space-y; the per-category mb-[19px]
is the intended separator and now applies uniformly.
2026-06-25 12:21:37 +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 3c134fe0e8 feat(sounds): self-made Ogg cues + watch/mute/pop fixes
Replace ripped Discord audio with self-authored Ogg Vorbis files (avoids
copyright on open-source launch) and fix three cue bugs:

- Anti-pop envelope: playSound now applies a 10ms fade-in (+ tail fade-out
  for one-shots) so buffers no longer start at a non-zero amplitude. Kills
  the pop on the looping call cues. Mirrors playTestTone.
- Deafen no longer plays mute+deafen at once: toggleDeafen flips isMuted and
  isDeafened atomically, so SoundController saw both transitions. New tested
  pure helper selectVoiceStateSound() suppresses the side-effect mute cue.
- Viewer-side watch feedback: stream_user_joined/left now play locally on the
  watcher's own machine for explicit Watch/Stop actions, via
  handleViewerWatchToggle. Direct local playback (not a watchingStreams diff)
  keeps auto-teardown silent and works identically on Safari and Electron.

Docs: docs/systems/sounds.md updated (Ogg, dual-audience cue rows, mute/deafen
selection, viewer feedback, anti-pop envelope).
2026-06-20 00:52:58 +02:00
Jannis Braun bc286c2750 merge: fix(chat): pagination skeleton no layout shift into main
Eliminates the visible push-down/snap-back when the pagination loading
skeleton appears at the top of the message list. Achieved by rendering a
constant-height (~200px) top-of-list slot above messages whenever
hasMore===true; the skeleton's grey-bar contents toggle inside that slot
rather than the slot itself mounting/unmounting. Companion changes:
load-more trigger raised to fire before the slot enters the viewport with
an iOS Safari rubber-band guard, prepend scroll-restore math corrected to
handle non-zero prevScrollTop, useDelayedLoading threshold lowered to
50ms for pagination only.
2026-05-15 12:57:38 +02:00
Jannis Braun fb38fee965 fix(chat): lower pagination skeleton delay threshold to 50ms 2026-05-15 12:32:09 +02:00
Jannis Braun 8beb093aab fix(chat): eliminate pagination skeleton layout shift via constant-height slot 2026-05-15 12:29:28 +02:00
Jannis Braun 886111b21a test(useDelayedLoading): lock in custom threshold parameter behavior 2026-05-15 12:22:11 +02:00
Jannis Braun dc6caa0125 merge: wip(mobile): in-progress mobile + voice polish into main
Brings the parked mobile voice + screenshare polish from wip/mobile-polish
(commit dbb9b2c) into main. Conflicts in MainContent.tsx and mobile-ui.md
expected per the WIP commit message — resolved manually.
2026-05-12 13:36:08 +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 87ecf0f4f3 fix(ui): AvatarStack — center inner Avatar in each tile (off-center clipping)
Each AvatarTile rendered at `size × size` with a 2px border under
`box-sizing: border-box`, giving a content area of `(size − 4) × (size − 4)`.
The inner `<Avatar size={size}>` exceeded the padding box, and the
`overflow-hidden + rounded-full` clip — centered on the wrapper — combined
with the avatar contents anchored at the padding-edge top-left to displace
photos and especially the centered initials gradient + letter toward the
lower-right of the visible disc, leaving a sliver of background opposite.
Compounding this, `Avatar`'s `inline-flex` root sat on the line-box text
baseline, so any inherited `line-height ≠ 1` (the DM list inherits the
row's line-height) drifted the avatar a further several px vertically.

Two corrections:
  • Size the inner Avatar to `size − 2·TILE_BORDER_WIDTH` (matches the
    padding box) — extracted as a constant so the dependency between
    `border-2` and the inner size is visible.
  • Center geometrically via `flex items-center justify-center` on the
    wrapper, bypassing inline-flow placement so the Avatar is anchored
    regardless of inherited type metrics.

Verified in the live app (DM sidebar, chat header, welcome header) and
with a 4-tile diamond at sizes 32 and 80: visible offset is now exactly
the border width on every tile, letters/photos sit dead-center.

Adds a regression test that pins both invariants (flex centering classes
present + inner Avatar style.width === tileSize − 4) so a future change
that re-introduces the bug fails fast. 12/12 AvatarStack tests, 365/365
web tests, typecheck clean.

design-system.md spec updated with the AvatarTile geometry contract.
2026-05-10 23:18:24 +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