Commit Graph
71 Commits
Author SHA1 Message Date
Jannis Braun b6b830568c fix(message-list): close smooth-scroll-to-bottom race against late-loading media
Smooth scrolls toward the bottom (new-message arrival in Effect A and the
Jump-to-Present click) animate scrollTop over many frames. Each intermediate
handleScroll measurement saw a large distanceFromBottom and flipped
isAtBottomRef to false, closing the Effect B/C gates. Lazy media (avatars,
embeds, Spotify thumbs) finishing mid-animation grew scrollHeight while the
gate was closed, so the smooth scroll landed at its originally-computed
target — leaving the user above the new bottom by ~the height of what loaded.

Fix: typed smoothScrollIntentRef ('bottom' | 'message' | null) with an 800ms
deadline. handleScroll suppresses the at-bottom flip while intent is 'bottom'
and the user hasn't wheeled past the 5000px nearBottom threshold. Effect D
fires a final defensive instant pin via native scrollend (Chrome 114+,
Safari 18+) or a setTimeout(800) fallback. 'message' intent (jump-to-message
from search) does NOT suppress — the gate flips honestly so the user is left
at the targeted message.

Verified live on nova.ddns.net Orbit → general: Jump-to-Present
lands flush at bottom; new Spotify-link messages stay at bottom as embeds
arrive via WS. docs/systems/message-list.md updated.
2026-04-27 17:51:55 +02:00
Jannis Braun 37797562d7 docs: fix stale Icon Generation block + close spec drift on tray-icon@2x
Final-pass review found two doc misses:

(1) docs/systems/desktop.md had a SECOND 'Icon Generation' section
under Build System (separate from the Tray Icon section that Task 9
updated) that still described the deleted scripts/gen-icns.sh —
sips/iconutil pipeline using present tense. Replaced with an accurate
description of the new SVG-driven scripts/gen-icons.mjs pipeline,
including the dev-time Electron Resources cp note and links to the
spec and generator README.

(2) The icon-system spec's 'Files Deleted' table only listed
/icon.png and gen-icns.sh; tray-icon@2x.png was deleted in Task 8 but
not documented. Added with the Cocoa-template-only rationale.

Doc-only fix; no code changes.
2026-04-27 14:48:05 +02:00
Jannis Braun 8fae1f42c5 docs(desktop): update tray loader table for new 3-branch logic
Reflects the explicit per-platform branches (macOS template / Win .ico
/ Linux PNG) and links to the icon-system spec and generator README.
2026-04-27 14:42:37 +02:00
Jannis Braun 7c21c616b1 docs(desktop): note Windows startMinimized disk fallback when no entry exists 2026-04-27 13:22:57 +02:00
Jannis Braun 93a91b8235 docs(desktop): clarify recordedExecPath null guard in startup re-apply pseudo-code 2026-04-27 13:03:50 +02:00
Jannis Braun e7413bb504 docs(desktop): rewrite Auto-Launch section to match new architecture
Replaces the stale Auto-Launch section with an accurate description of the
Tasks 3–7 implementation: OS-authoritative read model, platform-specific
applyLoginItemSettings() contract, Linux/AppImage-only conditional re-apply,
defence-in-depth hidden-launch detection, and the pure helpers in autoLaunch.ts.
Also updates Startup Sequence step 9 to reflect that unconditional re-apply is gone.
2026-04-27 13:01:35 +02:00
Jannis Braun 4fa2414933 fix(presence): drop status='online' from registration insert
Mirrors the REST-login change in f17c46c on the registration insert path.
A successful POST /api/auth/register does not by itself imply a live
WebSocket — the client may never connect (transient network, mobile
background, error path between the 201 and /ws), leaving a permanently
stuck-online row that no disconnect timer can clean up. The schema
default 'offline' is correct; ws/handler.ts flips it to 'online' on real
WS auth.

