144 Commits
Author SHA1 Message Date
devsyncwrld f5451e1b14 feat: soundboard, account menu, and call timer
OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
Soundboard: the trigger travels over the WebSocket and every client in the
call plays the clip locally, instead of mixing it into the presser's
microphone or publishing a LiveKit track. No upstream bandwidth, no media
stack changes, and the clip is not degraded by voice processing.

Fan-out uses a new sendToRoomParticipants rather than sendToRoom: the latter
broadcasts a space room to the whole space, which is right for the presence
the sidebar shows and wrong for anything audible. The cooldown is enforced
server-side — a client-side one only slows down people not trying to abuse it,
and a soundboard is the easiest thing here to turn into a weapon. Playing is
open to anyone in the call; deciding what the buttons are needs MANAGE_SPACE.

Account menu: the name in the user bar had cursor-pointer and no handler, so
the interface was already promising a click that did nothing. Offers profile,
status and copy-id — not the Clips or account switching the reference design
shows, which would be dead UI here.

Call timer: startedAt comes from the server, so a late joiner sees the call's
age rather than their own arrival. Empty space rooms are destroyed already,
which is what makes the next call start from zero — no reset logic needed.
2026-08-31 13:45:18 -03:00
devsyncwrld bbb190cbda feat(audit): append-only audit log for spaces
OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
Records who changed what, and is the mechanism statistics will read — one
event table rather than two logs that drift apart.

The table is deliberately generic (action + target + JSON metadata) so a new
action needs no migration. Writes never throw: a kick must not fail because
its log entry could not be written, since the kick already happened.

Leaving is recorded as a different action from being removed. The same route
serves both, and a log that conflates them misleads exactly when it matters.

Actor is nullable with ON DELETE SET NULL: the event outlives the account, and
a log that vanished with its actor would be worthless. Reads are gated on
MANAGE_SPACE rather than a new permission bit, which would default to nobody
until every role was re-edited. Paging uses the snowflake id, stable even for
two events in the same millisecond, and an action this build does not know
still renders a row.
2026-08-31 13:22:52 -03:00
Jannis Braun d45366c4ff feat(federation): owner-initiated detached-account re-attach — proof-gated re-bind with stub merge (re-attach spec §3.2, §3.3) 2026-07-03 02:06:00 +02:00
Jannis Braun 3591773a4c feat(federation): deleted flag on wire profile snapshots — tombstone markers never leave the instance (dead-incarnation spec §3.3) 2026-07-03 00:59:37 +02:00
Jannis Braun 42ad5e141d feat(federation): server-side acknowledge for reset events (acknowledged_at + admin endpoint) (detach spec §4.6) 2026-07-02 18:59:33 +02:00
Jannis Braun 7e1e32de69 feat(federation): detached accounts get local profile+password self-service; self-view flag (detach spec §4.4, §4.7) 2026-07-02 18:54:37 +02:00
Jannis Braun df1e457971 feat(federation): add needs_attention_reason 'repeer_incomplete' (BUG-2 prep) 2026-07-02 11:46:10 +02:00
Jannis Braun 290cd606c0 feat(federation): GET /reset-events admin endpoint + types 2026-07-02 01:47:58 +02:00
Jannis Braun ee52ff0c7b feat(federation): expose needsAttentionReason on peer API 2026-07-02 01:43:12 +02:00
Jannis Braun 45e1c88bdc feat(federation): reset detection (markPeerReset) via handshake + probe 2026-07-01 22:09:43 +02:00
Jannis Braun 7acf48d0a4 feat(federation): shared epoch types + getInstanceId() 2026-07-01 21:19:25 +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 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 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 2afe230de0 feat(shared): group_metadata_update event + extended FederationGroupPayload (with safe defaults at producers) 2026-05-10 18:00:07 +02:00
Jannis Braun e4717ef733 feat(shared): group DM name + icon validation constants 2026-05-10 17:56:49 +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 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 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 2f0940c30b feat(admin): manual cleanup of stale tus upload sessions + visibility
Adds an admin-driven sweep on top of the existing 24h auto-expire so
operators can see and reap abandoned `.tus/` sessions without waiting.

