The activity pipeline was already complete end to end — Activity type, store,
WS broadcast, server validation, presence relay, and an ActivityCard used by
four list surfaces — but the profile card rendered none of it, which is the
'Listening to Spotify' block the design calls for.
Add ProfileActivity: richer than ActivityCard because the card has room for
artwork, track and artist, so it reads details/state/assets. All optional, so
it degrades to the bare name that today's process-based detector supplies.
Also scheme-check activity image assets server-side. activity.url was already
restricted to http(s) but assets.largeImage/smallImage were only length-checked
— an asymmetry that was harmless while nothing rendered them, and is not once
they become <img src>: a client could point them at a host it controls and
harvest the IP of everyone opening that profile.
The level meter only measured a stream a call had already opened, so settings
offered no way to check a mic before joining — the panel said as much.
Add startMicTest/stopMicTest on AudioManager: the processed input bus is
routed to the master output through a dedicated gain node, so the loopback can
be disconnected precisely. Settings had deliberately never opened the mic
itself; a mic test cannot honour that, so the test hands the mic back when it
stops.
Releasing needs two independent guards, because the user may join a call
mid-test: AudioManager only stops the exact stream it opened (identity check,
not a flag), and the caller must consent — the UI reads the call state, which
AudioManager cannot, as it does not import stores. Unmounting mid-test tears
the loopback down too.
The profile popout already existed and was reachable from eleven places —
messages, mentions, avatars, member list, DMs, activity panel — but no voice
surface opened it, so clicking someone during a call did nothing.
Wire it into the voice user rows (VoiceChannel's sidebar list) and the name
label on grid tiles, whose avatar was already a ProfileAvatar; the name beside
it not reacting read as the click failing.
Left mobile alone deliberately: MobileSpacesScreen already opens the profile
from its row wrapper, and MobileVoiceJoinSheet would layer a history-pushed
full-screen profile inside a bottom sheet, which cannot be verified here.
The composer's GIF button drew a filled rounded rect with the letters knocked
out, which reads as a solid square rather than a picker. Invert it: stroked
outline with filled letters, reusing the original glyph paths scaled to centre.
Banners already accept absolute URLs on both ends (server isValidAssetUrl
allows http(s); the profile render branches on banner.startsWith('http')), so
the picker stores the remote URL directly with no upload path. Previews can now
hold either a blob: or an https: URL, so revoking is guarded — calling
revokeObjectURL on a remote URL is a silent no-op that would hide a mistake.
The channel name under 'Voice Connected' was a plain div. Making it navigate
needed more than an onClick: voiceStore never recorded which space the call
was in, and spaceStore.channels only holds the space currently being viewed —
so after navigating away the call's channel was unresolvable, which is also
why the label degraded to a generic 'Voice Channel'.
Capture space and channel name at join time (the only moment they are
reliable) and use them for both the label and the jump. Covers space calls
and DM calls.
better-sqlite3@12.11.1 ships prebuilt binaries for ABI 127/137/141/147 only;
Node 20 is ABI 115, so prebuild-install falls back to node-gyp, which fails on
node:20-slim for lack of python3/make/g++.
Add the toolchain to the builder stage, and in the runtime stage install, use
and purge it inside a single layer so the final image ships no compiler.
The CLA Assistant only records a signature when the exact phrase is posted as a comment on the PR, so ticking the checkbox alone left the check red with no explanation (see #39). Spell the required comment out next to the checkbox, using the exact phrase from custom-pr-sign-comment in .github/workflows/cla.yml.
Avatar opened the profile popout whenever it received a user prop. Since user is how every avatar gets its gradient, colour and status dot, all 22 call sites became profile triggers by accident — including the picture inside the profile card itself, which re-anchored the card to that picture on every click and walked it across the screen (120px right, 36px down, until it pinned at the viewport clamp).
Avatar is now presentational. A new ProfileAvatar carries the open-the-profile behaviour at the five call sites that actually want it. The card's own picture escalates to the full profile modal instead of reopening the card.
The card also places itself off its measured size via the shared computeFloatingPosition engine, replacing six call sites that each hand-computed coordinates against a guessed 460px card height.
Closes#37
The CLA Assistant appends each signature as a direct commit to the branch named in 'branch:'. That was main, which the 'Require CI on main' ruleset rejects ('Repository rule violations found'), so signatures were never recorded and the check stayed red however often a contributor signed — a deadlock for every outside contribution.
Point the store at the cla-signatures branch. The ruleset targets the default branch only, so the bot can append there without granting any actor a bypass on main. That branch is seeded with the existing signature and carries its own ruleset blocking deletion and non-fast-forward pushes.
better-sqlite3 11.x removes its environment cleanup hook from Statement::~Statement() after the Node environment is torn down. Node 24.19.0 asserts on the null environment and aborts the worker, so vitest reported 'Worker exited unexpectedly' and exited non-zero on a fully green test run — failing the required check on every PR.
11.x also ships no prebuild for Node 24, so CI compiled it from source on every run. 12.x has prebuilds for that ABI and the V8 13.9 shims 11.x lacks. drizzle-orm declares better-sqlite3 >=7, so the major bump is in range.
Supersedes #26.
Completes the design record on main: Plan B's plan and the federation spec were
already here; this adds the umbrella security spec (source of truth for the
remaining container/web/desktop/remediation workstreams) and Plan A's plan.
Final whole-branch review (opus) fixes:
- docker-publish.yml: upload-sarif was if:always() but not continue-on-error, so a
Trivy SARIF-emit flake would fail the job and SKIP the multi-arch publish. Made it
non-blocking so a scanner hiccup never blocks a release.
- deployment.md: seed-admin-rotated.txt is root-owned (written via docker exec, which
bypasses the gosu drop) — reverted an over-correction. Corrected the canonical
runtime-stage build description (no toolchain; non-root gosu). First-boot chown note.
- restore.sh: comment ownership root -> uid 1000.
v0.28.0's composite action referenced a nested aquasecurity/setup-trivy@v0.2.1
tag that no longer exists, so the action failed to RESOLVE during job setup
(before any step ran) — continue-on-error can't catch a resolution failure, so
both Trivy jobs went red on every run. v0.36.0 pins setup-trivy to a real SHA
(v0.2.6) and still supports scan-type/scan-ref/scanners/format/output.
- OSV-Scanner ref was google/osv-scanner-action@<sha> (metadata-only root
action, no runs:) -> subpath google/osv-scanner-action/osv-scanner-action
which carries the docker action + scan-args input. Root ref would fail to
load and redden the job on every run (caught in final whole-branch review).
- security-scanning.md: note gitleaks findings land in job log (not SARIF);
add scorecard branch_protection_rule trigger; mark SBOM/provenance as not-
yet-live. CLAUDE.md row: image scan is a later plan, not current.
Makes local dev work on Windows: cross-env for the server dev port, pnpm --parallel to run server+web together (replacing the POSIX-only '&'), PowerShell setup docs, engines widened to Node >=20, and a Node 20 + 24 CI matrix.
CI keeps a stable required 'Build & test' status via an aggregate gate job so the matrix rename doesn't drop the context the main ruleset requires.
Co-authored-by: BadAtCaptchas <2359196+BadAtCaptchas@users.noreply.github.com>
Co-authored-by: Jannis Braun <151788261+TheZwiss@users.noreply.github.com>
Non-members already can't mint invite codes (permissions resolve to zero for them since 85e1975f), but hasPermission reports it as a missing CREATE_INVITE permission, which is misleading. Return 'Space membership required' for the non-member case instead.
Message wording from #12 by BadAtCaptchas.
Co-authored-by: BadAtCaptchas <2359196+BadAtCaptchas@users.noreply.github.com>
Six S2S-HMAC endpoints repeated the same inbound-auth preamble verbatim
(parse federation headers -> resolve active peer -> optional per-peer rate
limit -> verify HMAC signature -> nonce replay protection). Extract it into
authenticateS2SPeer() so the trust boundary has a single, tested definition.
Adopters (preamble only; every post-auth side effect, body validation, and
response is unchanged):
- DELETE /api/federation/identity (no rate limiter; warns on missing nonce)
- POST /api/federation/relay (relay limiter; warns; keeps in-handler
epoch-baseline populate + nonce ratchet)
- POST /api/federation/sync (no limiter; warns with the [sync] tag;
keeps in-handler nonce ratchet)
- POST /api/federation/users/lookup (lookup limiter, Retry-After 60)
- POST /api/federation/users/by-home-id (same)
- POST /api/federation/verify-attach-proof(shares lookup bucket, Retry-After 60)
Deliberate non-adopters, each keeping a load-bearing gate the helper would
flatten (documented at each site + in the helper docstring):
- POST /api/federation/epoch gates status != 'revoked' (peer recovery),
400 on missing headers, no nonce check
- POST /api/federation/peer/rotate active-only but no nonce check
- POST /api/federation/peer/denied awaiting_approval gate (404/409), synthetic
no-grace secret verify
Behavior-preserving. The rate limiter is injected (plain { limited, retryAfter }),
so the limit still fires BEFORE signature verification. The only ordering change:
/relay's opportunistic epoch-baseline populate now runs just after the shared
preamble (i.e. after the nonce check) instead of between signature and nonce.
This is provably equivalent for every reachable honest-peer state (a duplicate
nonce means the baseline is already non-null; a valid-signature-but-no-nonce
request from a nonce-supporting peer is unreachable in transit and carries no
security/correctness consequence) and the populate is documented as not
affecting relay accept/reject.
Adds a dedicated unit test covering the full decision table (headers, peer
status, rate-limit + Retry-After, rate-limit-before-signature ordering,
signature, nonce duplicate/missing, log flag + context suffix, success). Full
server suite green (804 tests).
Phase C cleanup follow-up to the routes/federation split (#9). Behavior-
preserving; full server suite (790 tests) green.
A) rateLimits.ts: the four near-identical sliding-window limiters
(accept/relay/lookup/ensure) and their duplicated prune loops collapse
into one createLimiter(windowMs, max) factory. Per-call and periodic-
sweep semantics are preserved exactly, including that lookup buckets are
pruned per-call but never swept (unchanged from before). 177 -> 101 lines.
B) Extract sendSignedJson(reply, payload, hmacSecret) — the single
definition of how this instance signs an S2S JSON response — and use it
in the /epoch and /verify-attach-proof|reattach handlers, replacing two
copies of the build-headers-and-send boilerplate.
routes/federation.ts had grown to 7.6k lines, spanning HTTP route
registration, federated identity resolution, ~30 inbound relay event
processors, DM reconciliation, and rate-limiting internals — too large
to review or hold in context, and awkward to change safely.
Split the implementation into 18 focused modules under routes/federation/
(helpers, events/, handlers/) and keep routes/federation.ts as a thin
barrel that re-exports the public API and composes the HTTP registrars
into federationRoutes(). No import paths change anywhere else.
Pure move, no behavior change:
- 61/61 named functions byte-identical; only deltas are 2 dynamic-import
paths adjusted for the new directory depth
- public export surface unchanged (barrel re-exports all 22 symbols)
- all 30 endpoints preserved (identical verb+path set)
- typecheck, build, and full server suite (790 tests) green
Docs: update federation.md source-file map; add split design doc.
GitHub is deprecating the Node 20 runtime for JS actions; every run printed a
warning that actions/checkout@v4, actions/setup-node@v4 and pnpm/action-setup@v4
were being force-run on Node 24. Bump each to its first Node 24 major (v5) across
all workflows — the smallest jump that clears the warning, avoiding the extra
behavior changes in checkout v6/v7 (credential persistence, fork-PR blocking)
that don't apply here. Our checkout jobs use push/pull_request, not
pull_request_target/workflow_run, so none are affected regardless.
Also bump the GitHub Pages actions in deploy-pages.yml (configure-pages v5->v6,
upload-pages-artifact v3->v5, deploy-pages v4->v5), which were likewise on Node
20. Inputs are unchanged; pnpm still pinned to 10.34.3 via the version input and
the packageManager field.
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.
* 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>
* 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'.
deploy.sh now sources ./.deploy.local at runtime if it exists, letting a
maintainer point the script at their own hosts without committing them — the
tracked defaults stay placeholders. .deploy.local and .deploy-local/ are
gitignored so real hostnames/IPs never land in the repo.
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)