The federated-stub upgrade path in the same handler is unaffected: it
only updates passwordHash/username/homeUserId/displayName/avatarColor,
leaving the stub's pre-existing 'offline' status (set when the stub was
created via replication) untouched.

Updates docs/systems/auth.md step 7 to reflect the new behavior.
2026-04-27 09:35:34 +02:00
Jannis Braun f17c46c77f fix(presence): reset stale users.status on boot; drop REST-login online write
users.status was only flipped back to offline by the WebSocket disconnect
path (5s grace timer in ConnectionManager). Process exits (deploy/crash/OOM)
lose those in-memory timers, freezing any non-offline row at its last value
and making the user appear permanently online to friends and space co-members.
Confirmed in production on the Pi instance: a user appeared online for ~3
days with no live socket.

Add resetStalePresenceOnBoot() in utils/presenceBoot.ts and call it from
index.ts after getDb()/seedDatabase() and before WebSocket route registration.
The reset is federation-safe: it only updates rows where home_instance IS
NULL (replicated stubs are projections of remote presence and must not be
stomped) and is_deleted = 0 (tombstoned users are excluded from broadcasts).

Also remove the redundant status='online' write from POST /api/auth/login.
A successful REST login does not imply a live socket; the WS auth handshake
is the single source of truth. Login alone could otherwise produce the same
stuck-online row when a client logs in and never establishes a WS.

Tests cover: locally-homed online/idle/dnd reset, replicated rows untouched,
tombstoned rows untouched, idempotence, mixed populations.

Updates docs/systems/activity-presence.md (Connect/Disconnect Flow, new Boot
Reset section) and docs/systems/auth.md (login no longer mutates status).
2026-04-27 00:35:36 +02:00
Jannis Braun b698ded47d fix(federation): harden processFriendRequestCreateEvent receiver-side
Two correctness/defense fixes plus regression tests in the existing
in-memory drizzle test file.

1. Reverse-direction idempotency. The sender-side path in social.ts
   checks BOTH directions of friend_requests and returns 409
   incoming_request_exists when an opposite-direction row exists. The
   receiver only matched from->to, so cross-fire (alice@A and bob@B both
   click "add friend" near-simultaneously) produced two opposite
   pending rows on each instance. The receiver now silent-accepts when
   either direction matches a pending row, mirroring the sender's
   both-direction check.

2. Self-target guard (defense-in-depth). Reject events whose
   from-identity equals to-identity (after normalizeOriginForCompare)
   with a new receiver-acknowledged 4xx code self_target_invalid.
   Sender's local cannot_friend_self should catch this, but the
   receiver does not trust upstream validation. Added to
   TERMINAL_REJECTION_REASONS so the standard rollback fires
   (mapped client-side to peer_rejected). Logged at console.warn.

