Commit Graph
643 Commits
Author SHA1 Message Date
Jannis Braun 5ffc7c565e feat(federation): sync endpoint scopes friend events to the requesting peer, pagination-safe (dead-incarnation spec §3.2) 2026-07-03 00:50:31 +02:00
Jannis Braun bd40058613 feat(federation): sync endpoint scopes DM channels to the requesting peer (dead-incarnation spec §3.2) 2026-07-03 00:42:02 +02:00
Jannis Braun 99c8361b75 feat(federation): receiver guard — never create replicated stubs homed at our own domain (dead-incarnation spec §3.1) 2026-07-03 00:37:27 +02:00
Jannis Braun 54ab660204 feat(federation): near-instant reset detection — probe epoch at the auth-failure transition + on worker startup
A reset peer reaches needs_attention via the auth-failure path (HMAC desynced by
the new incarnation) without passing through unreachable, so the 5s recovery
probe never saw it — detection waited up to a full 15-min health-check cycle
before 'Re-peer & heal' surfaced. Extract detectResetForPeer() and fire it
event-driven at the transition, plus a startup sweep for already-stuck peers.
15-min tick remains the backstop.
2026-07-02 20:10:35 +02:00
Jannis Braun 13d050c1bb fix(federation): close detached-account gaps from final review — presence/hydrate guards, ack re-detect clear, self-delete password (detach spec §4.3/§4.4/§4.6) 2026-07-02 19:34:32 +02:00
Jannis Braun 172398171a docs(systems): finish detach consolidation — federation.md §6.3b, database.md, stale comments (detach spec §8) 2026-07-02 19:15:24 +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 ea66ec5dbd test(federation): pin registration 409 + suffixed stub creation against detached accounts (detach spec §4.3.5)
Also add the positive companion assertion (folded in from a Task 3 review
Minor): findFederatedUser tier-2 STILL returns a NON-detached (orphaned=0)
same-name federated row, locking that the eq(federation_home_orphaned, 0)
clause discriminates on the flag alone and never over-filters legitimate
replicated identities.

All three behaviors pass against the shipped Task 1-3 code; no product-code
change was required.
2026-07-02 18:47:29 +02:00
Jannis Braun 68be2e26b1 feat(federation): S2S surfaces exclude detached accounts — tier-2, profile_update, identity delete (detach spec §4.3) 2026-07-02 18:39:54 +02:00
Jannis Braun d37919b5a0 feat(federation): reset heal detaches real accounts instead of freeze+rename (detach spec §4.2) 2026-07-02 18:33:47 +02:00
Jannis Braun 79c1138813 feat(federation): detached accounts keep local-password login; self-heal permanently disabled (detach spec §4.1) 2026-07-02 18:28:55 +02:00
Jannis Braun 1629f8fbe1 fix(dm): enforce read-only guard on DM reactions (WS) + harden purge/ownership tests
Finding 1: handleReactionAdd/Remove now drop reactions on a dead 1-on-1
(isDeadOneOnOne) — previously a survivor could react on a Deleted-User
thread and the relay fanned out to all peers via undefined target-origins.
Client Message.tsx withdraws add/toggle reaction affordances for dead DMs
(existing reactions still display read-only).

Finding 2: dmMembership purge test now runs with foreign_keys=ON (matches
prod) and asserts dm_members/dm_messages cascade cleanup on channel purge.

Finding 3: tombstone group-DM ownership transfer filters isDeleted=0 so
ownership can never move to a tombstoned member; covered by a new test.
2026-07-02 16:45:44 +02:00
Jannis Braun a380927e72 test(dm): assert JOIN users guard drops orphan author in DM membership backfill (S5 review) 2026-07-02 16:00:19 +02:00
Jannis Braun 3470bb1914 fix(dm): idempotent backfill restores pre-existing broken Deleted-User 1-on-1 threads (S5) 2026-07-02 15:54:46 +02:00
Jannis Braun d03f8e4f77 fix(federation): heal path broadcasts user_updated so tombstoned-stub DMs update live (S4) 2026-07-02 15:50:08 +02:00
Jannis Braun 3ebdd048bd feat(dm): read-only guard blocks message create/edit/delete in a Deleted-User 1-on-1 (S3) 2026-07-02 15:47:18 +02:00
Jannis Braun 12d8256f65 fix(dm): purge DMs with zero live members (exclude-uid, scoped) so dead-both threads clean up (S2) 2026-07-02 15:37:33 +02:00
Jannis Braun e5a1cc9506 fix(dm): keep 1-on-1 dm_members on tombstone, drop only group membership (S1) 2026-07-02 15:32:04 +02:00
Jannis Braun 6926bdb8f8 fix(federation): /peer/initiate handles needs_attention/awaiting_approval/rejected rows instead of 500ing 2026-07-02 13:29:46 +02:00
Jannis Braun cd28c0336c refactor(federation): store verified epoch as peer baseline; drop redundant assertion 2026-07-02 13:07:35 +02:00
Jannis Braun c6f0e6f25d fix(federation): initiator handles 409 + verifies handshake before activating (BUG-1b/BUG-2) 2026-07-02 12:56:26 +02:00
Jannis Braun edcb4cb72a fix(federation): /peer/accept returns honest 409 instead of false 200 for existing peer (BUG-1a) 2026-07-02 12:40:16 +02:00
Jannis Braun 442811e600 test(federation): reproduce handshake desync BUG-1/BUG-2 over real handshake (RED) (BUG-0)
#1 control passes (harness validated); #2 fails (200 vs expected 409 — false success);
#4 fails (s2sHealthy false — Re-peer reports success on dead peering). Turned green by the fixes.
2026-07-02 12:32:09 +02:00
Jannis Braun 085670cff3 fix(federation): handshake sourceOrigin honors PUBLIC_ORIGIN (align with S2S auth origin) 2026-07-02 12:31:02 +02:00
Jannis Braun aa7bf5b532 test(federation): real-handshake two-instance harness helpers (BUG-0) 2026-07-02 11:51:54 +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 fd0ff4d199 fix(federation): clear federation_home_orphaned on tombstone (BUG-5) 2026-07-02 11:42:48 +02:00
Jannis Braun 6de14b281b fix(federation): friend-add returns graceful 503 instead of 500 on peer lookup failure (BUG-3)
lookupRemoteUser now maps peer HTTP failures (403/5xx, malformed body) to a
structured {ok:false,reason:'unreachable'} instead of throwing, and the
federated friend-add wraps the call in try/catch as defense-in-depth. A
desynced/unreachable peer no longer surfaces as a raw 500 on a user action.
README.md left unstaged.
2026-07-02 11:23:08 +02:00
Jannis Braun 43d1dad1d7 fix(federation): carry error body on HttpError so Reset-cleanup owns-spaces copy reaches the UI
Also narrow SanitizedPeer.needsAttentionReason to the shared union.
2026-07-02 02:19:13 +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 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 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