- storageJanitor: extract `walkTusDir(predicate)` helper, add
  `getStaleTusInfo` + `cleanupStaleTusSessions(thresholdMs, dryRun)`;
  refactor `cleanupTusStragglers` to delegate while preserving its
  janitor-tick `{ removed }` contract.
- StorageStats gains `staleTusSessions` + `staleTusSize` (fixed 1h
  display threshold).
- New `POST /api/admin/storage/cleanup-tus` route with
  `maxAgeHours` validation (positive finite number, default 1) and
  `dryRun` support; admin-gated.
- StoragePanel: 6th overview card "Stale Uploads" + new cleanup
  subsection mirroring the media-cleanup pattern (preview-then-clean
  with shared result panel styling).
- Tests: 8 new janitor tests covering empty dir, threshold filtering,
  dry-run vs live, oldest-mtime tracking, subdir skipping, and the
  override path on the existing straggler sweep. New
  `routes/admin.test.ts` covers auth/admin gates, validation (zero,
  negative, NaN), default `maxAgeHours`, dry-run vs live unlink.
- Docs: `uploads.md` §Janitor expanded to the full lifecycle (cancel
  DELETE, discard DELETE, auto-expire, straggler sweep, admin route);
  `admin.md` Storage Management updated with the new endpoint and
  StorageStats fields.
2026-05-02 18:44:19 +02:00
Jannis Braun 1ed70a90b1 fix(dm): render system messages in sidebar preview instead of raw JSON
DmLastMessagePreview lacked a `type` field, so the sidebar rendered
`lastMessage.content` verbatim — surfacing JSON like
`{"event":"space_invite",...}` for space invites and member-add events.

Adds `type` to the preview payload (populated server-side from
`dm_messages.type`) and routes all sidebar call sites through a single
`formatDmSidebarPreview` helper that renders human-readable text for
each system event and skips the group `Sender:` prefix on system rows.
2026-04-29 23:13:09 +02:00
Jannis Braun 855410f88c feat(shared): add SpaceInviteRequest / SpaceInviteResponse / SpaceInviteSystemPayload 2026-04-29 21:34:07 +02:00
Jannis Braun 04c66c4bcf feat(federation): add optional type field to FederationRelayEvent.message 2026-04-29 21:29:01 +02:00
Jannis Braun 33547038f4 feat(invites): expose lastRedeemedAt on InviteLinkSummary 2026-04-29 02:33:49 +02:00
Jannis Braun e984ce910c refactor(shared): rename PatchInviteRequest → UpdateInviteRequest + JSDoc
Matches existing codebase convention (UpdateChannelRequest,
UpdateSpaceRequest, etc.). Adds JSDoc on InviteStatus and
InviteLinkSummary.url / createdByUsername fields with server-side
semantics. Plan updated for downstream task naming consistency.
2026-04-28 19:50:05 +02:00
Jannis Braun 884b3d9fe9 feat(shared): add invite link types + federatedRegistrationOpen 2026-04-28 19:45:25 +02:00
Jannis Braun 94c291c472 refactor(shared): rename TriggerReason → PeeringTriggerReason, pin subscribers semantics
- Symmetric with PeeringNotificationKind at module scope; avoids
  future collision with unrelated trigger systems.
- subscribers field doc now explicit: undefined for inbound, present
  (possibly []) for outbound. Pins the response contract before Task 7
  implements the GET endpoint response shape.
