1710 Commits
Author SHA1 Message Date
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