1503 Commits
Author SHA1 Message Date
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 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 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 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 19d42445b0 polish(web): clear device lists on permission revoke; document probe-constraint omission 2026-05-03 02:09:13 +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