Spec updates: social.md inbound contract now documents both-direction
idempotency and the self-target guard; federation.md and the
s2s-friend-add design spec list the new terminal rejection reason.
2026-04-27 00:07:52 +02:00
Jannis Braun e35b44c05f docs(systems): outbound peering gate documentation across federation, db, api, ws, admin, client-federation
- federation.md: Outbound Peering Gate subsection (gate, intent contract,
  gate-but-don't-queue split, lifecycle invariant on onPeerActivated)
- database.md: peer_approval_requests direction + nullable hmac_secret +
  UNIQUE relaxation + CHECK; new peer_approval_subscribers + peer_approval_
  notifications tables
- api.md: /approval-requests direction-branched approve/deny + extended GET
  response; new peering-subscriptions and peering-notifications endpoints
- websocket.md: peering_subscription_changed, peering_notification_received
  events; federation_approval_request_received fires for outbound too
- admin.md: outbound row rendering with subscriber list
- client-federation.md: 'admin_required' status, peer_pending_local_admin
  error code, new Connections surfaces, federationStore slice

Spec §11 closes the Approve-button investigation finding.
2026-04-26 22:59:27 +02:00
Jannis Braun 42355ee889 feat(federation): direction-branched approve and deny for outbound queue
- /approve on outbound: generates HMAC, sends /peer/accept to remote.
  200 -> activate peer + onPeerActivated cleanup. 202 -> awaiting_approval,
  capture token, queue row + subscribers REMAIN. 4xx/5xx/network -> clean
  up peer row, leave queue for admin retry.
- /deny on outbound: fans out kind='denied' notifications, cascade-deletes
  parent + subscribers, broadcasts admin event. No remote network call.
- /approve and /deny on inbound: existing behavior preserved verbatim.
- GET /approval-requests: response includes direction; outbound rows
  carry subscribers[] (joined with users.username, possibly empty).
- Removes Task 1's temporary /deny scaffolding guard now that the
  direction-branched dispatcher handles outbound rows correctly.
- Updates docs/systems/federation.md to describe direction-branched flow.
2026-04-26 22:01:14 +02:00
Jannis Braun 4d4dc383d7 feat(federation): pass explicit intent at every ensurePeered call site
- social.ts friend-add: user_action, with 409 peer_pending_local_admin
  when gate fires
- /peer/ensure: user_action, surfaces peeringStatus: 'admin_required'
- sendCallRelay (typing warm-up + call relay): system intent
- federationWorker resolvePendingPeers: system intent (defensive — gate
  is unreachable from here since pending rows already exist)
- CallRelayFailureReason: peer_admin_required added (mapped to
  peer_transient_failure on the user-facing event surface, since system
  intent should never legitimately surface admin_required)
- Test files: thread intent arg through racePeering and ensurePeered
  calls (positional shift from racePeering signature change)
- outboundGate.test.ts: tighten noUncheckedIndexedAccess access via
  non-null assertions after toHaveLength()
- docs/systems/social.md: peer_pending_local_admin error code documented
2026-04-26 21:37:19 +02:00
Jannis Braun 94294c64b9 docs(systems): document approval token mechanism
federation.md: new 'Approval Token Verification' subsection covering
issuance (queue path generates token, returns in 202), storage on
initiator (federation_peers.approval_token), forwarding from /approve,
verification on receiver's awaiting_approval branch, single-use lifecycle,
backward compatibility, and threat-model boundary (sender-side outbound
gating tracked separately).

database.md: approval_token column documented on both federation_peers
and peer_approval_requests with cross-references to federation.md.

api.md: /peer/accept request + 202 response now show optional
approvalToken field with pointer to the federation spec.
2026-04-26 11:53:54 +02:00
Jannis Braun 11c5a2bf06 fix(federation): refuse outbound handshake when inbound approval pending
Closes the auto-reconnect trust-bypass: any code path calling
ensurePeered(remote) on an instance with autoAcceptPeering=0 could
previously bypass the admin gate by initiating a fresh handshake to the
remote, which the remote then accepted against its existing
awaiting_approval row.

The trigger surfaced was stores/instanceStore.ts:1010 — the silent
.catch(() => {}) auto-reconnect that fires for any user with the
remote in their replicatedInstances (commonly: any admin). Anyone with
that profile reloading their session activated peering on both sides
without any admin approval action.

Surgical fix: ensurePeered now returns rejected when an unresolved
inbound peer_approval_requests row exists for the target origin. The
legitimate admin-approve flow (routes/federation.ts:1089) does not call
ensurePeered; it deletes the approval-request and does its own direct
fetch to /peer/accept, so this check does not block legitimate approvals.

The receiver-side trust assumption at routes/federation.ts:619-645
(awaiting_approval branch in /peer/accept) still has the same flaw
— an adversarial peer that knows the timing could re-handshake at the
right moment to flip the receiver to active. That deeper trust-model
rework is plan-grade work tracked at internal notes
2026-04-26-peer-handshake-trust-model.md.
2026-04-26 00:12:42 +02:00
Jannis Braun e15661e1bc docs(systems): S2S friend-add — social/client-federation/federation/api
Reflects what shipped on feat/s2s-friend-add (T1-T22 verified live):
- social.md: rewrite §6 outbound friend_request_create flow (sender's
  home is now the queueing instance for native users); §8 sendFriendRequest
  collapsed to single home-API call; §12 drops ConnectInstanceModal
  trigger; new "Failure Handling" subsection covers rollback path;
  relayMessageId schema note added.
- client-federation.md: split paragraph clarifying friend/DM = S2S,
  spaces = client-federated; §1 clarifies federated accounts are now
  spaces-only; new API-client error contract subsection (err.message
  carries the code, not err.body — caught + fixed in fe969a7).
- federation.md: endpoints table + S2S User Lookup subsection;
  TERMINAL_REJECTION_REASONS + permanent-failure callback registry;
  ghost-row note (rollback errors are best-effort).
- api.md: POST /api/social/requests new error-code table; new
  federation lookup route entry.
2026-04-25 23:33:13 +02:00
Jannis Braun 39a542a1ec docs(systems): update social.md for search filter + Direct-Add changes
Sections 3 and 12 now describe the discover-equivalent filter set
applied to /api/social/search and the widened Direct-Add row
contract (always-visible for well-formed input, resolved-form
display, server-side username normalization).
2026-04-25 19:35:37 +02:00
Jannis Braun 9aa40c0304 docs: refresh stale embed renderer descriptions after Task 1
Final-review reviewer flagged two minor staleness items:
- embeds.md §10 ImageEmbed bullets still described the pre-Task-1
  shape (no wrapper, no aspect-ratio). Replaced with the actual
  current shape, with an explicit pointer to the Dimension
  reservation contract section that explains why the dims-null
  branch deliberately has no fallback.
- message-list.md said VideoEmbed uses "padding-bottom" without
  noting the direct-video branch uses aspectRatio. Now describes
  both branches explicitly.

No code changes; both are documentation-only touch-ups.
2026-04-25 13:15:14 +02:00
Jannis Braun 56830972f4 docs(embeds): document bidirectional dimension reservation contract 2026-04-25 13:12:10 +02:00
Jannis Braun d659637930 docs(message-list): note smooth-scroll exclusion; point sentinel comment at subsystem doc
Reviewer caught two small gaps after Task 3:
- Effect A's smooth-scroll path on new messages is intentionally NOT
  instrumented with the sentinel (the animation lands asynchronously
  across frames; no intermediate scrollTop is worth pinning to). The
  doc now records this so the reader's intuition matches the code.
- The sentinel-branch comment in MessageList.tsx pointed at "spec §2",
  which is the planning doc rather than the durable subsystem spec.
  Pointed at docs/systems/message-list.md instead.
2026-04-25 13:11:05 +02:00
Jannis Braun 4b040114ed docs: add docs/systems/message-list.md subsystem spec 2026-04-25 13:06:44 +02:00
Jannis Braun 4aced5654b docs(federation): document bidirectional instanceName exchange during peer handshake 2026-04-25 01:01:04 +02:00
Jannis Braun 798aa22690 docs(systems): rewrite database.md migration workflow section after migration squash
Line 4 pointed at a stale `runMigrations()` name and omitted the drizzle-kit
generate step entirely. Replace with a description of the real workflow:
`pnpm db:generate` produces SQL from `schema.ts`, `initDatabase()` runs
`drizzle.migrate()` + `ensureDefaults()` on startup. Note the 2026-04-24
baseline squash for future readers.

Delete the "Migration flags (internal)" line — those flags belonged to
pre-drizzle data-fix migrations deleted wholesale in 3acaea2 (2026-04-09)
and are historical trivia with no present referent.

Refs backlog #31 Phase 2.
2026-04-24 23:38:47 +02:00
Jannis Braun 34622a4290 docs(systems): #18 review followups — bullet formatting + host_unreachable phase
dm-system.md: fold the voice.md cross-reference into the paragraph so it
renders as part of the Cross-instance access explanation instead of an
orphaned bullet.

websocket.md: add 'host_unreachable' to the dm_call_undeliverable phase
union — stale since #32 was merged (docs drift noted in Task 8 review).
2026-04-24 21:22:21 +02:00
Jannis Braun 6edf02cb33 docs(systems): document three-way ack classification + no_recipient (#18)
federation.md — new undeliverable bucket subsection with three-way
classification table, Path A/B semantics, and wire backward-compat note.
voice.md — no_recipient row in failure-surface table.
websocket.md — DmCallUndeliverableReason union updated to include no_recipient.
dm-system.md — cross-reference to voice.md for no_recipient reason.
2026-04-24 21:19:34 +02:00
Jannis Braun 7c0dd33123 docs(systems): document host_unreachable phase + onPeerDeactivated + sentinel 2026-04-24 00:54:46 +02:00
Jannis Braun 1719e6d580 docs(systems): document federation call state machine hardening 2026-04-23 23:23:06 +02:00
Jannis Braun 4d2e50b55b fix(web): extract cross-store resolvers into neutral utility to break TDZ
instanceStore registers three resolver functions at module load —
setApiForOriginResolver, setUserIdForOriginResolver,
setOriginFromHostnameResolver — whose backing `let` bindings used to
live in spaceStore. When the module graph was entered from
instanceStore (e.g. JoinSpaceModal importing useInstanceStore) the
order became spaceStore → chatStore → useWebSocket → socialStore →
instanceStore (top-level setter call) while spaceStore was still
paused on its line-8 chatStore import, so the backing `let` had not
been reached yet and the setter crashed with
`Cannot access '_getApiForOrigin' before initialization`. This left
InviteModal.test.tsx and JoinSpace.test.tsx unable to even load their
suites once AudioManager was mocked away.

Move the three `let` bindings, their setters, their pure getters, plus
the WS-populated user-ID cache (`_myUserIdByOrigin`, setMyUserIdForOrigin,
getCachedUserIdForOrigin, clearMyUserIdCache) into
`packages/web/src/utils/crossStoreResolvers.ts`. The utility imports
nothing from `./stores/*`, so no back-edge exists. spaceStore re-exports
the public surface for backward compatibility with the many existing
import sites; instanceStore imports the setters directly from the
utility (the in-cycle re-export path does not resolve at module-init
time under vite-ssr, so a direct import is required for the top-level
setter calls).

spaceStore's remaining wrappers (resolveUserOrigin, getLayoutHomeOrigin,
getMyUserIdForOrigin) stay where they are — they combine the utility's
pure lookups with authStore state — but now delegate to the utility.

Also adds the AudioManager mock to InviteModal.test.tsx and
JoinSpace.test.tsx so their suites actually load (same pattern already
used in 5 other test files). Net test-suite result: 127/131 pass (up
from 121/121 — +6 newly unlockable). The 4 remaining JoinSpace
failures are pre-existing stale UI-text assertions (the placeholder was
expanded and the submit button was made disable-when-empty) made
visible by the suite now loading; they're orthogonal to this change
and handed back for a separate triage.

Closes backlog #27.
2026-04-23 02:46:19 +02:00
Jannis Braun de0b6c2a42 docs(federation): document DM origin failover mechanism
New subsection under client-federation.md's origin-aware routing section
covering dmAlternatives, failoverDmOriginsFromDisconnected, rekey flow,
trigger points, the intentional cache-flush trade-off, voice-out-of-scope,
no-re-home policy, and the WS routing contract. dm-system.md gets a
one-line cross-reference from the Client routing bullet.
2026-04-23 01:31:22 +02:00
Jannis Braun 6ff983b46c fix(federation): treat duplicate rejection as terminal in outbox worker
Duplicate rejection means the peer already has the message (e.g.,
delivered earlier via outbox AND pulled via sync in the same
window). Retrying will fail identically forever until TTL expires.

Before this patch: duplicate-rejected outbox entries were retained
with attempts++ and exponential backoff, creating log noise and
outbox bloat for up to 30 days.

After: duplicate-rejected entityIds join the terminal set alongside
accepted ones and are deleted from the outbox. Logged at info level
('outbox entry removed (terminal)') to distinguish from warn-level
transient-rejection retries.

Other rejection reasons (attribution_mismatch, processing_error,
etc.) stay on the retry path; some may also be terminal but are
deferred until observed accumulating.
2026-04-23 00:10:34 +02:00
Jannis Braun 15e42a7cc1 fix(federation): per-event fault isolation in syncPeerMutationLog (#25)
Replace the batch-level processRelayEvents call with a per-event
loop wrapped in try/catch. On exception: log event type, messageId,
timestamp, peer origin, and the error message; continue to the next
event.

Previously, a single poison-pill event (e.g., UNIQUE conflict from
a malformed relay payload) would throw, be caught by the outer
try/catch, and block lastSyncedAt from advancing — causing every
future activation to retry the same broken window indefinitely.

The final 'replayed N events' log line now reports '(K skipped due
to errors)' when K > 0, surfacing the count to operators. Individual
event failures are logged via console.error with enough context to
debug or replay manually.

Trade-off documented in docs/systems/federation.md: forward progress
of the sync pipeline takes priority over strict at-least-once
delivery. An event that fails to process is lost to the receiver
unless replayed manually.
2026-04-22 01:45:14 +02:00
Jannis Braun d5d56db254 docs(federation): document peer-activation recovery
Adds 'Peer Activation Recovery' subsection with call-site roster,
peer-state x outbox-enqueue x recovery matrix, mutation log
coverage table, /api/federation/sync contextType filter values,
and a Known Issues note about the poison-pill edge case.

Also updates stale references to runInitialSyncForNewPeers (removed
in commit 02a1ed7) to point at the unified startupBootstrapSync
path.
2026-04-22 00:57:57 +02:00
Jannis Braun 531104fecc fix(federation): handle 202 in admin /peer/initiate handshake
/peer/initiate checked `response.ok` to decide whether to activate the
local peer. `response.ok` is true for the full 2xx range, so a remote
that returned 202 (queued for admin approval — autoAcceptPeering off
on their side) caused the local peer to flip to `active` while the
remote had us `awaiting_approval`. The split only self-healed when
the remote admin approved and pushed us an `awaiting_approval → active`
override via the peer_approval_requests inbound path.

The auto-peer flow in federationPeering.ts:performHandshake already
had the correct 202 branch: set local status to awaiting_approval,
broadcast federation_peers_changed, surface a pending outcome. Mirror
it here:

- Check response.status === 202 BEFORE the !response.ok branch so the
  fall-through can't reach the activation code.
- Transition local peer to awaiting_approval (not active).
- Broadcast federation_peers_changed so other admin tabs refresh.
- Return 202 with the sanitized peer so the client observes the
  queued state distinctly from both success and failure.

Also added the missing federation_peers_changed broadcast on the
activation (200) path for parity with every other peer-state-change
site in the codebase — it was a pre-existing drift that would leave
sibling admin tabs stale after an initiate. Pattern-aligned with
federationPeering.ts:160 and the rest of routes/federation.ts.

Docs: expanded Phase 1 bullets in docs/systems/federation.md to cover
the 200 / 202 / other non-2xx / network-error branches explicitly and
reference the mirrored auto-peer branch.

Verified: pnpm -r typecheck clean (shared + server), vitest 70/70
pass.

Closes #21 from S2S DM unification backlog.
2026-04-21 22:41:44 +02:00
Jannis Braun 010aa7f7ca fix(schema): normalize federation_peers.consecutive_failures to NOT NULL
The column was `integer DEFAULT 0` (nullable) since the initial schema.
Counters should not be nullable — the semantics are a count, not an
optional measurement. `consecutive_auth_failures` (added later) was
correctly declared NOT NULL; tightening `consecutive_failures` to match
removes the drift and eliminates the "|null" burden everywhere the value
is read.

SQLite does not support in-place ALTER … SET NOT NULL, so drizzle-kit
cannot auto-generate this. The manual migration uses the standard
SQLite recreate pattern (new table + INSERT SELECT + DROP + RENAME +
recreate index) under `PRAGMA defer_foreign_keys = ON` so the existing
federation_outbox → federation_peers FK survives the swap. The COPY
step coalesces any hypothetical NULL to 0 defensively; live probes on
both test instances (nova, orbit) showed zero NULL rows so no
actual backfill is required.

Verified by applying the full migration chain against a copy of the VM's
live DB: column ends as `notnull=1 dflt=0`, the peer row is preserved,
the unique index on origin is recreated, NULL inserts are rejected, and
`PRAGMA foreign_key_check` reports no violations.

Server `SanitizedPeer.consecutiveFailures` tightened to `number` to
match the new drizzle inference and the shared `FederationPeer` shape.

Follow-up #22 from S2S DM unification backlog.
2026-04-21 22:21:06 +02:00
Jannis Braun 0d74d1d112 perf(federation-worker): tighten health-check cadence to 15 min
HEALTH_CHECK_INTERVAL_MS was 1 h, but ROTATION_GRACE_PERIOD_MS is 15 min.
Phase skew between two peers' health-check ticks could stretch rotation
finalization desync up to ~1 h, during which signatures from the already-
finalized side verify against the other side's primary-only secret (grace
has expired; verifyPeerSignature stops trying the pending secret). With
AUTH_FAILURE_THRESHOLD = 5 and the existing backoff schedule, this
occasionally tripped legitimate rotations into needs_attention.

