Commit Graph
658 Commits
Author SHA1 Message Date
TheZwiss c7d88481ad test: give federation integration suites a realistic per-test timeout (#7)
The three test/ files boot real federated instances and drive S2S over HTTP,
with several tests deliberately waiting on log matchers (e.g. logMatched(...,
1_000) per remote). Vitest's 5s default per-test timeout is meant for unit tests
and is too tight here: under CI load the multi-remote fan-out tests in
federation-identity-deletion intermittently timed out (observed on the post-merge
main run), producing a flaky red check.

Set a file-level testTimeout of 30s in each of the three harness-based suites via
vi.setConfig. Scoped per-file so unit tests keep the strict 5s default; a genuine
hang still trips the 30s ceiling well before the 90s hook budget. No test logic
changed.
2026-07-10 00:51:18 +02:00
BadAtCaptchasandJannis Braun 9b6d1b18eb Fix federated attachment downloads allowing SSRF via redirects (#1)
* Backspace — initial public release

* chore: pin Node 20 (LTS) and pnpm 10.34.3

Unpinned `pnpm@latest` in the Dockerfile made fresh builds non-reproducible:
`latest` now resolves to pnpm 11, but the committed lockfile targets pnpm 10, so
`pnpm install --frozen-lockfile` fails with ERR_PNPM_LOCKFILE_CONFIG_MISMATCH on
a clean host. Pin pnpm to 10.34.3 across the Dockerfile, the `packageManager`
field, and the release workflow; pin Node to 20 (LTS) via `.nvmrc` and `engines`
so Docker, CI, and from-source builds all use the same tested toolchain.

Also corrects the docs: the lockfile is v9.0 (requires pnpm 10, not "pnpm 8+"),
and "Node 20+" implied untested newer majors were supported.

* fix(compose): fail fast on missing JWT_SECRET instead of a silent restart loop

Without JWT_SECRET the server throws at boot (config.ts) and `restart:
unless-stopped` turns that into an endless crash loop that is invisible unless
you run `docker compose logs backspace`. Guard it exactly like DOMAIN already is,
so `docker compose up` stops immediately with an actionable message. install.sh
generates the secret before bringing the stack up, so this only affects the
manual `cp .env.example .env && docker compose up` path.

* docs(env): make the JWT_SECRET requirement explicit in .env.example

Spell out that JWT_SECRET is required and must be a strong 32+ char value, and
that the empty default is intentional (docker compose fails fast rather than the
server boot-looping). Pairs with the compose-level guard added in 592c23f.

* fix(desktop): don't let uiohook-napi rebuild abort the whole workspace install

The @backspace/desktop postinstall runs `electron-rebuild -f -w uiohook-napi`,
which compiles a native module from source and needs a C++ toolchain
(make/g++/python3). On a bare host without those — a typical VPS a self-hoster
clones onto — that failure took the ENTIRE `pnpm install` down (exit 255), so the
server never installed either, even though uiohook-napi is desktop-only and
irrelevant to hosting.

Make the rebuild non-fatal: on failure it now prints a clear one-line warning and
continues. Machines with build tools (CI, desktop devs) are byte-identical — the
fallback never fires; only toolless boxes (which aren't building the desktop app
anyway) skip it. Verified on a bare x86 box: full `pnpm install` now exits 0 and
the server builds and boots from source. Docs note the build-tool requirement for
desktop work and point self-hosters at the Docker installer.

* feat(deploy): three deployment modes + prebuilt multi-arch image for robust self-hosting

Make Backspace self-hostable in any homelab environment, not just a clean host
that owns ports 80/443.

install.sh is now mode-aware and auto-detects which fits:
  - allinone (default): bundled Caddy + auto-HTTPS — unchanged behavior
  - proxy: behind your own reverse proxy (nginx / Traefik / Caddy / Nginx Proxy
    Manager / SWAG) — app published on 127.0.0.1:APP_PORT, no bundled Caddy,
    prints paste-ready proxy snippets
  - tunnel: behind a tunnel (Cloudflare / Tailscale) — same, plus a 90MB upload
    cap (under Cloudflare's 100MB body limit) and voice force-disabled (WebRTC
    over UDP can't traverse a tunnel)

Port detection is Docker-aware (consults `docker ps` published ports, not just
`ss`), so a host whose proxy already owns 80/443 via iptables DNAT — with no
listening socket for `ss` to see — is correctly detected as "taken" instead of
dead-ending.

docker-compose.proxy.yml is a small overlay, layered via COMPOSE_FILE (written
into .env so no `-f` flags are ever needed), that publishes the loopback port and
parks Caddy in an inert profile. The base compose file is untouched, so All-in-One
behaves exactly as before.

Prebuilt image: .github/workflows/docker-publish.yml builds and pushes a
multi-arch (linux/amd64 + linux/arm64) image to ghcr.io/thezwiss/backspace on
release tags (and manual dispatch), so weak/ARM hosts skip the ~1.6GB local build
(the Vite build OOMs small ARM boxes). install.sh and docker-compose.yml default
to pulling it, fall back to an image already present on the host, and finally to a
from-source build — AGPL §13 commit stamping preserved on every path. Kept
deliberately separate from the desktop-installer workflow (release.yml).

Docs: README gains a "Deployment modes" section (all three modes, nginx / Caddy /
Traefik snippets, GUI-proxy field-by-field, cloudflared ingress, the update path,
and voice-per-mode caveats); docs/systems/deployment.md updated to match.

Verified live on a throwaway VM: proxy + all-in-one end-to-end through install.sh
(with a real Let's Encrypt cert), tunnel config generation, loopback-only binding,
and the local-image fallback path.

* Fix federation file download SSRF (#1)

---------

Co-authored-by: Jannis Braun <151788261+TheZwiss@users.noreply.github.com>
2026-07-10 00:27:05 +02:00
TheZwiss 628e4dec3e ci: run typecheck, build & tests on PRs and main (#6)
* ci: run typecheck, build & tests on PRs and main

Add a GitHub Actions workflow (.github/workflows/ci.yml) that installs on
Node 20 (the pinned runtime), builds shared/server/web, typechecks desktop,
and runs the full vitest suite across all packages on every pull request and
push to main. The repo already had 500+ tests but only ever ran them locally.

Also wires up the missing test/typecheck plumbing this exposed:
- web package had 55 test files but no test script — add test/test:watch/typecheck
- add root-level test and typecheck aggregate scripts
- fix a latent web type error the new typecheck surfaced: reference
  vite-plugin-pwa/react types so virtual:pwa-register/react is typed
  (useRegisterSW callback params were implicit any; the tsc half of the web
  build script has been failing, masked by vite build ignoring type errors)

* test: fix two latent failures surfaced by CI on Node 20

- server: exclude dist/** from vitest. Vitest 4's default exclude dropped
  dist/, so after `pnpm build` emits compiled .test.js files, vitest ran those
  stale copies alongside src/*.test.ts and they failed (compiled vi.mock paths
  resolve differently).
- web: polyfill Blob.prototype.stream in the jsdom test setup. jsdom's Blob has
  no .stream() on Node 20 (Node 25 provided one, masking this locally); undici's
  Response constructor calls blob.stream(), so new Response(blob) threw
  'object.stream is not a function'.
2026-07-10 00:15:41 +02:00
Jannis Braun 85e1975fa5 fix(permissions): deny space permissions to non-members (invite-bypass)
computePermissions() returned the space @everyone role's permissions without
verifying the caller had joined the space. Because CREATE_INVITE is in
DEFAULT_EVERYONE_PERMISSIONS, any authenticated user could mint an invite code
for a request-only space — whose id is listed by /api/spaces/explore — and then
self-join via /api/spaces/:id/join, bypassing the join-request approval flow.
The same gap let non-members read message history and search default channels.

Root cause:
- computePermissions now returns 0n for non-members (space owner and instance
  admin still short-circuit first, so they are unaffected).

Defense in depth (request-only spaces are approval-gated, never invite-joinable):
- both invite-code join endpoints reject visibility='request' (private stays
  invite-joinable — its only entry path; public too).
- POST /api/spaces/:id/invite refuses to hand out a code for request spaces.
- POST /api/dm/space-invite refuses to card a local request space, checked by
  space id against the local table so a spoofed spaceInstanceOrigin can't slip
  past it.
- InviteModal hides the invite affordances for request spaces.

Also removes the unused computeCategoryPermissions(), which duplicated the
resolution algorithm without the membership gate.

Adds unit + route + component tests covering non-member/member/owner/admin
resolution and the request/private/public visibility matrix.

Reported-by: BadAtCaptchas (#2)
2026-07-07 19:45:51 +02:00
Jannis Braun 011a9be92a feat(federation): startup sweep reconciles drifted 1-on-1 DM federatedIds — heals pre-fix re-attaches (reattach-dm-reconcile spec §3.3) 2026-07-03 12:36:17 +02:00
Jannis Braun 0bb4ef8cad feat(federation): re-attach reconciles the account's 1-on-1 DM federatedIds inline (reattach-dm-reconcile spec §3.2) 2026-07-03 12:34:18 +02:00
Jannis Braun 1da44c8d6b feat(federation): reconcileDmChannelFederatedId — re-key/merge 1-on-1 DM channels on identity change (reattach-dm-reconcile spec §3.1) 2026-07-03 12:28:21 +02:00
Jannis Braun d3af4f2170 fix(federation): close re-attach final-review findings — client/server domain normalization, merge attachment repoint, empty-domain guard, test hardening 2026-07-03 02:43:45 +02:00
Jannis Braun d45366c4ff feat(federation): owner-initiated detached-account re-attach — proof-gated re-bind with stub merge (re-attach spec §3.2, §3.3) 2026-07-03 02:06:00 +02:00
Jannis Braun 7bff6c1a1b feat(federation): outbound attach-proof verification + home-profile fetch helpers (re-attach spec §3.1) 2026-07-03 01:54:50 +02:00
Jannis Braun af94f12389 feat(federation): S2S verify-attach-proof endpoint — single-use, peer-domain-bound, signed response (re-attach spec §3.1) 2026-07-03 01:49:20 +02:00
Jannis Braun 1c962ded12 feat(auth): attach-proof mint endpoint for detached-account re-attach (re-attach spec §3.1) 2026-07-03 01:41:08 +02:00
Jannis Braun 669d80d7c7 feat(federation): federation_attach_proofs table (re-attach spec §3.1) 2026-07-03 01:36:30 +02:00
Jannis Braun a0238eabc2 feat(federation): startup sweep removes dead-incarnation channels and self-homed stubs (dead-incarnation spec §3.4) 2026-07-03 01:05:26 +02:00
Jannis Braun 3591773a4c feat(federation): deleted flag on wire profile snapshots — tombstone markers never leave the instance (dead-incarnation spec §3.3) 2026-07-03 00:59:37 +02:00
Jannis Braun 5ffc7c565e feat(federation): sync endpoint scopes friend events to the requesting peer, pagination-safe (dead-incarnation spec §3.2) 2026-07-03 00:50:31 +02:00
Jannis Braun bd40058613 feat(federation): sync endpoint scopes DM channels to the requesting peer (dead-incarnation spec §3.2) 2026-07-03 00:42:02 +02:00
Jannis Braun 99c8361b75 feat(federation): receiver guard — never create replicated stubs homed at our own domain (dead-incarnation spec §3.1) 2026-07-03 00:37:27 +02:00
Jannis Braun 54ab660204 feat(federation): near-instant reset detection — probe epoch at the auth-failure transition + on worker startup
A reset peer reaches needs_attention via the auth-failure path (HMAC desynced by
the new incarnation) without passing through unreachable, so the 5s recovery
probe never saw it — detection waited up to a full 15-min health-check cycle
before 'Re-peer & heal' surfaced. Extract detectResetForPeer() and fire it
event-driven at the transition, plus a startup sweep for already-stuck peers.
15-min tick remains the backstop.
2026-07-02 20:10:35 +02:00
Jannis Braun 13d050c1bb fix(federation): close detached-account gaps from final review — presence/hydrate guards, ack re-detect clear, self-delete password (detach spec §4.3/§4.4/§4.6) 2026-07-02 19:34:32 +02:00
Jannis Braun 172398171a docs(systems): finish detach consolidation — federation.md §6.3b, database.md, stale comments (detach spec §8) 2026-07-02 19:15:24 +02:00
Jannis Braun 42ad5e141d feat(federation): server-side acknowledge for reset events (acknowledged_at + admin endpoint) (detach spec §4.6) 2026-07-02 18:59:33 +02:00
Jannis Braun 7e1e32de69 feat(federation): detached accounts get local profile+password self-service; self-view flag (detach spec §4.4, §4.7) 2026-07-02 18:54:37 +02:00
Jannis Braun ea66ec5dbd test(federation): pin registration 409 + suffixed stub creation against detached accounts (detach spec §4.3.5)
Also add the positive companion assertion (folded in from a Task 3 review
Minor): findFederatedUser tier-2 STILL returns a NON-detached (orphaned=0)
same-name federated row, locking that the eq(federation_home_orphaned, 0)
clause discriminates on the flag alone and never over-filters legitimate
replicated identities.

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

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

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