Commit Graph
100 Commits
Author SHA1 Message Date
Jannis Braun a189cbfba3 fix(chat): dedup parallel loadMessages/loadMoreMessages per channel
Live network trace via playwright showed every channel mount firing TWO
parallel GET /channels/:id/messages requests (AppLayout + MessageList
both call loadMessages on mount; neither can be removed because
MessageList is also rendered by VoiceChatPanel which doesn't call it),
and every fast-scroll burst firing N parallel loadMoreMessages calls
(handleScroll closure shares !isLoadingMore=true across the burst).

The hasMore-based guard only deduplicates calls AFTER the first
completes — it does not collapse parallel callers. On a NAT hairpin'd
LAN where some federated TCP connections hang, having N hung parallel
fetches keeps the pagination skeleton stuck while any one is still
waiting on the 30 s api-client timeout.

Fix: in-flight Promise dedup at the chatStore level. Module-level Maps
keyed on channelId; concurrent callers receive the same Promise.
Cleared in finally with self-check so a force-reload mid-flight isn't
dropped. force=true bypasses dedup (WS reconnect intent).
2026-05-05 20:47:03 +02:00
Jannis Braun d793ab8f78 fix(message-list): sticky pagination skeleton on channel switch
Two cooperating bugs let the pagination skeleton stick at the top of the
chat across channel switches: (1) the browser's post-clamp scroll event
on channel switch fired the load-more block on the new channel, and
(2) useDelayedLoading's threshold timer refreshed displayStart on every
fire, extending the minDisplay window unboundedly when isLoading cycled.

- useDelayedLoading: stamp displayStart only on the false→true show
  transition (functional setShow form to avoid stale closures).
- MessageList: suppressNextLoadMoreRef armed in Effect 3, consumed by
  the load-more block on the next scroll event, with a 250 ms fallback
  disarm so legitimate user scrolls aren't silently dropped. Suppression
  scoped to the load-more block only.
- Regression test for the displayStart refresh bug.
- Updated docs/systems/message-list.md history.
2026-05-05 19:56:26 +02:00
Jannis Braun 97a8d982d5 Merge feat/s2s-federated-friends-presence-username
S2S federated-friends username + presence relay. Closes the original repro
(youruser on nova saw pbtest3/pbtest4 from orbit as snowflake-ID with stale
offline status when not client-federated to orbit).

Phase A — username:
- Stub creation uses realname@domain when wire snapshot carries a username hint
- profile_update payload extended with username, receiver applies displayName
  ?? username fallback so null-displayName home users don't clobber stubs
- New /api/federation/users/by-home-id reverse-lookup endpoint + helper
- Stub backfill worker heals legacy snowflake-named stubs (per-peer, on
  activation + startup pass)

Phase B — presence:
- New S2S presence_update relay event + sender helper + 5 call sites
- Receiver updates stub status + broadcasts WS to friends/DM/spaces
- Local broadcast scope widened to friends + DM + spaces (matches user_updated)
- Peer-activation snapshot (relationship-scoped) + deactivation mark-stubs-offline
- Outbox-only delivery (no mutation log) — presence is ephemeral

Follow-on fixes after live verification:
- Outbox-to-relay event rebuild was missing presenceUpdate (allowlist gap)
- Profile snapshot now carries status — freshly-friended online remotes show
  online immediately, not stuck at offline until next transition
- spaceStore.updateMemberPresence patches userViews so the avatar green dot
  re-renders on WS presence_update (was reading from a different cache than
  the 'Online' text label)

Docs: federation.md, activity-presence.md, social.md, websocket.md updated.
Verified live on Pi + VM (nova + orbit).
2026-05-05 16:48:19 +02:00
Jannis Braun 1effb1c53f fix: live presence on freshly-friended remotes + green dot in same session
Two follow-on bugs from the initial S2S presence rollout:

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

(2) 'Online' text updates but green avatar dot stays grey on the same page:
    spaceStore.updateMemberPresence patches members[] (which feeds space UIs)
    but never patches userViews — the cache useCanonicalUserView reads from.
    The Avatar in FriendItem reads canonical.status; the text reads
    friend.status (socialStore). Two sources, one stale until full
    user_updated arrives. Fix: updateMemberPresence now mirrors status into
    matching userViews entries, so canonical-view consumers re-render with
    fresh status the moment the WS event lands.
2026-05-05 16:44:22 +02:00
Jannis Braun d2bd7987c1 fix(federation): include presenceUpdate in outbox-to-relay event rebuild
The outbox worker rebuilds FederationRelayEvent objects from stored JSON via
an allowlist of known fields. presenceUpdate was missed when presence_update
events shipped, so peers received events with eventType='presence_update' but
no payload — rejected with missing_presence_update_payload on every tick.
2026-05-05 16:28:07 +02:00
Jannis Braun ba0f8637f5 docs(federation): document username on profile_update + new presence_update relay + stub backfill
- federation.md §10: extend FederationProfileUpdatePayload with username, document
  receiver fallback. Add Presence Sync sub-section: event shape, sender call sites,
  outbox-only (no mutation log) policy, peer-lifecycle hooks, flap recovery
  semantics. Add Stub Username Backfill sub-section + new
  /api/federation/users/by-home-id endpoint.

- activity-presence.md: resolve drift — line 147 previously claimed S2S
  presence_update relays existed but the code didn't ship them. Now points to
  federation.md §10 which describes the actually-implemented mechanism. Connect/
  Disconnect Flow updated to reflect collectProfileBroadcastTargetIds recipient
  set + S2S queueing.

- social.md / websocket.md: presence_update recipient column now reflects
  friends + DM + space co-members (matches user_updated), plus federated stub
  presence sourced via S2S.
2026-05-05 16:19:03 +02:00
Jannis Braun 1cb151d3a9 feat(federation): peer-lifecycle presence hooks (snapshot on activate, mark-offline on deactivate)
onPeerActivated re-emits a presence_update for every relationship-related
online native to the activating peer (relationship = friend with peer-stub /
DM-mate with peer-stub / replicatedInstances opt-in for peer origin). Scope
bounded by relationship count, not native count — flap recovery cost stays
proportional to actual interaction surface.

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

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

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

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

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

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

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

Adds lookupRemoteUserByHomeId to federationLookup.ts as the client-side
helper. Used by the upcoming stub-backfill worker on peers that hold legacy
snowflake-named replicas of users now visible by their real handle.
2026-05-05 15:54:06 +02:00
Jannis Braun 097eb9a2ef feat(federation): preserve effective displayName across profile_update relay
FederationProfileUpdatePayload gains `username`: the home user's canonical
handle. Receiver applies displayName ?? username so stubs whose home user has
no displayName show the real handle instead of getting clobbered to null.
Mirrors the existing fallback in hydrateReplicatedUserProfile. Username itself
is immutable on the home instance, so the receiver does not rewrite the stub's
username column on profile_update.
2026-05-05 15:48:56 +02:00
Jannis Braun 1d353c994b fix(federation): create replicated-user stubs with realname@domain when hint provided
When friend_request_create / friend_add / DM relay carries a profile snapshot,
the canonical-username hint is now used as the stub's local-part. Stubs created
purely from S2S (no client-federation) now display the human-readable handle,
not the homeUserId snowflake. Falls back to the snowflake-id scheme only when
no hint is available.
2026-05-05 15:46:26 +02:00
Jannis Braun 702d539e23 fix(register): read home JWT from localStorage so step-2 avatar uploads
RegisterPage step 2 writes the new token to localStorage but defers
initSession (and thus the authStore.token write) until after the avatar
upload, to keep AuthRedirect from yanking the user off /register
mid-upload. The home-origin branch of setTokenForOriginResolver was
reading authStore.token, so getTokenForOrigin('') returned null during
that window — transferStore.startUpload threw "not authenticated" and
the surrounding catch-and-ignore silently dropped the avatar. Align the
resolver with the home api client and read localStorage so both paths
share one source of truth for the home JWT.
2026-05-05 12:53:48 +02:00
Jannis Braun 49e9047005 feat(client-federation): user-view cache for cross-instance DM render
Fixes a render bug where a federated user (e.g. axel@nova) appeared with
the federation globe icon and a broken avatar when viewed on his own home
instance. Root cause: `populateFromReady` is first-wins by federatedId and
discards the entire skipped DM payload — including its `members` array —
so when a sibling instance's ready arrived first, the home instance's view
of every shared user was dropped on the floor.

Adds a render-only `userViews` cache that mirrors the `dmAlternatives`
philosophy: information from skipped ready payloads is preserved for
rendering. Every wire surface that delivers a User upserts into the cache
regardless of dedup outcome; render sites read through a Zustand selector
hook to surface the home view when one is loaded. The DM channel ingestion
race is left untouched — the existing no-flapping invariant on origin
reconnect is intentional and load-bearing for failover.

Layered changes:

- `identity.ts`: `normalizeOriginToHost`, `canonicalUserKey`,
  `isDeliveryFromHome`, `isFederationGlobeApplicable` — single helpers
  for origin/host normalization and the home/stub tier decision.
- `spaceStore.ts`: `userViews` Map, `UserViewEntry` type, `upsertUserView`
  action with the home-wins preference rule, prune by `deliveredBy` in
  `removeInstanceSpaces` (mirrors `dmAlternatives` cleanup), `reset`
  clears.
- `userViewLookup.ts`: `useCanonicalUserView` (Zustand selector hook for
  React) + `getCanonicalUserView` (sync getter for non-React paths).
  Render reactivity is structural via the selector, not coincidence on
  legacy update paths.
- `populateFromReady` upsert pass runs BEFORE the federatedId dedup so
  members of skipped DMs still reach the cache.
- WS handlers (dm_message_*, message_*, user_updated, member_joined,
  friend_request_*, dm_channel_created, dm_member_added) and REST
  hydrators (socialStore, discoverStore, mutuals) feed the cache with
  their delivering origin.
- Render-site routing through `useCanonicalUserView` at every audited
  user-rendering site (sidebar, header, search, message bubble, reply
  chips, profile popout/modal, group settings, voice tiles, mention
  chips, member lists, friends, invites). Self-rendering sites compose
  alongside via existing `isSelf`/`resolveDisplayIdentity`.
- Globe predicate hoisted to `isFederationGlobeApplicable` and applied
  at three sites, gating on `domain !== window.location.host` so we
  never show the globe for users whose home IS our own.

Tests: 31 new unit tests across `identity`, `userViews` store, and
`userViewLookup`. Full suite 276/276.

Docs: `client-federation.md` §3 gains a "User View Cache" section
parallel to "DM Origin Failover"; `dm-system.md` notes the new store
action and WS handler upserts.

Bug 3 (federation profile-sync gap — orbit's stale profile data on
nova-Axel after a clear/color-change on nova never propagated)
remains open. The user-view cache routes around it for the common case
(home instance is connected), but the underlying S2S relay gap is its
own diagnosis and follows in a separate branch.
2026-05-05 01:59:05 +02:00
Jannis Braun fb96b1457a fix(ui): paint avatar fallback gradient before broken-img onError
The fallback <div>'s inline style only carried `background` when src was
falsy, so an <img> that 404'd flipped to display:flex via onError but with
no gradient applied — surfacing a colorless letter for any broken avatar
URL. Always paint the gradient + fontSize; keep `display:none` as the only
src-conditional override.
2026-05-05 01:58:29 +02:00
Jannis Braun 06498836bb fix(federation): gate registry PUT on successful initial GET
Without a sync-ready gate, a transient GET failure during autoConnectAll
left the local registry Map empty/incomplete while `set()` still computed
`registryUpdatedAt = Date.now()`. The trailing `syncRegistry()` would then
PUT the empty payload with a fresh timestamp; the server's LWW guard
accepted it and legitimate registry rows were wiped — including
remote-instance entries the user never explicitly removed.

Add `_registrySyncReady` flag, set true only after a successful initial
GET. `syncRegistry()` short-circuits while false, so the degraded mode
(GET failed) is display-only: the Map is still populated locally from
\`replicatedInstances\` synthesis (status \`auth_expired\`) so the
Connections UI shows the user's known remotes, but mutations don't push.
On the next session where GET succeeds, localStorage cached tokens
reseed the registry and \`syncRegistry()\` pushes the merged authoritative
state — no data lost, sync deferred until we have a complete picture.

\`reset()\` clears the flag alongside the registry. Spec updated with the
sync-ready gate and degraded-mode behavior. Unit tests cover the gate
on initial fail, mutations during degraded mode, recovery on next
successful GET, and the synthesis fallback for empty replicatedInstances.
2026-05-05 00:20:51 +02:00
Jannis Braun c05c04181d fix(chat): keep scroll container mounted during initial-load skeleton
The initial-load skeleton was rendered as an early return that replaced
the JSX containing `containerRef` / `contentRef`. On slow loads, the
200ms threshold flipped the skeleton on before messages arrived, so when
messages did arrive every scroll-affecting effect (Effect A, the
ResizeObserver, the load handler, scrollend) re-fired exactly once
against null refs and bailed — and never re-attached because no dep
changed when the skeleton finally cleared. Net: chat opened scrolled to
the top instead of the bottom; saved-anchor restore was equally broken.

Render the skeleton as an absolutely-positioned overlay alongside the
(always-mounted) scroll container so all refs stay live across the
loading transition. Encode the constraint in the spec as the
"ContainerRef invariant" so future loading/empty/error UI doesn't
reintroduce the early-return pattern.
2026-05-05 00:19:00 +02:00
Jannis Braun b3cbe9403f build(desktop): add electronbuild.sh — multi-platform installer build
Convenience wrapper around `pnpm build:all` in packages/desktop that
builds installers for mac/win/linux × arm64/x64 and collects the
distributable artifacts into packages/desktop/installers/. Excluded
from rsync deploy by deploy.sh.
2026-05-04 00:48:20 +02:00
Jannis Braun 52b5f8e2b1 chore(brand): refresh app icons + add brand source artwork
Replaces desktop and web icon binaries with the new brand mark, ships
PNG raster exports under assets/brand/ alongside the existing SVG
sources, adds Alternative Styles artwork variants, and updates the
icon generation script and System Prompt doc. deploy.sh excludes
the new local-only artifacts (*.rtfd, assets/brand, electronbuild.sh,
multi-platform-roadmap.md) so they don't get rsync'd to the live
boxes. Drops ARCHITECTURE_AUDIT.md (obsolete).
2026-05-04 00:48:11 +02:00
Jannis Braun c7eb14d38e test(federation-identity): #18 unrelated federated user untouched 2026-05-04 00:32:48 +02:00
Jannis Braun dfd3c26922 test(federation-identity): #17 mixed-result fan-out 2026-05-04 00:32:40 +02:00
Jannis Braun ec37b241d9 test(federation-identity): #16 member_left WS broadcast on full delete 2026-05-04 00:29:24 +02:00
Jannis Braun d917ff5354 test(federation-identity): #15 rate limit 5/15min 2026-05-04 00:26:35 +02:00
Jannis Braun 8e3fa1d5aa test(federation-identity): #14 S2S idempotency 2026-05-04 00:23:51 +02:00
Jannis Braun 27b6a3f912 test(federation-identity): #13 attribution guard rejects mismatched origin 2026-05-04 00:23:02 +02:00
Jannis Braun 21b413f9bd test(harness): use DISABLE_RATE_LIMITS in spawned instances + rate-limit-enabled variant
spawnInstance now sets DISABLE_RATE_LIMITS=1 by default so unrelated tests
don't exhaust the shared 127.0.0.1 per-IP bucket. bootHomePlusRemotes/
bootTwoInstances accept an optional { enableRateLimits } that omits the env
for tests that need real enforcement, exposed via the explicit
bootTwoInstancesWithRateLimits() helper for Test #15 (rate-limit assertion).
2026-05-04 00:18:24 +02:00
Jannis Braun 22b01c35f5 feat(server): DISABLE_RATE_LIMITS env replaces NODE_ENV-based bypass
Overloading NODE_ENV='test' to silently disable rate limiting layered a
second meaning onto an env that already gates the test-only seed-peer
route. A dedicated DISABLE_RATE_LIMITS env (envBool semantics, matching
DISABLE_FEDERATION_WORKERS) makes intent explicit, defaults off in
production, and leaves room for tests that need to assert real rate-limit
behaviour to opt back in by simply not setting the var.
2026-05-04 00:17:10 +02:00
Jannis Braun f4c9ed880f test(federation-identity): #9 unreachable / #10 no_active_peer 2026-05-04 00:14:09 +02:00
Jannis Braun b5df3d5075 test(federation-identity): #7 #8 — owned-spaces 409 + transfer-and-retry
Also fixes per-IP rate limit exhaustion in test environments: @fastify/rate-limit
v9 has no skip(); use allowList(() => NODE_ENV==='test') which propagates to
per-route overrides via mergeParams Object.assign merge.
2026-05-04 00:13:14 +02:00
Jannis Braun 22daaafb63 test(federation-identity): #2 #4 #6 — all-remotes fan-out variants 2026-05-04 00:06:44 +02:00
Jannis Braun 3c0e024213 test(harness): document why peerInstances inserts two rows per direction
The original peerInstances comment framed the two-row pattern as a
band-aid for getOurOrigin's https://${DOMAIN} default. After
investigating a clean collapse to one row (PUBLIC_ORIGIN override on
each spawned instance), the deeper coupling surfaces:

  - extractDomain() strips port via new URL().hostname, so unique-port
    localhost instances all share hostname '127.0.0.1' and the
    receiver's attribution guard
        extractDomain(user.homeInstance) === extractDomain(fedHeaders.origin)
    becomes ambiguous in any multi-remote configuration.
  - The homeInstance validator regex /^[a-zA-Z0-9._-]+$/ in auth.ts
    rejects ':', so the port cannot be encoded into homeInstance to
    disambiguate.
  - Eliminating the second row would require a production refactor of
    extractDomain (port-preserving), the attribution check (decoupled
    from URL), or the homeInstance validator (allow ':') — all out of
    scope.

So the harness DELIBERATELY keeps DOMAIN as a per-instance human label
('home.test.local' / 'remoteN.test.local') for stable identity, and the
two peer rows per direction (transport URL + getOurOrigin URL) are
structural to localhost-port test reality, not a band-aid. Comment
rewritten to reflect this. PUBLIC_ORIGIN remains available in
production code for reverse-proxy / dev-without-TLS deployments.
2026-05-04 00:02:14 +02:00
Jannis Braun d55e85d2c5 feat(federation): PUBLIC_ORIGIN env override for getOurOrigin
Adds an explicit override for the federation transport URL returned by
getOurOrigin(). When unset, behaviour is unchanged (https://${DOMAIN} ->
http://localhost:${PORT} dev fallback). Intended for reverse-proxy /
dev-without-TLS deployments where the public origin must be advertised
explicitly (typically http://...) and differs from the bare DOMAIN
value used for federated identity.

Wired via config.publicOrigin (envOptional('PUBLIC_ORIGIN')) so the
override flows through the existing config layer rather than scattering
process.env reads. Trailing slash is stripped for symmetry with
peer.origin storage.

docs/systems/federation.md gets a "Public Origin Override" subsection
under §14 Background Workers documenting the resolution order.
2026-05-04 00:02:14 +02:00
Jannis Braun c5e3d36689 test(federation-identity): #3 soft / #5 full — DB cascade verification + setup fixture
Adds setupFullDeletionFixture (federated user joins remote space, authors 2
messages with reactions, opens 1-on-1 DM with a live observer). Tests #3 (soft
mode: tombstone + dm_members cleared, messages/reactions retained) and #5 (full
mode: tombstone + messages/reactions purged, surviving 1-on-1 DM channel).

Also fixes seedPeer to install both the URL form (outbound lookup on sender)
and the DOMAIN-claim form (inbound auth on receiver) — required because the
test harness's ephemeral http://127.0.0.1 origin and DOMAIN-derived
getOurOrigin() return different strings, while production has them coincide.
This was latent: test #1 (leave mode) skips S2S, so #3 was the first test to
actually exercise the S2S delete path and surfaced the dual-origin gap.
2026-05-03 23:46:20 +02:00
Jannis Braun 957cfd9094 test(federation-identity): #1 leave mode — registry cleaned, remote untouched, no S2S call
Also fix dbInspect.ts UserRow column aliases: SELECT * returns snake_case
columns (is_deleted, display_name, etc.) but UserRow expected camelCase.
Switch to explicit aliased SELECT so all callers get the documented interface.
2026-05-03 23:38:02 +02:00
Jannis Braun b62ffdf03b test(federation-identity): #11 invalid mode / #12 empty origins 2026-05-03 23:35:15 +02:00
Jannis Braun 2df346cf03 test(federation-identity): scaffold + harness boot smoke 2026-05-03 23:34:57 +02:00
Jannis Braun a2fadf7e93 test(harness): wsListener helper for capturing real WS events 2026-05-03 23:33:29 +02:00
Jannis Braun e06b5fec3c test(harness): testUsers helpers — registerLocal + createFederatedUser via production endpoints 2026-05-03 23:26:47 +02:00
Jannis Braun 5fe6e7f225 test(harness): hmacSign + seedSpaceWithStubOwner helpers 2026-05-03 23:21:53 +02:00
Jannis Braun e95d3ef065 test(harness): read-only DB inspector helpers 2026-05-03 23:20:59 +02:00
Jannis Braun db70064a5a test(harness): seedPeer helpers — peer pair + unreachable peer 2026-05-03 23:20:30 +02:00
Jannis Braun cd6c51f893 test(harness): N-remote child-process harness + log-tail helpers 2026-05-03 23:17:42 +02:00
Jannis Braun c25aecc81b chore: add test scripts for federation identity deletion suite 2026-05-03 23:07:37 +02:00
Jannis Braun f661d112fa chore: gitignore tests/.tmp/ and tests/reports/ scratch dirs 2026-05-03 23:07:22 +02:00
Jannis Braun 35072dc11f docs: correct soft-mode purge claim — orphaned DMs always purge regardless of mode 2026-05-03 23:06:34 +02:00
Jannis Braun 03165a3b7d feat(server): test-only seed-peer route gated by NODE_ENV+ENABLE_TEST_ROUTES 2026-05-03 22:59:46 +02:00
Jannis Braun 3dbf4dab5e feat(server): DISABLE_FEDERATION_WORKERS env gate for test isolation 2026-05-03 22:51:06 +02:00
Jannis Braun 2b0f93ec62 polish(desktop): rename userData folder to Backspace with first-launch migration
Electron derived userData from package.json's `@backspace/desktop` name, leaking
the monorepo's pnpm scope into ~/Library/Application Support/. Now `app.setName`
runs at module load before any userData consumer, and a one-shot migration
atomically moves the historical folder to <appData>/Backspace, cleaning the
empty @backspace/ parent. Conservative on conflict — never clobbers an existing
populated target. EXDEV fallback to recursive copy. Smoke-recovery path flipped
back to Backspace.
2026-05-03 14:36:34 +02:00
Jannis Braun 309484dca9 fix(testing): smoke-recovery.sh — correct userData path, http.server scenarios, missing helper
Three issues found while running the script for the first time:

1. userData path was hardcoded to ~/Library/Application Support/Backspace.
   Electron actually uses ~/Library/Application Support/@backspace/desktop
   (derived from package.json#name when app.name is not set explicitly).
   Scenario 2's bad URL was being written to the wrong location, so the app
   never loaded it and recovery never entered. Path corrected.

2. Scenario 4's VITE_FORCE_BOOT_STALL approach didn't actually stall anything
   in practice: the desktop loads the user's REMOTE instance URL, which
   serves its own web bundle from the server. The locally rebuilt stalled
   bundle is never used at runtime. Replaced with a tiny static HTML page
   served via `python3 -m http.server` and pointed at via BACKSPACE_URL.
   The page has no window.backspace integration, so it never pings,
   guaranteeing the boot timer fires. No web/desktop rebuilds needed.

3. Scenario 13 (positive control: rendererReady ping disarms the timer)
   used assert_no_log which was never defined. The function is now defined
   alongside wait_for_log. Scenario 13 now correctly verifies that a page
   that DOES call rendererReady never triggers recovery.

All three automated scenarios now pass:
  ✓ Scenario 2:  load-failed recovery entered
  ✓ Scenario 4:  renderer-stalled recovery entered
  ✓ Scenario 13: boot timer correctly disarmed by rendererReady ping
2026-05-03 14:02:05 +02:00
Jannis Braun 833dedd4a1 fix(desktop): boot-timer race — handle rendererReady ping arriving before arm
CRITICAL BUG. In real SPAs, useEffect fires during document load (microtask
after bundle execute + React render), which is BEFORE did-finish-load fires
(after window.onload). Without this fix, the ping arrived when bootArmed=false
(no-op), then did-finish-load armed a timer nothing would clear → 20s later
every successful packaged build falsely entered recovery.

Caught by smoke scenario 13 (positive control: page that DOES ping should NOT
recover). The smoke proved the page's script ran AND the ping was sent, yet
recovery still fired.

Fix: module-level pingReceivedThisNav flag, reset on did-navigate, set in
handleRendererReady, checked in armBootTimer (early-return if true). Late-ping
case (ping after arm) preserved via existing 'if (bootArmed) clearBootTimer()'.

Also exports resetBootTimerStateForTest() to ensure full module-state isolation
between tests (pingReceivedThisNav is module-level and must not bleed across
test cases in the same run).

3 new tests pin the early-ping, late-ping, and per-nav persistence semantics.
48/48 tests pass. Build clean.
Spec + docs updated.
2026-05-03 13:51:04 +02:00
Jannis Braun 7e30db3773 feat(testing): smoke-test script + VITE_FORCE_BOOT_STALL gate
- App.tsx and main.tsx ErrorBoundary gate rendererReady() on
  VITE_FORCE_BOOT_STALL build env var. When set, the ping is suppressed
  so the main-process boot timer fires (exercising the renderer-stalled
  recovery path without hand-editing source).
- vite-env.d.ts declares the env var type so TS doesn't complain.
- scripts/smoke-recovery.sh automates scenarios 2 (bad URL → load-failed)
  and 4 (forced stall → renderer-stalled), grepping stderr for the
  [recovery] entered: ... lines added by the prior commit. Backs up and
  restores the user's instance-url.json. Manual scenarios documented in
  the script header.
2026-05-03 13:21:54 +02:00
Jannis Braun fff39f8d76 polish(desktop): non-destructive Change Instance + recovery enter/exit logs
UX bug found during smoke testing: clicking Change Instance immediately
deleted the saved instance URL and showed an empty picker, with no way
back if the user changed their mind.

Fix:
- Don't clearInstanceUrl() in recovery action 'change-instance' — picker
  is now non-destructive
- Picker pre-fills the input with the current saved URL when present
- Cancel button (shown only when a saved URL exists) returns to current
  instance via idempotent setInstanceUrl re-save
- Header copy switches to 'Switch instance' / 'Cancel to stay' framing
  when a saved URL is present
- URL only overwrites on explicit Connect to a different instance

Also: add console.log enter/exit lines in enterRecoveryMode and the
clear-recovery-state action handlers, so smoke-test scripts can grep
stderr for recovery activity without UI introspection.

Spec + docs/systems/desktop.md updated.
2026-05-03 13:16:55 +02:00
Jannis Braun cff9a8e2cf polish(desktop): final cross-cutting fixes
Two real bugs from final review:
- Clear recovery state on window 'closed' so macOS dock-activate doesn't
  drop the recovery surface (window recreated with stale recoveryStore.mode)
- Hoist setOnQuitRequested before createWindow so synchronous boot failures
  reach a wired Quit handler

Three polish items:
- Tray's Change Instance now routes through handleRecoveryAction so both
  paths share one implementation; recovery action's change-instance also
  show()+focus() for hidden-window tray clicks
- install-update action guards against state.updateState !== 'downloaded'
  (defense in depth against malicious or buggy renderers)
- Object.freeze rationale documented in RecoveryStateStore.update
2026-05-03 12:19:51 +02:00
Jannis Braun 6356e2e526 docs(desktop): document Recovery Mode and updated Auto-Update integration
- New Recovery Mode section: state model, detection paths, UI, loop prevention,
  hidden-launch override, force-kill fix, inter-module wiring
- Auto-Update section extended with Recovery Integration subsection
- IPC tables updated: renderer-ready, recovery-action, get-recovery-state,
  recovery-state-changed
- Preload Bridge table extended with 4 new methods
- Notifications section updated with optional onClick + setAppUserModelId
2026-05-03 12:13:15 +02:00
Jannis Braun d21459129c polish(desktop): recovery.html — await version alongside state in init
Eliminates the brief "Version: loading…" flash in the diagnostic block
on first render. Resolves getRecoveryState + getVersion in parallel via
Promise.all so the first render call has both. Drops the redundant
module-load .then() that set cachedVersion before render ran.
2026-05-03 12:09:06 +02:00
Jannis Braun d2a225a4b9 feat(desktop): add recovery.html UI page
Vanilla HTML/CSS/JS, mirrors instance-picker.html drag-region pattern.
Reads RecoveryState via preload bridge, dispatches actions through
single recovery-action IPC. Cmd/Ctrl+R wired as Reload shortcut.
2026-05-03 12:05:40 +02:00
Jannis Braun 6929c460aa feat(web): ping rendererReady from App.tsx and ErrorBoundary
App.tsx useEffect signals boot-completion (success path). ErrorBoundary
componentDidCatch signals it on the caught-error path so the in-app
error UI isn't overridden by native recovery 20s later.
2026-05-03 12:01:28 +02:00
Jannis Braun ece81a2b71 feat(desktop): add recovery bridge methods to preload + types
- rendererReady (boot-completion ping)
- getRecoveryState / onRecoveryStateChanged (recovery.html subscribers)
- recoveryAction (button click dispatcher with enum action)

Type declarations kept ambient (no export) to preserve window.backspace
global augmentation — exporting from an ambient .d.ts converts it to a
module and breaks the Window interface extension.
2026-05-03 11:57:51 +02:00
Jannis Braun d8266e62da feat(desktop): wire auto-updater events into recovery store
- All updater events update recoveryStore (drives tray/menu UI)
- Native notification fires on update-downloaded when window unfocused
- Notification click calls quitAndInstall() directly (force-kill fix)
- Preserves existing renderer IPC channels (in-app banner unaffected)
- Graceful degradation when electron-updater module unavailable
2026-05-03 11:52:21 +02:00
Jannis Braun 56677da8ad refactor(desktop): pass trayActions to buildAppMenuTemplate, remove appMenuActions duplication
buildAppMenuTemplate's actions param is Partial<MenuActions>, so the
tray-only callbacks (onShow/onHide/onQuit) are simply ignored. Removes
the three-callback duplication between the two objects without changing
behavior. Future onChangeInstance/onCheckForUpdates/onRestartToInstall
changes only need to be made in one place.
2026-05-03 11:49:42 +02:00
Jannis Braun bb25a3ba5e feat(desktop): wire recovery store into main process
- setMainWindow on createWindow, setMainWindow(null) on closed
- attachRecoveryHandlers wires Electron unresponsive/crash/load-fail events
- Store subscriber drives tray context menu + macOS app menu + mode-gated
  recovery-state-changed push to renderer; single applyMenusForState
  function shared between subscriber and initial fire (no drift)
- Old hard-coded createTray Menu and macOS app-menu construction deleted;
  Win/Linux Edit-only menu retained as one-time setup for keyboard accelerators
- requestQuit exported, wired via setOnQuitRequested callback
- Recovery IPC handlers: renderer-ready, recovery-action, get-recovery-state
- setAppUserModelId('com.backspace.desktop') for Win32 notification attribution
- Extended showNotification with optional onClick (existing 2-arg callers unchanged)
2026-05-03 04:30:58 +02:00
Jannis Braun 1c4bde3029 feat(desktop): attach Electron webContents handlers for recovery detection
- did-navigate + did-finish-load arm/disarm boot timer (skips SPA routing)
- did-fail-load filtered by isMainFrame and ERR_ABORTED
- render-process-gone filtered to exclude clean-exit
- unresponsive uses 10s grace with responsive cancellation
2026-05-03 04:25:03 +02:00
Jannis Braun 0c489701fc feat(desktop): add enterRecoveryMode and recovery action funnel
- mainWindow/autoUpdater/quit handover via setter pattern (no main.ts imports)
- Optimistic recovery exit on Reload — re-enters via did-fail-load if reload fails
- install-update calls autoUpdater.quitAndInstall() directly (force-kill fix)
- Strict allowlist validation in handleRecoveryAction
2026-05-03 04:20:22 +02:00
Jannis Braun 323b9017d7 feat(desktop): add boot-completion timer with packaged-only and URL-protocol guards 2026-05-03 04:16:22 +02:00
Jannis Braun a9122d1fdf feat(desktop): add macOS app menu template builder
Adds buildAppMenuTemplate pure function to recovery.ts that produces the
three-submenu macOS app menu (App/Edit/Window), reusing MenuActions and
checkForUpdatesItem from T4. Includes Restart to Install Update item
conditionally on updateState=downloaded. Tests use destructuring to satisfy
noUncheckedIndexedAccess. 26 tests pass, tsc clean.
2026-05-03 04:13:19 +02:00
Jannis Braun 172f53e3a5 feat(desktop): add tray menu template builder with state-driven labels
Pure buildTrayMenuTemplate function returns MenuItemConstructorOptions[]
without constructing real Menu objects, enabling full test coverage.
All 7 new tests pass (22 total); MenuActions interface and
checkForUpdatesItem helper are intentionally unexported.
2026-05-03 04:10:11 +02:00
Jannis Braun b5273b596a feat(desktop): add extractErrorCode helper for updater errors 2026-05-03 04:07:46 +02:00
Jannis Braun d3b3abacff harden(desktop): RecoveryStateStore listener safety + frozen state
- Snapshot listener set before notifying so subscribers can subscribe/
  unsubscribe during notification without breaking the pass
- Per-callback try/catch so one throwing subscriber does not silence others
- Object.freeze on each state object so the live reference returned by
  get() cannot be accidentally mutated externally (compile-time
  Readonly<> is hint only)
- 3 new tests pinning these invariants
2026-05-03 04:06:43 +02:00
Jannis Braun 15dfa0a68c feat(desktop): add RecoveryStateStore with subscriber lifecycle 2026-05-03 04:03:07 +02:00
Jannis Braun dcbae46e0e refactor(desktop): extract instance-URL helpers to instanceUrl.ts
Pure mechanical extraction — no behavior change. Enables recovery.ts
to share these helpers without a circular import on main.ts.
2026-05-03 03:58:00 +02:00
Jannis Braun 3dc3043faf Merge branch 'feature/audio-device-ux': polish audio device UX
15 commits implementing:
- useAudioDevices hook (single source of truth, privacy-respecting enumeration)
- AudioManager extensions (test tone, upstream-track-end event, hot-plug helpers)
- Seamless hot-plug in AppLayout (debounced re-acquire + new-device toast)
- Mic-track-loss recovery via AudioManager.onInputTrackEnded (corrected from original published-track design)
- Full Audio Input/Output sections in Voice & Video settings
- Privacy fix in UserAreaPanel (eliminates unconditional getUserMedia probe)
- Spec parallel to Camera Device Selection in voice.md
2026-05-03 02:31:27 +02:00
Jannis Braun 19d42445b0 polish(web): clear device lists on permission revoke; document probe-constraint omission 2026-05-03 02:09:13 +02:00
Jannis Braun 4044e910c3 docs(voice): add Audio Device Selection section parallel to Camera Device Selection 2026-05-03 02:01:09 +02:00
Jannis Braun 6905071459 refactor(web): UserAreaPanel uses useAudioDevices — kills unconditional getUserMedia probe; reuses shared DropdownItem 2026-05-03 01:56:24 +02:00
Jannis Braun ff8dcc28ea feat(web): wire AudioInput/Output sections into Voice & Video settings; fix EC tooltip 2026-05-03 01:52:20 +02:00
Jannis Braun 5b8af14e12 feat(web): AudioOutputSection — output picker, volume, test tone, lifecycle-aware sinkId check
- Uses shared SectionShell/DropdownItem from ./_shared/SettingsPickerPrimitives
  rather than redefining local copies (parity with AudioInputSection).
- supportsSinkId is reactive to AudioContext lifecycle via the audioCtxGen
  pattern (bumped by AudioManager.onResumed). Defaults to true and only flips
  to false when a real context exists AND lacks setSinkId (Safari < 17), so
  the picker is never preemptively hidden when the user opens Settings before
  joining voice. AudioManager.setOutputDevice + initContext re-apply path
  handles the deferred sinkId binding once the context appears.
2026-05-03 01:47:49 +02:00
Jannis Braun 8e67f1d553 fix(web): mic meter reacts to AudioContext lifecycle; extract picker primitives
- AudioInputSection now subscribes to AudioManager.onResumed and bumps an
  audioCtxGen state on each 'running' transition. Mic-level meter and
  resolved-default hint effects depend on it, so opening Settings before
  joining voice and then joining voice activates the meter without needing
  to remount the panel. Footer copy updated to match the new behavior.
- SectionShell and DropdownItem extracted to
  settingsPanels/_shared/SettingsPickerPrimitives.tsx so Task 6
  (AudioOutputSection) can import them instead of triplicating the markup.
  The _shared/ folder keeps these settings-internal primitives out of the
  broader ui/ namespace.
- Deliberate scope choice: VideoSection.tsx still has its own DropdownItem
  copy. Unifying all three is left to a follow-up; touching VideoSection
  here would expand scope beyond the audio-device-ux branch.
2026-05-03 01:43:01 +02:00
Jannis Braun 3aea8210bd feat(web): AudioInputSection — full input picker with level meter and resolved-default hint 2026-05-03 01:37:37 +02:00
Jannis Braun 8907bb305d fix(web): mic-loss recovery uses 'could not be restored' for unclassified errors 2026-05-03 01:35:07 +02:00
Jannis Braun 63bf9e684c fix(web): mic-loss detection observes upstream stream, not published clone
The previous Task 4 handler installed onended on the *published* mic track,
but that track is a clone of AudioManager's MediaStreamAudioDestinationNode
output — destination-node tracks never end on upstream loss, they just go
silent. The handler also called setInputDevice for silent-recovery and assumed
syncMic would re-publish, but syncMic's dep array does not depend on
streamGeneration, so the recovery never republished.

This commit moves loss detection into AudioManager (where the upstream
getUserMedia track lives) via a new onInputTrackEnded subscription, extracts
republishMicrophone from syncMic into a module-level helper that both the
normal device-change path and the recovery path call directly, and removes
the published-track Microphone branch from RoomEvent.LocalTrackPublished.
The plan and Task 9 spec text are updated to match.
2026-05-03 01:20:13 +02:00
Jannis Braun 9a26513009 feat(web): mic-track-loss recovery — probe, attempt re-acquire, toast 2026-05-03 01:09:04 +02:00
Jannis Braun 674d011d3c fix(web): debounce devicechange handler and gate listener on baseline seeding 2026-05-03 01:05:37 +02:00
Jannis Braun d992b2bb41 feat(web): seamless audio hot-plug — re-acquire live stream + toast new devices 2026-05-03 01:01:03 +02:00
Jannis Braun 2726cc5c5a feat(web): expose AudioManager helpers for device introspection + test tone 2026-05-03 00:57:26 +02:00
Jannis Braun a1ebb23264 feat(web): add useAudioDevices hook for permission-aware audio enumeration 2026-05-03 00:51:20 +02:00
Jannis Braun c0e71b1ded fix(federation): hydrate downloads replicated avatars locally + backfill stale URL rows
hydrateReplicatedUserProfile now calls downloadProfileAsset and stores bare local filenames, falling back to absolute URLs only on download failure. It also fills empty fields only — no longer clobbering local files written by processProfileUpdateEvent. Adds an idempotent startup backfill that converts existing http-prefixed avatar/banner rows on replicated users into local files, so federated profile pictures keep rendering when the home instance is offline.
2026-05-02 22:45:27 +02:00
Jannis Braun c0fd7e3a75 feat(web): home tile uses primary purple when active/hovered, grey when idle
Replaces the full-bleed black-bg logo badge in the top-left @me tile with a
white logo mark on the primary purple (matching toggle switches) when active
or hovered, and `--interactive-muted` grey when idle. Logo bumped to 25px.
2026-05-02 22:12:18 +02:00
Jannis Braun 12a71a12d4 fix(web): close writable on download pause/abort so partial bytes flush (resume continues from offset) 2026-05-02 21:13:09 +02:00
Jannis Braun f33ebe2aa7 chore(web): drop unused React import; static-import idbHandles consistently 2026-05-02 20:54:50 +02:00
Jannis Braun bc32eccc2d fix(server): CORS allows tus headers (federated uploads no longer blocked at preflight) 2026-05-02 20:42:02 +02:00
Jannis Braun 3a050f475b fix(web): tus uploads use per-origin token (federation auth) 2026-05-02 20:37:17 +02:00
Jannis Braun 33cfc66ac4 fix(web): boot rehydrate normalizes transfers; paused has its own visual 2026-05-02 19:10:29 +02:00
Jannis Braun 61be9d013f fix(web): hide Retry when blob unavailable; failed-state surfaces actionable error
Pending bubbles that survive a reload (or post-redeploy refresh) without a
FileSystemFileHandle had no way to recover the original File bytes, so the
Retry button silently no-op'd: resumeUpload set state back to 'paused' with
no surfaced error, leaving the user stuck.

- transferStore: add reactive hasInMemoryFile Set mirroring liveUploadFiles.
  setInMemoryRef helper keeps both in sync at every set/delete site
  (startUpload, resumeUpload, onSuccess, remove). Persisted shape is unchanged.
- resumeUpload: when no blob is reachable (no in-memory File and no FS handle),
  call setError with an actionable "File no longer available — discard and
  re-upload" message instead of silently flipping back to 'paused'.
- Message.tsx: compute canRetry reactively from transfersForRow + hasInMemoryFile;
  hide the Retry button when retry is infeasible. Discard remains.
- AttachmentProgress: optional error prop surfaces transfer.error.message via
  title= on the failed-state ring for hover context.
- Tests: existing 3 resumeUpload tests now assert state==='failed' with the
  actionable message; +2 new tests for hasInMemoryFile lifecycle (start/remove,
  abort retains).
2026-05-02 18:58:05 +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 4e5a440176 fix(web): server-side DELETE for failed/discarded uploads (no janitor wait) 2026-05-02 18:22:45 +02:00
Jannis Braun dd91f5f349 fix(web): retry-after-abort starts fresh tus session; preserve File past abort 2026-05-02 18:18:07 +02:00
Jannis Braun 61dea725a3 fix(web): polish failed-upload row — pill-shaped, content-width, proper button affordance 2026-05-02 18:11:31 +02:00
Jannis Braun a58b7459fb fix(web): in-session pause/resume preserves File ref; aborted transfers flip bubble to failed 2026-05-02 18:03:12 +02:00