Setting the interval to 15 min (= ROTATION_GRACE_PERIOD_MS) guarantees a
finalization tick fires within one grace window on each side, so the
cross-verification window where one peer signs with NEW while the other
still treats NEW as pending cannot outlast the grace period.

Per-tick cost is negligible for the worker's steady state: the only
network fetches are per-active-peer /peer/rotate calls when the 90-day
rotation interval hits (rare) and per-unreachable-peer /instance/info
health pings (bounded by outage count). Going lower than 15 min would
reduce the residual desync but increase tick overhead with diminishing
returns; 15 min is the grace-period-aligned value that the original spec
("runs hourly") deviated from without justification.

Follow-up #20 from S2S DM unification backlog; reduces #19 false-positive
rate (outbox auth-failure transition) on legitimate rotations.
2026-04-21 22:16:09 +02:00
Jannis Braun e236d0730e docs(systems): document needs_attention state and Reset peering action 2026-04-21 21:11:17 +02:00
Jannis Braun 9cdc5921d9 docs: clarify that livekit_unavailable is emitted from sendFederatedCallStart, not sendCallRelay 2026-04-21 14:08:31 +02:00
Jannis Braun 740dae298d docs: document call-relay auto-peering and dm_call_undeliverable surface 2026-04-21 14:05:59 +02:00
Jannis Braun 852e3657f9 fix: dedup federation membership events by (sourceInstance, messageId)
processMemberAddEvent, processMemberRemoveEvent, and processOwnershipTransferEvent inserted system messages unconditionally. Outbox retries and initial-sync replays (triggered whenever an admin re-approves a peering request, which recreates the peer row with lastSyncedAt=0) duplicated the system message on every delivery. Each new snowflake ID exceeded the user's last_read_message_id, flipping the channel back to unread after every deploy.