2026-04-26 21:14:51 +02:00
Jannis Braun 55af76f8b4 feat(shared): types for outbound peering gate (intent, subscriptions, notifications) 2026-04-26 21:10:32 +02:00
Jannis Braun 1a9dc547c4 feat(shared): types for federation user lookup + WS events
Add FederationUserLookupRequest, FederationUserLookupProfile, and
FederationUserLookupResponse for the peer lookup endpoint contract.
Add friend_request_sent and friend_request_relay_failed ServerEvent
variants alongside existing friend_request_* cases.
2026-04-25 21:19:40 +02:00
Jannis Braun 3988c5823a feat(shared): add no_recipient reason + undeliverable response field (#18)
Additive protocol extension. No consumers yet — follow-up commits wire
the new bucket into the relay endpoint, sendCallRelay, sendFederatedCallStart,
and the toast copy.
2026-04-24 19:12:54 +02:00
Jannis Braun 942619f422 feat(shared): add 'host_unreachable' phase to DmCallPhase 2026-04-24 00:34:05 +02:00
Jannis Braun 5e9b353124 feat(shared): add DmCallPhase to dm_call_undeliverable event 2026-04-23 23:06:16 +02:00
Jannis Braun 1df25737b6 types: align FederationPeer union with actual server statuses
Adds 'rejected', 'awaiting_approval', and 'needs_attention' to the status
union, plus the consecutiveAuthFailures / autoRotateIntervalDays /
secretRotatedAt / rotationInProgress fields the UI already reads.
2026-04-21 20:29:14 +02:00
Jannis Braun 1be544bbd5 feat(shared): add dm_call_undeliverable event type 2026-04-21 13:33:52 +02:00
Jannis Braun 3d8709d20a feat: real-time Federation panel updates via WS events
Added federation_peers_changed (no-payload signal) broadcast from every
peer state mutation, and federation_approval_request_received when a new
approval request is queued. Client subscribes via onFederationPeersChanged
callback registry. FederationPanel and PendingApprovals debounce-refetch
on any event. sendToAdmins helper broadcasts only to admin users.
2026-04-20 18:28:10 +02:00
Jannis Braun 0aec716d4c fix: gate all client DM events on active S2S peer status
The client's direct WS connection to remote instances (via Connections)
delivered DM events independently of S2S peering. Added activePeerOrigins
allowlist to ready payload — all DM event handlers now silently drop
events from non-home origins without an active peer. This prevents
notifications, sounds, previews, typing indicators, calls, and channel
updates from instances where peering was revoked or never established.
2026-04-20 17:05:57 +02:00
Jannis Braun 33c45e3184 feat: add awaitingApprovalPeerOrigins and pendingApprovalCount to ready payload 2026-04-20 15:03:29 +02:00
Jannis Braun 4285e44d2d feat: client ensurePeered API, connection flow swap, WS event handlers for peer rejection 2026-04-09 13:56:18 +02:00
Jannis Braun abd12223b1 feat: add federation_peer_rejected/active WS types and autoAcceptPeering setting 2026-04-09 13:38:41 +02:00
Jannis Braun ba7b975bc8 feat: add federatedCallId and callOrigin to client call routing
Enable federated DM calls to route accept/reject/end through the correct
WebSocket connection using callOrigin, and include federatedCallId in all
dm_call payloads for server-side FederatedCallEntry lookup.
2026-04-08 03:26:48 +02:00
Jannis Braun 2c7eefc7b1 feat: fix caller exclusion (Bug 1) and add Path B receiver processing 2026-04-08 03:09:16 +02:00
Jannis Braun 365c64794d refactor: re-key FederatedCallEntry by federatedId, add ringedUserIds 2026-04-08 03:03:44 +02:00
Jannis Braun ba0df6305e feat: add dm_close and dm_reopen federation relay event types 2026-04-07 22:23:01 +02:00
Jannis Braun 9b8330fcdb feat: add federatedId to DmChannel type, read_state_update to relay events 2026-04-07 19:45:20 +02:00
Jannis Braun 3a2dfde4a8 feat: add profile_update federation relay event type and payload interface 2026-04-07 13:51:53 +02:00
Jannis Braun 02a44c201d fix: clean up replicatedInstances and registry on federation identity deletion
After deleting a federated identity, the server-side user_federation_registry
and users.replicated_instances were not cleaned up, causing "already connected"
errors when trying to re-federate. The deletion endpoint now authoritatively
removes both the registry row and the replicatedInstances entry, and bumps the
LWW timestamp to prevent stale client syncs from re-inserting them.

Also extends the endpoint to accept mode 'leave' (skip S2S, just clean up),
and enables the "Select instances..." scope option in DeleteIdentityDialog.
2026-04-03 04:56:25 +02:00
Jannis Braun 867caa5f31 feat: add federation identity delete request/response types 2026-04-03 02:28:45 +02:00