Processors now short-circuit on a matching (source_instance, source_message_id) row, and persist those fields when inserting. processMemberAddEvent emits the tagged system message in both bootstrap and incremental paths so bootstrap replays don't fall through and insert a second one; the bootstrap's dm_channel_created broadcast carries that message as lastMessage so sidebar previews and unread anchors agree across instances.
2026-04-21 01:06:43 +02:00
Jannis Braun aeebf79feb fix: federated friend request routed to wrong user with same name
When two instances each have a native user with the same username, the
Add Friend search card for the federated one sent its request to the
local namesake instead of the intended remote user.

Root cause: `isNative = !homeUserId` in socialStore's searchUsers and
loadFriends dedup. The server backfills native users' homeUserId to
their own id so federation tier-1 lookups succeed, so `homeUserId` is
set on natives too. Only `homeInstance` distinguishes native (null)
from replicated stubs. With the wrong check, no entry was ever "native"
and the home-origin stub of the remote user was kept over the true
native record — leaving `_instanceOrigin=''`, which caused the Send
button handler to drop the domain suffix and POST to the home API,
where "nova" resolved to a completely different local user.

Also fixes loadRequests dedup to prefer the target-native record so the
search card correctly flips to "Request Pending" after sending.
2026-04-21 00:36:15 +02:00
Jannis Braun ea8f786c2b docs: document pending peering approval queue, new endpoints, and awaiting_approval status 2026-04-20 15:16:07 +02:00
Jannis Braun 7366b56fdf docs: document auto-peering, rejected status, autoAcceptPeering setting 2026-04-09 14:01:11 +02:00
Jannis Braun ce0b2d0e15 feat: allow any group DM member to add friends, not just owner
Remove the owner-only gate on POST /api/dm/:id/members. The S2S relay
already accepts member_add from any HMAC-verified peer, and the UI
already shows the add button to all group DM members. Only the
server-side check was blocking non-owners.
2026-04-09 02:15:44 +02:00
Jannis Braun d58464fe95 docs: document profile image file replication in federation spec 2026-04-08 16:59:55 +02:00
Jannis Braun adc2a1cb01 docs: update voice.md with client-side federated call implementation details 2026-04-08 15:49:20 +02:00
Jannis Braun f40ea03cfb docs: update system specs for federated DM calls v2 2026-04-08 03:28:54 +02:00
Jannis Braun e3742e199a docs: fix endpoint references and messageId format in relay docs 2026-04-07 22:46:49 +02:00
Jannis Braun 28624343d0 docs: document S2S DM close/reopen relay events
Add federation.md section 8b covering dm_close/dm_reopen relay events:
payload, outbound queueing via queueDmCloseRelay, inbound processDmCloseEvent/
processDmReopenEvent handlers (lookup-only identity resolution, silent
no-ops on missing channel/user/membership), and the processCreateEvent
closed-state reopen bug fix.

Update dm-system.md soft-close section with federation behaviour: relay
to peers for both close and reopen, relayed-message reopen in
processCreateEvent, and the federatedId-only guard for legacy DMs.
2026-04-07 22:43:04 +02:00
Jannis Braun 1ada46baff fix: relay mark_unread to peers, fix docs and timestamp consistency
- Add queueReadStateRelay call in handleMarkUnread (skip '0' sentinel)
- Fix double Date.now() in queueReadStateRelay (use single const)
- Fix federation.md: read state relay uses outbox (not fire-and-forget),
  correct payload schema to match implementation
2026-04-07 20:08:31 +02:00
Jannis Braun 626a8ded50 docs: update subsystem specs for cross-instance DM access
Document read_state_update relay event, lifted DM gates,
federatedId dedup, and relaxed authority check.
2026-04-07 20:01:43 +02:00
Jannis Braun a023c85cad docs: document S2S profile sync relay and write-protection guard 2026-04-07 14:01:44 +02:00