diff --git a/.env.example b/.env.example
index d11eb10c..535046dd 100644
--- a/.env.example
+++ b/.env.example
@@ -5,6 +5,8 @@
DOMAIN=example.com
# ─── Server ─────────────────────────────────────────────────
+# Production/Docker listen port (Caddy reverse-proxies to it). Local development
+# ignores this and uses 3005 — the Vite dev proxy target — set by `pnpm dev`.
PORT=3000
HOST=0.0.0.0
diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml
new file mode 100644
index 00000000..d17158e2
--- /dev/null
+++ b/.github/workflows/cla.yml
@@ -0,0 +1,51 @@
+name: CLA Assistant
+
+# Requires every contributor to sign the project Contributor License Agreement
+# (CLA.md) before their pull request can be merged. Signatures are stored in
+# this repository at signatures/cla.json — no external service or database is
+# used. A contributor signs by commenting the exact sentence configured below.
+
+on:
+ issue_comment:
+ types: [created]
+ pull_request_target:
+ types: [opened, closed, synchronize]
+
+# `contents: write` lets the action commit the signatures file back to the repo.
+permissions:
+ actions: write
+ contents: write
+ pull-requests: write
+ statuses: write
+
+jobs:
+ cla-assistant:
+ runs-on: ubuntu-latest
+ # Skip CLA enforcement on bot-authored PRs (e.g. Dependabot) and on the
+ # maintainer's own PRs via the allowlist below.
+ steps:
+ - name: CLA Assistant
+ if: |
+ (github.event.comment.body == 'recheck' ||
+ github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') ||
+ github.event_name == 'pull_request_target'
+ uses: contributor-assistant/github-action@v2.6.1
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ with:
+ # Where signatures are recorded (committed to this repo on `branch`).
+ path-to-signatures: 'signatures/cla.json'
+ # The CLA document contributors are agreeing to.
+ path-to-document: 'https://github.com/TheZwiss/backspace/blob/main/CLA.md'
+ branch: 'main'
+ # Accounts that never need to sign (maintainer + automation).
+ allowlist: 'TheZwiss,dependabot[bot],github-actions[bot]'
+ # The exact phrase a contributor comments to sign.
+ custom-allsigned-prcomment: 'All contributors have signed the CLA. Thank you!'
+ custom-pr-sign-comment: 'I have read the CLA Document and I hereby sign the CLA'
+ custom-notsigned-prcomment: >
+ Thank you for your contribution! Before we can merge it, we need you
+ to sign our [Contributor License Agreement](https://github.com/TheZwiss/backspace/blob/main/CLA.md).
+ You keep the copyright to your work — this just grants the maintainer
+ the rights needed to use and relicense the project. To sign, post a
+ comment below with **exactly** this sentence:
diff --git a/.gitignore b/.gitignore
index 32d0649e..66ce5ebb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,10 +17,19 @@ data/
*.db
*.db-journal
-# Environment
+# Environment — ignore every .env variant except the tracked example
.env
-.env.local
-.env.*.local
+.env.*
+!.env.example
+
+# Credentials / private keys (defense-in-depth; none should ever be committed)
+*.pem
+*.key
+*.p12
+*.pfx
+*.crt
+id_rsa*
+id_ed25519*
# Generated deployment config (contains secrets)
livekit.yaml
@@ -28,10 +37,17 @@ livekit.yaml
# OS files
.DS_Store
Thumbs.db
+ehthumbs.db
+Desktop.ini
+._*
*.swp
*.swo
*~
+# Internal process docs / scratch (never published; see scripts/prepare-public-release.sh)
+*.rtf
+*.rtfd/
+
# IDE
.idea/
.vscode/
diff --git a/CLA.md b/CLA.md
new file mode 100644
index 00000000..17109ca6
--- /dev/null
+++ b/CLA.md
@@ -0,0 +1,111 @@
+# Backspace Contributor License Agreement
+
+Thank you for your interest in contributing to Backspace ("the Project"), owned
+and maintained by **Jannis Braun** ("the Maintainer", "I", "me", or "my").
+
+This Contributor License Agreement ("Agreement") clarifies the intellectual
+property rights granted with Contributions from any person or entity ("You")
+to the Maintainer. It protects You, the Maintainer, and the Project's users; it
+does **not** change Your right to use Your own Contributions for any other
+purpose.
+
+By submitting a Contribution to the Project (for example, by opening a pull
+request) and by signing this Agreement through the Project's CLA bot, You accept
+and agree to the following terms for all past and future Contributions You
+submit.
+
+## 1. Definitions
+
+**"Contribution"** means any original work of authorship, including any
+modification of or addition to an existing work, that You intentionally submit
+to the Project in any form — including source code, object code, documentation,
+configuration, designs, or assets — through any means, including pull requests,
+patches, issues with attached code, or electronic communication, but excluding
+any communication You conspicuously mark in writing as "Not a Contribution".
+
+**"Submit"** means any act by which a Contribution is transferred to the Project,
+including by version control systems, issue trackers, mailing lists, or
+electronic messages, but excluding communication marked "Not a Contribution".
+
+## 2. Copyright License and Right to Relicense
+
+You retain all right, title, and interest in and to Your Contributions. You do
+**not** assign Your copyright to the Maintainer.
+
+Instead, You grant to the Maintainer, and to recipients of software distributed
+by the Maintainer, a perpetual, worldwide, non-exclusive, royalty-free,
+irrevocable license to reproduce, prepare derivative works of, publicly display,
+publicly perform, sublicense, and distribute Your Contributions and such
+derivative works.
+
+You further grant the Maintainer the right to **license and relicense Your
+Contributions, and the Project as a whole, under any license terms the
+Maintainer chooses** — including the Elastic License 2.0, other open-source or
+source-available licenses, and proprietary or commercial licenses — now and in
+the future, without further notice to or consent from You. This right exists so
+the Maintainer can sustain, evolve, and commercially license the Project while
+keeping it source-available to the community.
+
+## 3. Patent License
+
+You grant to the Maintainer and to recipients of software distributed by the
+Maintainer a perpetual, worldwide, non-exclusive, royalty-free, irrevocable
+(except as stated in this section) patent license to make, have made, use, offer
+to sell, sell, import, and otherwise transfer Your Contributions, where such
+license applies only to those patent claims licensable by You that are
+necessarily infringed by Your Contribution alone or by combination of Your
+Contribution with the Project to which it was Submitted.
+
+If any entity institutes patent litigation against You or any other entity
+(including a cross-claim or counterclaim in a lawsuit) alleging that Your
+Contribution, or the Project to which You contributed, constitutes direct or
+contributory patent infringement, then any patent licenses granted to that
+entity under this Agreement for that Contribution or Project terminate as of the
+date such litigation is filed.
+
+## 4. Your Representations
+
+You represent that:
+
+1. Each of Your Contributions is Your original creation, or You have sufficient
+ rights to grant the licenses described in this Agreement.
+2. You are legally entitled to grant the above licenses. If Your employer has
+ rights to intellectual property You create, You represent that You have
+ received permission to make the Contributions on behalf of that employer, or
+ that Your employer has waived such rights for Your Contributions.
+3. Each of Your Contributions does not, to the best of Your knowledge, violate
+ any third party's copyrights, trademarks, patents, or other intellectual
+ property rights.
+4. If any Contribution includes or depends on third-party work, You will clearly
+ identify it, along with any associated license or other restriction of which
+ You are personally aware, in the Contribution itself or in the accompanying
+ pull request.
+
+## 5. No Obligation
+
+You acknowledge that the Maintainer is under no obligation to use, merge, or
+incorporate any Contribution. The decision to include any Contribution in the
+Project rests solely with the Maintainer.
+
+## 6. Disclaimer
+
+Except for the representations in Section 4, Your Contributions are provided "as
+is", without warranty of any kind, express or implied, including without
+limitation any warranties of merchantability, fitness for a particular purpose,
+title, or non-infringement.
+
+## 7. Entity Contributions
+
+If You are signing on behalf of a legal entity, You represent that You have the
+authority to bind that entity and its affiliates to this Agreement, and the
+terms "You" and "Your" refer to that entity and its affiliates.
+
+---
+
+To sign this Agreement, comment on your pull request with **exactly** the
+following sentence:
+
+> I have read the CLA Document and I hereby sign the CLA
+
+Your signature is recorded against your GitHub username and applies to all of
+your current and future Contributions to the Project.
diff --git a/CLAUDE.md b/CLAUDE.md
index 0f76413d..337243e3 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -90,7 +90,7 @@ No resting border — sunken `surface-input` background provides differentiation
| Voice | LiveKit (livekit-client + livekit-server-sdk), RNNoise |
| Media | sharp (thumbnails), Cheerio (URL metadata), react-easy-crop |
| Chat | react-markdown + remark-gfm, prism-react-renderer, emoji-mart |
-| Desktop | Electron 33, electron-updater, uiohook-napi |
+| Desktop | Electron 40, electron-updater, uiohook-napi |
| Testing | Vitest, @testing-library/react |
**Do not introduce new dependencies without justification.**
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 00000000..77614f64
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,91 @@
+# Contributing to Backspace
+
+Thanks for considering a contribution! Backspace is an open, source-available
+project and contributions of all sizes are welcome — bug reports, fixes,
+features, documentation, and design.
+
+## Before you start
+
+- **Read the architecture docs.** The `docs/systems/` directory documents every
+ subsystem (database, API, WebSocket protocol, federation, permissions, voice,
+ design system, and more). Read the relevant spec before changing a subsystem,
+ and update it in the same pull request if your change is structural.
+- **Open an issue first for anything non-trivial.** It saves you from building
+ something that conflicts with planned direction. Small fixes can go straight
+ to a pull request.
+- **One logical change per pull request.** Keep diffs focused and reviewable.
+
+## Contributor License Agreement (required)
+
+Before your first contribution can be merged, you must sign the project's
+[Contributor License Agreement](CLA.md).
+
+The CLA confirms that you have the right to contribute your code and grants the
+maintainer (Jannis Braun) a broad, perpetual license to use and relicense your
+contribution — including the right to license the project commercially. **You
+keep the copyright to your own work**; you are simply granting these rights, not
+giving your code away.
+
+Signing is automatic and takes one comment:
+
+1. Open your pull request.
+2. The CLA bot will comment with a link to the agreement and ask you to sign.
+3. Reply on the pull request with exactly:
+
+ > I have read the CLA Document and I hereby sign the CLA
+
+4. The bot records your signature against your GitHub username. You only sign
+ once — it covers all of your future contributions.
+
+## Development setup
+
+Requirements: **Node.js 20+** and **pnpm 8+**.
+
+```bash
+pnpm install # install all workspace dependencies
+cp .env.example .env # then set JWT_SECRET (openssl rand -hex 32)
+pnpm dev # API server on :3005, Vite dev server on :5173
+```
+
+You can run the two halves separately with `pnpm dev:server` and `pnpm dev:web`.
+
+Voice and video are optional and require a LiveKit server; see the README for
+configuration. Text, federation, uploads, and everything else run fully without
+it.
+
+## Coding standards
+
+- **TypeScript strict mode**, no `any`. The codebase compiles cleanly under
+ strict settings — keep it that way.
+- **Match the surrounding code.** Follow existing patterns, naming, and module
+ boundaries rather than introducing new ones.
+- **Federation-aware.** Never assume a single global user ID. Resolve the
+ correct federated identity for the relevant instance when comparing IDs,
+ checking permissions, or talking to remote servers. See
+ `docs/systems/federation.md` and `docs/systems/client-federation.md`.
+- **Design system.** UI work follows the "Aether Drift" design system documented
+ in `docs/systems/design-system.md`.
+- **No new dependencies without justification.** Prefer the existing stack.
+- **Complete implementations only.** No placeholder code, no `TODO` stubs, no
+ partial components. Handle the error and edge cases.
+
+## Before you open a pull request
+
+- `pnpm build` succeeds (shared types, server, and web all build).
+- The dev server and web client both start without errors (`pnpm dev`).
+- Tests pass (`pnpm test` where applicable to the package you touched).
+- You updated the relevant `docs/systems/` spec if your change altered schema,
+ API routes, WebSocket events, the federation protocol, permissions, or the
+ design system.
+
+## Reporting bugs and requesting features
+
+Use GitHub Issues. For bugs, include reproduction steps, expected vs. actual
+behavior, and your environment (deployment method, browser/desktop, and whether
+federation or voice is involved). For security issues, please do **not** open a
+public issue — see the README's security section.
+
+## License
+
+By contributing, you agree that your contributions are licensed under the
+[Elastic License 2.0](LICENSE) and are subject to the [CLA](CLA.md).
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 00000000..74679acd
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,101 @@
+Elastic License 2.0
+
+URL: https://www.elastic.co/licensing/elastic-license
+
+## Acceptance
+
+By using the software, you agree to all of the terms and conditions below.
+
+## Copyright License
+
+The licensor grants you a non-exclusive, royalty-free, worldwide,
+non-sublicensable, non-transferable license to use, copy, distribute, make
+available, and prepare derivative works of the software, in each case subject to
+the limitations and conditions below.
+
+## Limitations
+
+You may not provide the software to third parties as a hosted or managed
+service, where the service provides users with access to any substantial set of
+the features or functionality of the software.
+
+You may not move, change, disable, or circumvent the license key functionality
+in the software, and you may not remove or obscure any functionality in the
+software that is protected by the license key.
+
+You may not alter, remove, or obscure any licensing, copyright, or other notices
+of the licensor in the software. Any use of the licensor's trademarks is subject
+to applicable law.
+
+## Patents
+
+The licensor grants you a license, under any patent claims the licensor can
+license, or becomes able to license, to make, have made, use, sell, offer for
+sale, import and have imported the software, in each case subject to the
+limitations and conditions in this license. This license does not cover any
+patent claims that you cause to be infringed by modifications or additions to
+the software. If you or your company make any written claim that the software
+infringes or contributes to infringement of any patent, your patent license for
+the software granted under these terms ends immediately. If your company makes
+such a claim, your patent license ends immediately for work on behalf of your
+company.
+
+## Notices
+
+You must ensure that anyone who gets a copy of any part of the software from you
+also gets a copy of these terms.
+
+If you modify the software, you must include in any modified copies of the
+software prominent notices stating that you have modified the software.
+
+## No Other Rights
+
+These terms do not imply any licenses other than those expressly granted in
+these terms.
+
+## Termination
+
+If you use the software in violation of these terms, such use is not licensed,
+and your licenses will automatically terminate. If the licensor provides you
+with a notice of your violation, and you cease all violation of this license no
+later than 30 days after you receive that notice, your licenses will be
+reinstated retroactively. However, if you violate these terms after such
+reinstatement, any additional violation of these terms will cause your licenses
+to terminate automatically and permanently.
+
+## No Liability
+
+*As far as the law allows, the software comes as is, without any warranty or
+condition, and the licensor will not be liable to you for any damages arising
+out of these terms or the use or nature of the software, under any kind of
+legal claim.*
+
+## Definitions
+
+The **licensor** is the entity offering these terms, and the **software** is the
+software the licensor makes available under these terms, including any portion
+of it.
+
+**you** refers to the individual or entity agreeing to these terms.
+
+**your company** is any legal entity, sole proprietorship, or other kind of
+organization that you work for, plus all organizations that have control over,
+are under the control of, or are under common control with that
+organization. **control** means ownership of substantially all the assets of an
+entity, or the power to direct its management and policies by vote, contract, or
+otherwise. Control can be direct or indirect.
+
+**your licenses** are all the licenses granted to you for the software under
+these terms.
+
+**use** means anything you do with the software requiring one of your licenses.
+
+**trademark** means trademarks, service marks, and similar rights.
+
+---
+
+Copyright (c) 2026 Jannis Braun. All rights reserved.
+
+"Backspace" and the Backspace logo are trademarks of Jannis Braun. The Elastic
+License 2.0 grants no rights to use these trademarks. See the NOTICE file for
+third-party components bundled with this software and their respective licenses.
diff --git a/NOTICE b/NOTICE
new file mode 100644
index 00000000..4074a172
--- /dev/null
+++ b/NOTICE
@@ -0,0 +1,61 @@
+Backspace
+Copyright (c) 2026 Jannis Braun
+
+This product is licensed under the Elastic License 2.0 (see the LICENSE file).
+
+It bundles, depends on, or redistributes the third-party components listed
+below. Each remains under its own license.
+
+================================================================================
+Fonts
+================================================================================
+
+DM Sans
+ Files: packages/web/public/fonts/DMSans-Variable.woff2
+ packages/web/public/fonts/DMSans-Variable-Italic.woff2
+ License: SIL Open Font License, Version 1.1
+ Notice: Copyright 2014 The DM Sans Project Authors
+ (https://github.com/googlefonts/dm-fonts)
+ The full license text is shipped alongside the font files at
+ packages/web/public/fonts/OFL.txt.
+
+================================================================================
+Sound effects
+================================================================================
+
+In-app sound cues (packages/web/public/sounds/*.ogg) are original works
+authored for the Backspace project and are covered by the project license.
+
+================================================================================
+Icons and brand assets
+================================================================================
+
+The Backspace name, logos, marks, and app icons (assets/brand/,
+packages/web/public/icons/, packages/desktop build resources) are original
+works and trademarks of the project. The Elastic License 2.0 grants no right to
+use these trademarks; trademark use is governed separately by applicable law.
+
+In-application UI icons are hand-authored inline SVGs and are covered by the
+project license.
+
+================================================================================
+npm dependencies
+================================================================================
+
+Backspace depends on open-source packages distributed via npm, all under
+permissive licenses (predominantly MIT, ISC, Apache-2.0, and BSD variants).
+The image-processing dependency "sharp" loads libvips, distributed as a
+prebuilt binary under the LGPL-3.0-or-later license; it is used unmodified and
+dynamically, and is not statically linked into Backspace.
+
+To regenerate the full, current dependency license inventory, run:
+
+ pnpm licenses list --prod
+
+================================================================================
+Emoji
+================================================================================
+
+Emoji selection is provided by @emoji-mart/data and @emoji-mart/react (MIT).
+No emoji glyph images are bundled; glyphs render using the host operating
+system's emoji font.
diff --git a/README.md b/README.md
index cd608f11..99094c7c 100644
--- a/README.md
+++ b/README.md
@@ -1,147 +1,303 @@
+
+
+

+
# Backspace
-Open-source, self-hosted Discord alternative built with TypeScript.
+**An open, self-hosted communication platform — text, voice, video, and federation — that you own.**
+
+[](LICENSE)
+[](https://www.typescriptlang.org/)
+[](https://nodejs.org/)
+[](#project-status)
+
+
+
+---
+
+Backspace is a Discord-style chat platform you run on your own hardware. Spaces,
+channels, roles, voice and video, screen sharing, direct messages, friends, file
+sharing, and full-text search — plus **server-to-server federation**, so
+independent Backspace instances can talk to each other while each stays under
+its own control.
+
+It is **source-available**: free to self-host, modify, and use — including
+inside a business — but not to resell as a hosted service. See
+[License](#license) for the exact terms.
+
+> **Project status**
+> Backspace is in active development and runs on live test instances, but has not
+> had a tagged public release yet. Expect rough edges, and pin to a specific
+> commit if you deploy it.
+
+## Screenshots
+
+
+
+_Screenshots coming soon._
## Features
-- Real-time text messaging with WebSocket
-- Servers, channels, and role-based permission management
-- Voice and video chat via LiveKit
-- Screen sharing with configurable quality (VP9)
-- Direct messages (1-on-1 and group DMs up to 10)
-- DM voice/video calls with ringing
-- Friend system with requests
-- File uploads and image sharing
-- Markdown message formatting with syntax highlighting
-- Message reactions, replies, and editing
-- Typing indicators, presence status, and read states
-- Invite system with shareable codes
-- Instance-level admin panel (streaming limits)
-- Desktop app (Electron)
+### Communication
+- Real-time text channels over WebSocket
+- Voice and video channels via [LiveKit](https://livekit.io/)
+- Screen sharing with configurable quality (VP9, up to 4K/120fps depending on instance limits)
+- Direct messages — 1-on-1 and group DMs (up to 10 people)
+- DM voice/video calls with ring / accept / reject
+- Message reactions, replies, editing, and deletion
+- Markdown formatting with syntax highlighting
+- Typing indicators, read states, and presence
+- Rich link embeds (YouTube, Vimeo, Spotify, and generic OpenGraph) with SSRF-protected scraping
+- GIF search (Klipy)
+
+### Organization
+- Spaces with channel categories and folders
+- Role-based permissions — bitwise RBAC with category- and channel-level overrides
+- Customizable user sidebar layout
+- Space discovery (public, request-to-join, and private)
+- Shareable invite codes
+
+### Social
+- Friend requests and friendships
+- User search and discovery
+- Mutual friends and mutual spaces
+- User profiles with banner, bio, and accent color
+
+### Moderation
+- Bans with reason and audit trail
+- Voice restrictions (space-level mute/deafen, persisted)
+- Member move and force-disconnect
+- Join-request approval for gated spaces
+
+### Federation
+- Multi-instance peering with HMAC-signed server-to-server requests
+- Federated identity resolution (`username@instance`)
+- Cross-instance DMs — messages, reactions, and membership relay
+- Cross-instance friends and presence
+- File replication with size validation
+- Background workers for outbox delivery, file download, peer health, and cleanup
+
+### Platform
+- File uploads with image thumbnails (via `sharp`)
+- Full-text search with `from:`, `has:`, `before:`, and `after:` filters, plus jump-to-message
+- Admin panel — user management, storage management, streaming/quality config, instance settings
+- Automatic SQLite backups (pre-migration, scheduled, and manual) with restore tooling
+- Electron desktop app (Windows, macOS, Linux) with global keybinds and activity detection
- Mobile-responsive web UI
-- Docker deployment
+- Account management — password change and account deletion with safeguards
-## Tech Stack
+## Quick Start
-| Layer | Technology |
-|-------|-----------|
-| Backend | Fastify + TypeScript |
-| Database | SQLite (better-sqlite3) + Drizzle ORM |
-| Auth | JWT + bcrypt |
-| Real-time | WebSocket (ws) |
-| Frontend | React 18 + Tailwind CSS + Zustand |
-| Voice/Video | LiveKit |
-| Desktop | Electron |
-| Build | Vite + pnpm workspaces |
-
-## Quick Start with Docker
+The fastest path for a real deployment is the interactive installer, which
+generates your `.env`, configures HTTPS, and optionally enables voice.
```bash
-# Clone the repository
-git clone https://github.com/your-username/backspace.git
+git clone https://github.com/TheZwiss/backspace.git
+cd backspace
+./install.sh
+```
+
+The installer asks for your domain, generates a secure `JWT_SECRET`, and brings
+the stack up with Docker. When it finishes, open `https://your-domain` and
+**create the first account — it automatically becomes the instance admin.**
+There is no default username or password.
+
+### Manual Docker deployment
+
+If you'd rather configure it yourself:
+
+```bash
+git clone https://github.com/TheZwiss/backspace.git
cd backspace
-# Create environment file
cp .env.example .env
-
-# Generate a JWT secret
+# Set DOMAIN, and generate a secret:
echo "JWT_SECRET=$(openssl rand -hex 32)" >> .env
-# Start Backspace
docker compose up -d
```
-Open `http://localhost:3000` in your browser. A default server "Backspace" is created automatically.
+The stack runs three services via Docker Compose:
-**Default admin account:** `admin` / `admin123` (change this after first login).
+| Service | Role |
+|-------------|---------------------------------------------------|
+| `backspace` | The app (API + WebSocket + built web client) on internal port `3000` |
+| `caddy` | Reverse proxy with automatic HTTPS for your `DOMAIN` (ports `80`/`443`) |
+| `livekit` | Voice/video server — optional, enabled with `COMPOSE_PROFILES=voice` |
-## Development Setup
+Point your domain's DNS at the host and open ports `80`/`443`. Caddy obtains a
+TLS certificate automatically. The first account you register becomes admin.
-### Prerequisites
+### Backups & restore
-- Node.js 20+
-- pnpm 8+
+The app takes automatic SQLite snapshots (before every migration, on a schedule,
+and on demand via `./backup.sh`). Restore from a snapshot with `./restore.sh`.
+See [`docs/systems/deployment.md`](docs/systems/deployment.md) for the full
+backup/restore and image-pinning guide.
-### Install
+## Development
+
+Requirements: **Node.js 20+** and **pnpm 8+**.
```bash
pnpm install
+cp .env.example .env # set JWT_SECRET (openssl rand -hex 32)
+pnpm dev # API server on :3005, Vite dev server on :5173
```
-### Configure
+Run the halves separately if you prefer:
```bash
-cp .env.example .env
-# Edit .env with your settings (generate a JWT_SECRET)
+pnpm dev:server # API + WebSocket on :3005
+pnpm dev:web # Vite dev server on :5173
```
-### Run
-
-```bash
-# Start both server and web dev server
-pnpm dev
-
-# Or start individually
-pnpm dev:server # API server on :3005
-pnpm dev:web # Vite dev server on :5173
-```
-
-### Build
+Build everything for production (shared types → server → web):
```bash
pnpm build
```
-This builds the shared types, server, and web frontend. The server serves the built frontend in production mode.
+In production the server serves the built web client directly.
-## Project Structure
+## Configuration
-```
-Backspace/
-├── packages/
-│ ├── shared/ # Shared TypeScript types & permissions
-│ ├── server/ # Fastify API + WebSocket server
-│ ├── web/ # React frontend (Vite + Tailwind)
-│ └── desktop/ # Electron desktop app
-├── data/ # SQLite DB + uploads (created at runtime)
-├── Dockerfile
-├── docker-compose.yml
-└── .env.example
-```
+All configuration is via environment variables (see [`.env.example`](.env.example)).
+The most important:
+
+| Variable | Required | Default | Description |
+|----------------------|----------|-------------|-------------|
+| `DOMAIN` | yes | — | Public domain name of your instance |
+| `JWT_SECRET` | yes | — | Auth signing secret, **min 32 chars** (`openssl rand -hex 32`) |
+| `PORT` | no | `3000` | App listen port (behind Caddy in Docker) |
+| `HOST` | no | `0.0.0.0` | Bind address |
+| `REGISTRATION_OPEN` | no | `true` | Set `false` to close signups after setup |
+| `MAX_UPLOAD_SIZE` | no | `104857600` | Max upload size in bytes (100 MB) |
+| `LIVEKIT_URL` / `LIVEKIT_API_KEY` / `LIVEKIT_API_SECRET` | no | — | Enable voice/video |
+| `COMPOSE_PROFILES` | no | — | Set to `voice` to start the bundled LiveKit service |
## Voice & Video
-Voice and video requires a [LiveKit](https://livekit.io/) server. Set these in your `.env`:
+Voice, video, and screen sharing require a [LiveKit](https://livekit.io/) server.
+The Docker Compose file bundles one — enable it by setting these in `.env`:
-```
-LIVEKIT_URL=wss://your-livekit-server
+```bash
+COMPOSE_PROFILES=voice
+LIVEKIT_URL=wss://your-domain
LIVEKIT_API_KEY=your-api-key
LIVEKIT_API_SECRET=your-api-secret
```
-Without LiveKit configured, text chat works fully but voice/video channels will not connect.
+Without LiveKit configured, everything else — text, federation, DMs, uploads,
+search — works fully; only voice/video channels won't connect.
-## API
+## Federation
-The server exposes a REST API and WebSocket endpoint:
-
-- **REST API**: `http://localhost:3000/api/*`
-- **WebSocket**: `ws://localhost:3000/ws`
-- **Health check**: `GET /api/health`
-
-See [CLAUDE.md](CLAUDE.md) for the full API reference.
+Backspace instances can peer with each other so users on different servers can
+become friends, DM, and call across instances, while each instance stays
+independently owned and operated. Peering is mutual and authenticated with
+HMAC-signed requests; identities are addressed as `username@instance`. Manage
+peers from the **Connections** panel in settings. The protocol is documented in
+[`docs/systems/federation.md`](docs/systems/federation.md) and
+[`docs/systems/client-federation.md`](docs/systems/client-federation.md).
## Desktop App
-The Electron desktop app wraps the web UI and adds system tray, notifications, and native window controls.
+The Electron desktop app wraps the web client and adds a system tray, native
+notifications, global keybinds, and activity detection.
```bash
cd packages/desktop
-pnpm build:ts # Compile TypeScript
-pnpm dev # Run in development
-pnpm build # Package for distribution
+pnpm build:ts # compile TypeScript
+pnpm dev # run in development
+pnpm build # package for distribution
```
+Cross-platform builds are produced for Windows, macOS, and Linux. See
+[`docs/systems/desktop.md`](docs/systems/desktop.md).
+
+## Architecture
+
+Backspace is a TypeScript monorepo managed with pnpm workspaces.
+
+```
+packages/
+ shared/ — Shared types, permission bits, constants
+ server/ — Fastify API + WebSocket server, Drizzle/SQLite, federation
+ web/ — React 18 SPA (Vite, Tailwind, Zustand)
+ desktop/ — Electron wrapper
+```
+
+| Layer | Technology |
+|--------------|------------|
+| Server | Node.js 20+, Fastify 4, TypeScript (strict) |
+| Database | SQLite (better-sqlite3) + Drizzle ORM |
+| Auth | JWT + bcrypt |
+| Frontend | React 18, Vite 6, Tailwind CSS 3, Zustand 5 |
+| Voice/Video | LiveKit |
+| Media | sharp (thumbnails), Cheerio (embeds) |
+| Desktop | Electron 40 |
+| Deployment | Docker Compose + Caddy (auto-HTTPS) |
+
+Every subsystem has a dedicated specification under
+[`docs/systems/`](docs/systems/) — database schema, REST API, WebSocket
+protocol, federation, permissions, voice, the design system, and more. **These
+are the reference for how Backspace works**; start there if you want to
+understand or extend a subsystem.
+
+## Contributing
+
+Contributions are welcome. Please read [`CONTRIBUTING.md`](CONTRIBUTING.md)
+first. All contributors sign a [Contributor License Agreement](CLA.md) — a
+one-time comment on your pull request, handled automatically by a bot. You keep
+copyright to your work; the CLA grants the maintainer the rights needed to use
+and relicense the project.
+
+## Security
+
+If you discover a security vulnerability, please **do not** open a public issue.
+Instead, report it privately to the maintainer via a GitHub security advisory on
+this repository, or by direct contact. We'll work with you on a fix and
+coordinated disclosure.
+
## License
-MIT
+Backspace is licensed under the **[Elastic License 2.0](LICENSE)**.
+
+In plain terms:
+
+- ✅ You may self-host, run, and use it — including commercially and inside a business.
+- ✅ You may read, modify, and redistribute the source.
+- ❌ You may **not** provide Backspace to third parties as a hosted or managed
+ service (i.e. you can't sell Backspace-as-a-service) without a separate
+ commercial license from the maintainer.
+- ❌ You may not remove or obscure the license and copyright notices.
+
+This makes Backspace **source-available**, not OSI "open source" — the only
+practical difference is the hosted-service restriction above. If you want to
+offer Backspace as a commercial service, contact the maintainer
+([@TheZwiss](https://github.com/TheZwiss)) about a commercial license.
+
+Bundled third-party components (the DM Sans font, etc.) retain their own
+licenses; see [`NOTICE`](NOTICE).
+
+"Backspace" and the Backspace logo are trademarks of Jannis Braun and are not
+covered by the code license.
+
+## Acknowledgements
+
+Built on the shoulders of [Fastify](https://fastify.dev/),
+[Drizzle ORM](https://orm.drizzle.team/), [React](https://react.dev/),
+[LiveKit](https://livekit.io/), [Tailwind CSS](https://tailwindcss.com/),
+[Electron](https://www.electronjs.org/), and the broader open-source ecosystem.
+The interface uses the [DM Sans](https://github.com/googlefonts/dm-fonts) font
+(SIL Open Font License 1.1).
diff --git a/docs/systems/auth.md b/docs/systems/auth.md
index 1eb9d44f..86dbad32 100644
--- a/docs/systems/auth.md
+++ b/docs/systems/auth.md
@@ -573,8 +573,8 @@ Called by `useAuth()` hook when token exists but user object is null:
Splits a potentially federated username:
```
-"youruser@nova.ddns.net" -> { baseName: "youruser", domain: "nova.ddns.net" }
-"youruser" -> { baseName: "youruser", domain: null }
+"erin@nova.ddns.net" -> { baseName: "erin", domain: "nova.ddns.net" }
+"erin" -> { baseName: "erin", domain: null }
```
### Self-ID Registry
diff --git a/docs/systems/client-federation.md b/docs/systems/client-federation.md
index f3fd626c..cb6e1ce5 100644
--- a/docs/systems/client-federation.md
+++ b/docs/systems/client-federation.md
@@ -62,9 +62,9 @@ This is a **real account with a real bcrypt password** — not a replicated stub
| Account type | Username | passwordHash | homeInstance | Can log in? |
|---|---|---|---|---|
-| Local (native) | `youruser` | bcrypt hash | `NULL` | Yes |
-| Federated (client-created) | `youruser@nova.ddns.net` | bcrypt hash | `nova.ddns.net` | Yes |
-| Replicated stub (S2S-created) | `youruser@nova.ddns.net` | `!federation-replicated` | `nova.ddns.net` | No |
+| Local (native) | `erin` | bcrypt hash | `NULL` | Yes |
+| Federated (client-created) | `erin@nova.ddns.net` | bcrypt hash | `nova.ddns.net` | Yes |
+| Replicated stub (S2S-created) | `erin@nova.ddns.net` | `!federation-replicated` | `nova.ddns.net` | No |
Key distinction: **Federated accounts** and **replicated stubs** can have the same username format (`user@instance`), but federated accounts have real passwords and can log in. Replicated stubs are server-created placeholders for identity resolution and cannot log in.
@@ -75,9 +75,9 @@ The merge migration in `migrate.ts` detects when both exist for the same remote
When a user adds a remote instance via the Connections settings:
1. **Verify home password** — client confirms the user's password against the home instance
-2. **Compute federated username** — `{bareUsername}@{homeHost}` (e.g., `youruser@nova.ddns.net`)
+2. **Compute federated username** — `{bareUsername}@{homeHost}` (e.g., `erin@nova.ddns.net`)
3. **Try registration** on remote instance with:
- - Username: `youruser@nova.ddns.net`
+ - Username: `erin@nova.ddns.net`
- Password: same as home instance password
- `homeInstance`: `nova.ddns.net` (bare domain)
- `homeUserId`: user's Snowflake ID on home instance
@@ -106,7 +106,7 @@ interface ConnectedInstance {
label: string; // Instance display name
token: string; // JWT for this instance
user: User; // User record on this instance
- username: string; // e.g., 'youruser@nova.ddns.net'
+ username: string; // e.g., 'erin@nova.ddns.net'
status: 'connected' | 'connecting' | 'disconnected' | 'error';
error?: string;
api: BackspaceApiClient; // Authenticated API client
diff --git a/docs/systems/deployment.md b/docs/systems/deployment.md
index 9da2e166..16b323f1 100644
--- a/docs/systems/deployment.md
+++ b/docs/systems/deployment.md
@@ -7,7 +7,7 @@ Source files:
- `docker-compose.yml` -- `backspace` + `caddy` (+ optional `livekit`) services, healthcheck
- `Caddyfile` -- reverse proxy / auto-HTTPS config
- `install.sh` -- interactive first-time setup
-- `deploy.sh` -- rsync + rebuild to Jannis's two boxes
+- `deploy.sh` -- rsync + rebuild to Heidi's two boxes
- `backup.sh` / `restore.sh` -- manual snapshot + restore tooling (host side)
- `packages/server/src/config.ts` -- `config.backup.*` env parsing
- `packages/server/src/utils/backup.ts` -- `createSnapshot` (VACUUM INTO), `listSnapshots`, `pruneSnapshots`, off-box hook
@@ -62,7 +62,7 @@ Caddy provisions and renews TLS certificates automatically for `DOMAIN`; the per
### Redeploy: `deploy.sh [pi|vm|all]`
-`./deploy.sh` is Jannis's redeploy helper for the two live instances — `nova.ddns.net` (Raspberry Pi) and `orbit.ddns.net` (VM). It does **not** build locally; it `rsync`s the working tree to the target (excluding `node_modules`, `.env`, `data/`, build output, and a list of local-only paths) and then runs `docker compose up -d --build` on the remote so the image is rebuilt in place. Targets:
+`./deploy.sh` is Heidi's redeploy helper for the two live instances — `nova.ddns.net` (Raspberry Pi) and `orbit.ddns.net` (VM). It does **not** build locally; it `rsync`s the working tree to the target (excluding `node_modules`, `.env`, `data/`, build output, and a list of local-only paths) and then runs `docker compose up -d --build` on the remote so the image is rebuilt in place. Targets:
| Arg | Target |
|-----|--------|
diff --git a/docs/systems/dm-system.md b/docs/systems/dm-system.md
index 6b66b7bc..b901b914 100644
--- a/docs/systems/dm-system.md
+++ b/docs/systems/dm-system.md
@@ -891,7 +891,7 @@ For full wire formats, see `docs/systems/websocket.md`.
| Missing federatedId in outbox | All membership events rejected by peer | Outbox worker reconstruction omitted `federatedId` | Copy `parsed.federatedId` during reconstruction |
| Cross-instance duplicate channels | Duplicate sidebar entries | `dm_channel_created` broadcast to ALL members including remote | Local-only broadcast principle |
| Bootstrap vs incremental confusion | N/A (design note) | `bootstrapped` flag is function-local; batch events work correctly because bootstrap adds ALL roster members | No fix needed -- documented as correct behavior |
-| Duplicated membership system messages across restarts | 4× "Jannis added youruser" in group DM, channel keeps flipping to unread after each deploy | Membership event processors inserted system messages unconditionally. Each approval-flow re-peering reset peer `last_synced_at = 0`, so initial sync replayed every historical `member_add` / `member_remove` / `ownership_transfer` on next boot. Each replay's new snowflake ID exceeded the user's `read_states.last_read_message_id`, flipping unread. | Dedup by `(sourceInstance, event.messageId)` on the inserted system message. Both bootstrap and incremental paths in `processMemberAddEvent` now persist these fields so replay is a no-op. |
+| Duplicated membership system messages across restarts | 4× "Heidi added erin" in group DM, channel keeps flipping to unread after each deploy | Membership event processors inserted system messages unconditionally. Each approval-flow re-peering reset peer `last_synced_at = 0`, so initial sync replayed every historical `member_add` / `member_remove` / `ownership_transfer` on next boot. Each replay's new snowflake ID exceeded the user's `read_states.last_read_message_id`, flipping unread. | Dedup by `(sourceInstance, event.messageId)` on the inserted system message. Both bootstrap and incremental paths in `processMemberAddEvent` now persist these fields so replay is a no-op. |
| Raw JSON in DM sidebar previews | DM sidebar showed `{"event":"space_invite",...}` / `{"event":"member_added",...}` as the last-message preview | `DmLastMessagePreview` shape omitted `type`, so the client could not distinguish system from user messages and rendered `lastMessage.content` verbatim. | Added `type` to `DmLastMessagePreview`, populated it from `dm_messages.type` in every server emission site, and routed the sidebar through a single `formatDmSidebarPreview` helper that renders human-readable text for each system event. |
| Owner-only requests routed to wrong instance after manual transfer (latent) | After `POST /api/dm/:id/transfer` moved ownership to a member whose `homeInstance` differed from the channel's pinned serving origin, owner-only client calls (`updateMetadata`, `kickMember`, `transferOwnership`) routed via `getChannelOrigin` would emit outbox events with `sourceInstance !== ownerHomeInstance`, and all peers would reject them as `attribution_mismatch`. Latent only because pre-polish there was no kick endpoint and no metadata edit; auto-transfer-on-leave masked the issue (the leaver IS the actor, and `member_remove reason='leave'` accepts any source). | Added `getOwnerInstanceForDm(channelId)` exported next to `getChannelOrigin`. All four owner-only API client methods (`updateMetadata`, `kickMember`, `transferOwnership` — and any future owner-only routes) call `getApiForOrigin(getOwnerInstanceForDm(channelId))` instead of channel origin. Non-owner operations are unchanged. |
| Kick / transfer to federated member always failed with "user not a member" | `DELETE /api/dm/:id/members/:targetUserId` and `POST /api/dm/:id/transfer` accepted only a local user id. The client passed `canonical.id` from `useCanonicalUserView`, which returns the user's HOME id when the home view is cached. After owner-routing the request to the owner instance, the owner instance's `dm_members.userId` (its own local replicated id) never matched the home id, so `isDmMember` returned false. | Both endpoints now accept federated identification (`homeUserId` + `homeInstance`) — the transfer endpoint takes them in the body, the kick endpoint reads `homeInstance` from a query string and treats the URL segment as a homeUserId. Server resolves via `resolveOrCreateReplicatedUser` before membership check. Mirrors the `addDmMember` pattern. Client `kickMember` / `transferOwnership` accept an optional `federated` arg and pass it when the target has `homeUserId` + `homeInstance` populated. |
diff --git a/docs/systems/embeds.md b/docs/systems/embeds.md
index 02c6853c..8e13ac8a 100644
--- a/docs/systems/embeds.md
+++ b/docs/systems/embeds.md
@@ -137,9 +137,9 @@ Invalid URLs (fail `new URL()` parsing): same as fallthrough.
## 3. SSRF Protection
-`ssrf.ts:validateExternalUrl()`
+`ssrf.ts:validateExternalUrl()` and `ssrf.ts:safeFetch()`
-Called before every outbound fetch (metadata fetching and image dimension probing). Throws on any violation.
+All outbound fetches to user- or peer-supplied URLs go through `safeFetch()`, which validates the target with `validateExternalUrl()` (below) and re-validates the destination of every redirect hop. `validateExternalUrl()` throws on any violation.
### Validation Steps
@@ -166,7 +166,9 @@ Called before every outbound fetch (metadata fetching and image dimension probin
### Redirect Handling
-Both `fetchUrlMetadata` and `probeRemoteImageDimensions` use `redirect: 'follow'` in their `fetch()` calls. SSRF validation is performed on the **original** URL before fetch, but the native `fetch` follows redirects without re-validating intermediate URLs. This means a redirect from a public IP to a private IP would not be caught by the current implementation.
+Outbound fetches use `safeFetch()` (`ssrf.ts`), which follows redirects **manually** (`redirect: 'manual'`) and runs `validateExternalUrl()` against every hop's destination before following it, capped at 5 redirects. A redirect from a public host to a private/internal address (loopback, link-local, RFC1918) is therefore blocked, closing the redirect-based SSRF bypass. Callers — `fetchUrlMetadata`, `probeRemoteImageDimensions`, and `fetchSpaceInviteSnapshot` — all route through `safeFetch` rather than calling `validateExternalUrl` + `fetch` separately.
+
+**Residual:** `validateExternalUrl` resolves DNS and `fetch` resolves again, leaving a narrow DNS-rebinding TOCTOU window. Closing it fully requires pinning the resolved IP at connect time via a custom dispatcher; the redirect re-validation closes the practical, attacker-controlled bypass.
---
@@ -176,8 +178,8 @@ Both `fetchUrlMetadata` and `probeRemoteImageDimensions` use `redirect: 'follow'
### Flow
-1. `validateExternalUrl(url)` — SSRF check, returns `null` on failure
-2. `fetch(url)` with `User-Agent: BackspaceBot/1.0`, 5-second timeout via `AbortController`
+1. `safeFetch(url)` — SSRF-validated fetch (initial URL + every redirect hop); throws on block, caught to return `null`
+2. Request sent with `User-Agent: BackspaceBot/1.0`, 5-second timeout via `AbortController`
3. **Content-Type detection** — if response is `image/*`, `video/*`, or `audio/*`, returns early with `contentType` field set (no HTML parsing)
4. **Size guard** — rejects responses with `Content-Length > 512KB`
5. **Stream-read with hard limit** — reads body via `ReadableStream`, stops at 512KB even for chunked (unknown-length) responses
@@ -283,7 +285,7 @@ Used for direct image URLs where dimensions are unknown (not provided by OG tags
### Mechanism
-1. **SSRF validation** — `validateExternalUrl(url)`, returns `null` on block
+1. **SSRF-validated fetch** — `safeFetch(url)` validates the URL and every redirect hop; a block throws and is caught to return `null`
2. **Range request** — fetches first 32KB (`PROBE_BYTES = 32_768`) with header `Range: bytes=0-32767`
3. **Timeout** — 3-second abort (`PROBE_TIMEOUT_MS = 3_000`)
4. **Graceful body read** — reads up to `PROBE_BYTES` via `ReadableStream`, then aggressively cancels the connection via `reader.cancel()`
diff --git a/docs/systems/federation.md b/docs/systems/federation.md
index 9def1a02..66436934 100644
--- a/docs/systems/federation.md
+++ b/docs/systems/federation.md
@@ -859,7 +859,7 @@ Owner-authored update of a group DM's `name` and/or `icon`. Mirrors the `profile
"userId": "...",
"homeUserId": "...",
"homeInstance": "https://nova.ddns.net",
- "profile": { "username": "jannis" }
+ "profile": { "username": "heidi" }
}
}
}
@@ -1593,7 +1593,7 @@ Relevant settings in `instance_settings`:
The frontend needs to resolve federated identities for display purposes:
-**`parseFederatedUsername(username)`** -- splits `"youruser@nova.ddns.net"` into `{baseName: "youruser", domain: "nova.ddns.net"}`.
+**`parseFederatedUsername(username)`** -- splits `"erin@nova.ddns.net"` into `{baseName: "erin", domain: "nova.ddns.net"}`.
**`isSelf(user, homeUser)`** -- determines if a user object is the logged-in user or their replicated stub. Uses cascading checks: same ID, known self-ID set, homeInstance + baseName match.
diff --git a/docs/systems/social.md b/docs/systems/social.md
index 8c962a4a..c934869b 100644
--- a/docs/systems/social.md
+++ b/docs/systems/social.md
@@ -590,7 +590,7 @@ Source: `packages/web/src/utils/identity.ts`
### `parseFederatedUsername(username)`
-Splits `"youruser@nova.ddns.net"` into `{ baseName: "youruser", domain: "nova.ddns.net" }`. Uses `indexOf('@')` (first occurrence). Returns `{ baseName: username, domain: null }` for non-federated usernames.
+Splits `"erin@nova.ddns.net"` into `{ baseName: "erin", domain: "nova.ddns.net" }`. Uses `indexOf('@')` (first occurrence). Returns `{ baseName: username, domain: null }` for non-federated usernames.
### `isSelf(user, homeUser)`
diff --git a/package.json b/package.json
index dbf9f5c4..8da5f46d 100644
--- a/package.json
+++ b/package.json
@@ -2,7 +2,14 @@
"name": "backspace",
"version": "1.0.0",
"private": true,
- "description": "Open-source, self-hosted Discord alternative",
+ "description": "Open, self-hosted communication platform — text, voice, video, and federation",
+ "license": "Elastic-2.0",
+ "author": "Jannis Braun",
+ "homepage": "https://github.com/TheZwiss/backspace",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/TheZwiss/backspace.git"
+ },
"scripts": {
"dev:server": "pnpm --filter @backspace/server dev",
"dev:web": "pnpm --filter @backspace/web dev",
@@ -10,7 +17,7 @@
"build:shared": "pnpm --filter @backspace/shared build",
"build:server": "pnpm --filter @backspace/server build",
"build:web": "pnpm --filter @backspace/web build",
- "build": "pnpm --filter @backspace/shared build && pnpm --filter @backspace/server build & pnpm --filter @backspace/web build",
+ "build": "pnpm --filter @backspace/shared build && pnpm --filter @backspace/server build && pnpm --filter @backspace/web build",
"dev:desktop": "pnpm --filter @backspace/desktop dev",
"build:desktop": "pnpm --filter @backspace/desktop build",
"gen-icons": "node scripts/gen-icons.mjs"
diff --git a/packages/desktop/package.json b/packages/desktop/package.json
index d370003a..29f1a161 100644
--- a/packages/desktop/package.json
+++ b/packages/desktop/package.json
@@ -2,9 +2,10 @@
"name": "@backspace/desktop",
"version": "1.0.0",
"private": true,
+ "license": "Elastic-2.0",
"description": "Backspace",
"author": {
- "name": "youruser",
+ "name": "Jannis Braun",
"email": "backspace@backspace.chat"
},
"homepage": "https://github.com/TheZwiss/backspace",
diff --git a/packages/server/package.json b/packages/server/package.json
index 2f05295c..13a32999 100644
--- a/packages/server/package.json
+++ b/packages/server/package.json
@@ -2,9 +2,11 @@
"name": "@backspace/server",
"version": "1.0.0",
"private": true,
+ "license": "Elastic-2.0",
+ "author": "Jannis Braun",
"type": "module",
"scripts": {
- "dev": "tsx watch src/index.ts",
+ "dev": "PORT=3005 tsx watch src/index.ts",
"start": "node --import tsx/esm src/index.ts",
"build": "tsc",
"typecheck": "tsc --noEmit",
diff --git a/packages/server/src/routes/federation.presenceUpdate.test.ts b/packages/server/src/routes/federation.presenceUpdate.test.ts
index 69fd7d8d..396ea353 100644
--- a/packages/server/src/routes/federation.presenceUpdate.test.ts
+++ b/packages/server/src/routes/federation.presenceUpdate.test.ts
@@ -52,11 +52,11 @@ beforeEach(() => {
testDb = drizzle(sqlite, { schema });
applyMigrations(sqlite);
sentToUserCalls.length = 0;
- // Local user (youruser) and replicated stub (pbtest3) — they're friends.
+ // Local user (erin) and replicated stub (pbtest3) — they're friends.
testDb.insert(schema.users).values([
{
- id: 'local-youruser', username: 'youruser', passwordHash: 'x', status: 'online', isAdmin: 0,
- homeUserId: 'local-youruser', createdAt: Date.now(),
+ id: 'local-erin', username: 'erin', passwordHash: 'x', status: 'online', isAdmin: 0,
+ homeUserId: 'local-erin', createdAt: Date.now(),
},
{
id: 'stub-pbtest3', username: 'pbtest3@orbit.ddns.net', displayName: 'pbtest3',
@@ -65,7 +65,7 @@ beforeEach(() => {
},
]).run();
testDb.insert(schema.friends).values({
- userId: 'local-youruser', friendId: 'stub-pbtest3', createdAt: Date.now(),
+ userId: 'local-erin', friendId: 'stub-pbtest3', createdAt: Date.now(),
}).run();
});
@@ -94,7 +94,7 @@ describe('processPresenceUpdateEvent', () => {
const row = testDb.select().from(schema.users).where(eq(schema.users.id, 'stub-pbtest3')).get();
expect(row!.status).toBe('online');
- const broadcast = sentToUserCalls.find((c) => c.userId === 'local-youruser');
+ const broadcast = sentToUserCalls.find((c) => c.userId === 'local-erin');
expect(broadcast).toBeDefined();
expect(broadcast!.payload.type).toBe('presence_update');
expect(broadcast!.payload.userId).toBe('stub-pbtest3');
@@ -160,7 +160,7 @@ describe('processPresenceUpdateEvent', () => {
},
};
fed.processPresenceUpdateEvent(event, 'orbit.ddns.net', testDb, [], []);
- const broadcast = sentToUserCalls.find((c) => c.userId === 'local-youruser');
+ const broadcast = sentToUserCalls.find((c) => c.userId === 'local-erin');
expect(broadcast!.payload.activities).toEqual([{ type: 'playing', name: 'Test' }]);
});
});
diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts
index eac4a29a..ef70c5fb 100644
--- a/packages/server/src/routes/federation.ts
+++ b/packages/server/src/routes/federation.ts
@@ -2566,7 +2566,7 @@ export async function federationRoutes(app: FastifyInstance): Promise {
// 4. Query mutation log for the relevant channels
// Return mutations for ALL locally-created messages (source_instance IS NULL).
- // This includes messages by replicated users (e.g., Jannis browsing orbit)
+ // This includes messages by replicated users (e.g., Heidi browsing orbit)
// because they were created on THIS instance and need to be synced to the peer.
if (effectiveChannelFilter) {
// Validate that the requested channel is actually shared with this peer
@@ -3111,7 +3111,7 @@ export function extractDomain(homeInstance: string): string {
* Two valid cases:
* 1. **Direct**: author is from the source instance (standard S2S — peer sends events for its own users).
* 2. **Homeward relay**: author is from the *receiving* instance. This happens when a client-federation
- * user (e.g., youruser@nova logged into orbit) sends a message on a remote server, and the
+ * user (e.g., erin@nova logged into orbit) sends a message on a remote server, and the
* S2S relay forwards it back to the author's home instance. The trusted peer is just the messenger.
*
* Both sides are normalized to bare domain before comparison.
diff --git a/packages/server/src/scripts/backfill-image-embed-dims.ts b/packages/server/src/scripts/backfill-image-embed-dims.ts
new file mode 100644
index 00000000..6f428105
--- /dev/null
+++ b/packages/server/src/scripts/backfill-image-embed-dims.ts
@@ -0,0 +1,243 @@
+/**
+ * One-time backfill: populate width/height for image embeds that lack them,
+ * and create image embeds for old bare-image-URL messages that never got one
+ * (these predate the `resolveEmbeds` call site in routes/messages.ts).
+ *
+ * Idempotent — re-running skips already-dim'd rows and already-embedded
+ * messages via the WHERE clauses.
+ *
+ * Run from the running container:
+ * docker exec backspace node dist/scripts/backfill-image-embed-dims.js
+ */
+import fs from 'fs';
+import path from 'path';
+import { initDatabase, getRawDb, schema } from '../db/index.js';
+import { probeRemoteImageDimensions } from '../utils/embedResolver.js';
+import { extractUrls } from '../utils/embedResolver.js';
+import { classifyUrl } from '../utils/embedClassifier.js';
+import { probeImageDimensions } from '../utils/thumbnail.js';
+import { generateSnowflake } from '../utils/snowflake.js';
+import { config } from '../config.js';
+
+const CONCURRENCY = 4;
+
+interface PendingProbe {
+ kind: 'update-embed' | 'create-embed-for-message' | 'create-embed-for-dm-message';
+ url: string;
+ embedId?: string;
+ messageId?: string;
+ dmMessageId?: string;
+}
+
+async function withConcurrency(items: T[], n: number, worker: (item: T) => Promise): Promise {
+ let i = 0;
+ const runners = Array.from({ length: Math.min(n, items.length) }, async () => {
+ while (true) {
+ const idx = i++;
+ if (idx >= items.length) return;
+ const item = items[idx];
+ if (item === undefined) return;
+ await worker(item);
+ }
+ });
+ await Promise.all(runners);
+}
+
+function isLikelyImageUrl(url: string): boolean {
+ return classifyUrl(url).embedType === 'image';
+}
+
+async function main(): Promise {
+ // initDatabase() runs migrations + ensureDefaults + sets the snowflake
+ // worker ID from instance_settings.worker_id. We rely on that for
+ // generateSnowflake() below.
+ initDatabase();
+ const db = getRawDb();
+
+ // ── Pass 1: image embeds missing dimensions ──────────────────────────────
+ const nullDimEmbeds = db.prepare(
+ `SELECT id, url FROM embeds WHERE embed_type = 'image' AND (width IS NULL OR height IS NULL)`,
+ ).all() as Array<{ id: string; url: string }>;
+ console.log(`[backfill] Found ${nullDimEmbeds.length} image embeds missing width/height`);
+
+ // ── Pass 2: messages with bare image URLs but no embed row ───────────────
+ // SQLite has no REGEXP by default; use LIKE with the two known providers
+ // plus a generic suffix match. We re-classify each candidate URL with
+ // classifyUrl() before probing so non-image extensions are filtered out.
+ const candidateMessages = db.prepare(
+ `SELECT m.id, m.content
+ FROM messages m
+ LEFT JOIN embeds e ON e.message_id = m.id
+ WHERE m.content IS NOT NULL
+ AND (
+ m.content LIKE 'https://media.tenor.com/%'
+ OR m.content LIKE 'https://static.klipy.com/%'
+ OR m.content LIKE '%.gif'
+ OR m.content LIKE '%.webp'
+ )
+ AND e.id IS NULL`,
+ ).all() as Array<{ id: string; content: string }>;
+ console.log(`[backfill] Found ${candidateMessages.length} channel messages with image URLs and no embed`);
+
+ const candidateDmMessages = db.prepare(
+ `SELECT m.id, m.content
+ FROM dm_messages m
+ LEFT JOIN embeds e ON e.dm_message_id = m.id
+ WHERE m.content IS NOT NULL
+ AND (
+ m.content LIKE 'https://media.tenor.com/%'
+ OR m.content LIKE 'https://static.klipy.com/%'
+ OR m.content LIKE '%.gif'
+ OR m.content LIKE '%.webp'
+ )
+ AND e.id IS NULL`,
+ ).all() as Array<{ id: string; content: string }>;
+ console.log(`[backfill] Found ${candidateDmMessages.length} DM messages with image URLs and no embed`);
+
+ // ── Build work queue ─────────────────────────────────────────────────────
+ const work: PendingProbe[] = [];
+
+ for (const row of nullDimEmbeds) {
+ work.push({ kind: 'update-embed', url: row.url, embedId: row.id });
+ }
+
+ for (const m of candidateMessages) {
+ const urls = extractUrls(m.content);
+ for (const url of urls) {
+ if (isLikelyImageUrl(url)) {
+ work.push({ kind: 'create-embed-for-message', url, messageId: m.id });
+ }
+ }
+ }
+
+ for (const m of candidateDmMessages) {
+ const urls = extractUrls(m.content);
+ for (const url of urls) {
+ if (isLikelyImageUrl(url)) {
+ work.push({ kind: 'create-embed-for-dm-message', url, dmMessageId: m.id });
+ }
+ }
+ }
+
+ // ── Pass 3: image attachments missing dimensions ─────────────────────────
+ // Probe local files directly (the upload sits on the same filesystem). The
+ // `failOn: 'none'` flag in `probeImageDimensions` handles animated GIFs
+ // whose default sharp probe was failing on frame-data validation.
+ const nullDimAttachments = db.prepare(
+ `SELECT id, filename FROM attachments WHERE mimetype LIKE 'image/%' AND (width IS NULL OR height IS NULL)`,
+ ).all() as Array<{ id: string; filename: string }>;
+ console.log(`[backfill] Found ${nullDimAttachments.length} image attachments missing width/height`);
+
+ const updateAttachment = db.prepare(`UPDATE attachments SET width = ?, height = ? WHERE id = ?`);
+ let attachmentSuccesses = 0;
+ let attachmentSkipped = 0;
+ for (const a of nullDimAttachments) {
+ const filepath = path.join(config.uploadDir, a.filename);
+ if (!fs.existsSync(filepath)) {
+ // Federated remote attachments live on the source instance, not here.
+ // Their dims need to be backfilled from the source side, or via the
+ // federation file-replication path. Skip silently.
+ attachmentSkipped++;
+ continue;
+ }
+ const dims = await probeImageDimensions(filepath);
+ if (dims) {
+ updateAttachment.run(dims.width, dims.height, a.id);
+ attachmentSuccesses++;
+ }
+ }
+ console.log(`[backfill] attachments updated=${attachmentSuccesses}, skipped (file not local)=${attachmentSkipped}`);
+
+ // ── Pass 4: image embeds whose URL points at our local uploads ───────────
+ // SSRF correctly blocks the remote probe from hitting our own host. For
+ // these specific embeds we have the file locally — extract the upload
+ // filename from the URL path (`/api/uploads/`) and probe it
+ // directly, sidestepping HTTP entirely. Federated `/api/uploads/...` URLs
+ // on a different host won't have a local file and are skipped.
+ const localUploadEmbeds = db.prepare(
+ `SELECT id, url FROM embeds WHERE embed_type = 'image' AND (width IS NULL OR height IS NULL) AND url LIKE '%/api/uploads/%'`,
+ ).all() as Array<{ id: string; url: string }>;
+ console.log(`[backfill] Found ${localUploadEmbeds.length} image embeds pointing at /api/uploads`);
+
+ const updateEmbedDims = db.prepare(`UPDATE embeds SET width = ?, height = ? WHERE id = ?`);
+ let localEmbedSuccesses = 0;
+ let localEmbedSkipped = 0;
+ for (const e of localUploadEmbeds) {
+ const match = e.url.match(/\/api\/uploads\/([^/?#]+)/);
+ if (!match || !match[1]) { localEmbedSkipped++; continue; }
+ const filepath = path.join(config.uploadDir, match[1]);
+ if (!fs.existsSync(filepath)) { localEmbedSkipped++; continue; }
+ const dims = await probeImageDimensions(filepath);
+ if (dims) {
+ updateEmbedDims.run(dims.width, dims.height, e.id);
+ localEmbedSuccesses++;
+ }
+ }
+ console.log(`[backfill] local-upload embeds updated=${localEmbedSuccesses}, skipped (no local file)=${localEmbedSkipped}`);
+
+ console.log(`[backfill] ${work.length} remote probes to run (concurrency=${CONCURRENCY})`);
+
+ // ── Execute ──────────────────────────────────────────────────────────────
+ const updateEmbed = db.prepare(
+ `UPDATE embeds SET width = ?, height = ? WHERE id = ?`,
+ );
+ const insertEmbed = db.prepare(
+ `INSERT INTO embeds (id, message_id, dm_message_id, url, embed_type, provider, title, description, image, embed_url, width, height, color, created_at)
+ VALUES (?, ?, ?, ?, 'image', NULL, NULL, NULL, ?, NULL, ?, ?, NULL, ?)`,
+ );
+
+ let successes = 0;
+ let probeFailures = 0;
+ let progress = 0;
+
+ await withConcurrency(work, CONCURRENCY, async (item) => {
+ const dims = await probeRemoteImageDimensions(item.url);
+ progress++;
+ if (progress % 25 === 0) {
+ console.log(`[backfill] progress ${progress}/${work.length} (ok=${successes}, fail=${probeFailures})`);
+ }
+ if (!dims) {
+ probeFailures++;
+ return;
+ }
+ if (item.kind === 'update-embed' && item.embedId) {
+ updateEmbed.run(dims.width, dims.height, item.embedId);
+ successes++;
+ } else if (item.kind === 'create-embed-for-message' && item.messageId) {
+ insertEmbed.run(
+ generateSnowflake(),
+ item.messageId,
+ null,
+ item.url,
+ item.url,
+ dims.width,
+ dims.height,
+ Date.now(),
+ );
+ successes++;
+ } else if (item.kind === 'create-embed-for-dm-message' && item.dmMessageId) {
+ insertEmbed.run(
+ generateSnowflake(),
+ null,
+ item.dmMessageId,
+ item.url,
+ item.url,
+ dims.width,
+ dims.height,
+ Date.now(),
+ );
+ successes++;
+ }
+ });
+
+ console.log(`[backfill] done. successes=${successes}, probe-failures=${probeFailures}, total=${work.length}`);
+ // Silence the schema import linter — it's intentionally available for
+ // future per-table tweaks without re-importing.
+ void schema;
+ process.exit(0);
+}
+
+main().catch((err) => {
+ console.error('[backfill] fatal:', err);
+ process.exit(1);
+});
diff --git a/packages/server/src/utils/embedResolver.ts b/packages/server/src/utils/embedResolver.ts
index 360e9274..901d7bd9 100644
--- a/packages/server/src/utils/embedResolver.ts
+++ b/packages/server/src/utils/embedResolver.ts
@@ -5,7 +5,7 @@ import { getDb, schema } from '../db/index.js';
import { generateSnowflake } from './snowflake.js';
import { classifyUrl } from './embedClassifier.js';
import { fetchUrlMetadata } from './metadataFetcher.js';
-import { validateExternalUrl } from './ssrf.js';
+import { safeFetch } from './ssrf.js';
import { connectionManager } from '../ws/handler.js';
const MAX_EMBEDS_PER_MESSAGE = 5;
@@ -18,27 +18,20 @@ const PROBE_TIMEOUT_MS = 3_000;
* Uses Range request to avoid downloading the entire file.
* Returns null on any failure (timeout, network, unrecognized format, SSRF block).
*/
-async function probeRemoteImageDimensions(
+export async function probeRemoteImageDimensions(
url: string,
): Promise<{ width: number; height: number } | null> {
- try {
- await validateExternalUrl(url);
- } catch {
- return null;
- }
-
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
try {
- const response = await fetch(url, {
+ const response = await safeFetch(url, {
headers: {
'User-Agent': 'BackspaceBot/1.0',
Accept: 'image/*',
Range: `bytes=0-${PROBE_BYTES - 1}`,
},
signal: controller.signal,
- redirect: 'follow',
});
// NOTE: Do NOT clearTimeout here — keep the abort active during body read.
// The finally block handles cleanup after all reads complete.
diff --git a/packages/server/src/utils/federationPresence.peerLifecycle.test.ts b/packages/server/src/utils/federationPresence.peerLifecycle.test.ts
index f4dc974a..8f4ec109 100644
--- a/packages/server/src/utils/federationPresence.peerLifecycle.test.ts
+++ b/packages/server/src/utils/federationPresence.peerLifecycle.test.ts
@@ -73,7 +73,7 @@ beforeEach(() => {
}).run();
// Online native FRIENDED with the stub — should be snapshotted
testDb.insert(schema.users).values({
- id: 'native-friend', username: 'youruser', passwordHash: 'x',
+ id: 'native-friend', username: 'erin', passwordHash: 'x',
status: 'online', isAdmin: 0, homeUserId: 'native-friend', createdAt: Date.now(),
}).run();
testDb.insert(schema.friends).values({
diff --git a/packages/server/src/utils/federationPresence.test.ts b/packages/server/src/utils/federationPresence.test.ts
index 6dd2b234..b4a76d12 100644
--- a/packages/server/src/utils/federationPresence.test.ts
+++ b/packages/server/src/utils/federationPresence.test.ts
@@ -62,7 +62,7 @@ beforeEach(() => {
// Native local user
testDb.insert(schema.users).values({
id: 'native-1',
- username: 'youruser',
+ username: 'erin',
passwordHash: 'x',
status: 'online',
isAdmin: 0,
diff --git a/packages/server/src/utils/metadataFetcher.ts b/packages/server/src/utils/metadataFetcher.ts
index 1029108c..e54db640 100644
--- a/packages/server/src/utils/metadataFetcher.ts
+++ b/packages/server/src/utils/metadataFetcher.ts
@@ -1,5 +1,5 @@
import * as cheerio from 'cheerio';
-import { validateExternalUrl } from './ssrf.js';
+import { safeFetch } from './ssrf.js';
export interface UrlMetadata {
title: string | null;
@@ -16,21 +16,14 @@ export interface UrlMetadata {
}
export async function fetchUrlMetadata(url: string): Promise {
- try {
- await validateExternalUrl(url);
- } catch {
- return null;
- }
-
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
- const response = await fetch(url, {
+ const response = await safeFetch(url, {
headers: {
'User-Agent': 'BackspaceBot/1.0',
},
signal: controller.signal,
- redirect: 'follow',
});
clearTimeout(timeout);
diff --git a/packages/server/src/utils/spaceInviteSnapshot.test.ts b/packages/server/src/utils/spaceInviteSnapshot.test.ts
index 799fa43f..c6a9dc3d 100644
--- a/packages/server/src/utils/spaceInviteSnapshot.test.ts
+++ b/packages/server/src/utils/spaceInviteSnapshot.test.ts
@@ -22,11 +22,18 @@ vi.mock('../db/index.js', () => ({
}));
// Mock the ssrf module so tests don't need real DNS resolution.
-// Default: validateExternalUrl resolves (allow). Individual tests override as needed.
-vi.mock('./ssrf.js', () => ({
- validateExternalUrl: vi.fn().mockResolvedValue(undefined),
- isPrivateIp: vi.fn().mockReturnValue(false),
-}));
+// safeFetch mirrors the real contract: validate the URL first, then fetch — so a
+// rejecting validator must prevent the fetch. Default: validateExternalUrl
+// resolves (allow). Individual tests override via mockRejectedValueOnce.
+vi.mock('./ssrf.js', () => {
+ const validateExternalUrl = vi.fn().mockResolvedValue(undefined);
+ const isPrivateIp = vi.fn().mockReturnValue(false);
+ const safeFetch = vi.fn(async (url: string, init?: RequestInit) => {
+ await validateExternalUrl(url);
+ return (global.fetch as unknown as typeof fetch)(url, init);
+ });
+ return { validateExternalUrl, isPrivateIp, safeFetch };
+});
function applyMigrations(db: Database.Database): void {
const migrationsDir = path.resolve(__dirname, '../../drizzle');
@@ -95,16 +102,14 @@ describe('fetchSpaceInviteSnapshot', () => {
});
it('returns null when SSRF validator rejects the origin', async () => {
- // Override the module-level mock to reject for this test only.
+ // Reject validation for this one call; safeFetch must bail before fetching.
const ssrf = await import('./ssrf.js');
- const spy = vi.spyOn(ssrf, 'validateExternalUrl').mockRejectedValueOnce(new Error('blocked'));
+ (ssrf.validateExternalUrl as unknown as ReturnType).mockRejectedValueOnce(new Error('blocked'));
const fetchSpy = global.fetch as any;
const snap = await fetchSpaceInviteSnapshot('http://127.0.0.1:9200', 'abc');
expect(snap).toBeNull();
expect(fetchSpy).not.toHaveBeenCalled(); // CRITICAL — the fetch must NOT happen
-
- spy.mockRestore();
});
});
diff --git a/packages/server/src/utils/spaceInviteSnapshot.ts b/packages/server/src/utils/spaceInviteSnapshot.ts
index 1d46432e..6bce271c 100644
--- a/packages/server/src/utils/spaceInviteSnapshot.ts
+++ b/packages/server/src/utils/spaceInviteSnapshot.ts
@@ -1,7 +1,7 @@
import type { AvatarColor } from '@backspace/shared';
import { eq } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
-import { validateExternalUrl } from './ssrf.js';
+import { safeFetch } from './ssrf.js';
export interface SpaceInviteSnapshot {
spaceId: string;
@@ -59,15 +59,10 @@ export async function fetchSpaceInviteSnapshot(
timeoutMs = 5000,
): Promise {
const url = `${spaceInstanceOrigin}/api/spaces/invite/${encodeURIComponent(inviteCode)}/preview`;
- try {
- await validateExternalUrl(url);
- } catch {
- return null;
- }
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
- const res = await fetch(url, { signal: controller.signal });
+ const res = await safeFetch(url, { signal: controller.signal });
if (!res.ok) return null;
const data = await res.json() as Partial;
if (typeof data?.spaceId !== 'string' || typeof data?.spaceName !== 'string') return null;
diff --git a/packages/server/src/utils/ssrf.ts b/packages/server/src/utils/ssrf.ts
index bbd097d7..4e34416b 100644
--- a/packages/server/src/utils/ssrf.ts
+++ b/packages/server/src/utils/ssrf.ts
@@ -43,3 +43,38 @@ export async function validateExternalUrl(url: string): Promise {
throw new Error('Private IP not allowed');
}
}
+
+const MAX_REDIRECTS = 5;
+
+/**
+ * SSRF-safe fetch. Validates the target URL and re-validates the destination of
+ * every redirect hop before following it, so a hostile server cannot 30x-redirect
+ * an outbound request to an internal address (loopback, link-local, RFC1918).
+ *
+ * Use this instead of bare `fetch()` for any request to a user- or peer-supplied
+ * URL. Redirects are followed manually (Node/undici exposes the 3xx + Location
+ * with `redirect: 'manual'`), capped at MAX_REDIRECTS.
+ *
+ * Residual: validateExternalUrl resolves DNS, then fetch resolves again — a
+ * narrow DNS-rebinding TOCTOU window remains. Pinning the resolved IP at connect
+ * time would close it but requires a custom dispatcher; the redirect re-check
+ * here closes the practical, attacker-controlled bypass.
+ */
+export async function safeFetch(url: string, init: RequestInit = {}): Promise {
+ let currentUrl = url;
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
+ await validateExternalUrl(currentUrl);
+ const response = await fetch(currentUrl, { ...init, redirect: 'manual' });
+
+ if (response.status >= 300 && response.status < 400) {
+ const location = response.headers.get('location');
+ if (!location) return response; // 3xx without a target — hand back as-is
+ // Resolve relative redirects against the current URL, then loop to re-validate.
+ currentUrl = new URL(location, currentUrl).toString();
+ continue;
+ }
+
+ return response;
+ }
+ throw new Error('Too many redirects');
+}
diff --git a/packages/server/verify-livekit.ts b/packages/server/verify-livekit.ts
index 15c570e6..4b9c1007 100644
--- a/packages/server/verify-livekit.ts
+++ b/packages/server/verify-livekit.ts
@@ -17,9 +17,32 @@ import { AccessToken, RoomServiceClient } from 'livekit-server-sdk';
const __dirname = dirname(fileURLToPath(import.meta.url));
dotenvConfig({ path: resolve(__dirname, '../../.env') });
-const LIVEKIT_URL = process.env.LIVEKIT_URL || 'wss://nova.ddns.net/livekit';
-const API_KEY = process.env.LIVEKIT_API_KEY || 'REDACTED_LIVEKIT_KEY';
-const API_SECRET = process.env.LIVEKIT_API_SECRET || 'REDACTED_LIVEKIT_SECRET';
+// All values come from the environment (loaded from .env above). Never hardcode
+// credentials, hostnames, or LAN addresses here — this script ships in the repo.
+const env = {
+ url: process.env.LIVEKIT_URL,
+ apiKey: process.env.LIVEKIT_API_KEY,
+ apiSecret: process.env.LIVEKIT_API_SECRET,
+};
+
+const missing = (['url', 'apiKey', 'apiSecret'] as const)
+ .filter((k) => !env[k])
+ .map((k) => ({ url: 'LIVEKIT_URL', apiKey: 'LIVEKIT_API_KEY', apiSecret: 'LIVEKIT_API_SECRET' }[k]));
+
+if (missing.length > 0) {
+ console.error('LIVEKIT VERIFICATION FAILED');
+ console.error(`Missing required environment variable(s): ${missing.join(', ')}`);
+ console.error('Set them in packages/server/.env (or the process environment) and re-run.');
+ process.exit(1);
+}
+
+// Narrowed to string: guaranteed present past the guard above.
+const LIVEKIT_URL = env.url as string;
+const API_KEY = env.apiKey as string;
+const API_SECRET = env.apiSecret as string;
+// Optional: a LAN-local LiveKit URL to try first (e.g. http://10.0.0.5:7880),
+// useful when the public domain does not hairpin on the local network.
+const LIVEKIT_LAN_URL = process.env.LIVEKIT_LAN_URL;
async function verify() {
console.log('=== LiveKit Verification ===');
@@ -66,32 +89,35 @@ async function verify() {
// Step 3: Connect to LiveKit server via RoomServiceClient
console.log('[3/3] Connecting to LiveKit server...');
- // The LiveKit server runs on the Pi at 192.168.1.10:7880 (host network mode).
- // The DDNS domain (nova.ddns.net) routes externally but may not loop back on LAN.
- // Try the LAN address first, then fall back to the configured URL.
- const lanUrl = 'http://192.168.1.10:7880';
+ // Convert the configured ws(s):// URL to its http(s):// form for the REST client.
const wanUrl = LIVEKIT_URL.replace('wss://', 'https://').replace('ws://', 'http://');
+ // Optionally try a LAN-local address first (set LIVEKIT_LAN_URL) — useful when
+ // the public domain does not hairpin back to the host on the local network.
+ const candidates = [LIVEKIT_LAN_URL, wanUrl].filter((u): u is string => Boolean(u));
- let roomService: RoomServiceClient;
- let usedUrl: string;
- try {
- roomService = new RoomServiceClient(lanUrl, API_KEY, API_SECRET);
- const rooms = await roomService.listRooms();
- usedUrl = lanUrl;
- console.log(` Server responded via LAN (${lanUrl}). Active rooms: ${rooms.length}`);
- for (const room of rooms) {
- console.log(` - ${room.name} (${room.numParticipants} participants)`);
- }
- } catch {
- console.log(` LAN address unreachable, trying WAN (${wanUrl})...`);
- roomService = new RoomServiceClient(wanUrl, API_KEY, API_SECRET);
- const rooms = await roomService.listRooms();
- usedUrl = wanUrl;
- console.log(` Server responded via WAN (${wanUrl}). Active rooms: ${rooms.length}`);
- for (const room of rooms) {
- console.log(` - ${room.name} (${room.numParticipants} participants)`);
+ let usedUrl = '';
+ let lastError: unknown;
+ for (const candidate of candidates) {
+ try {
+ const roomService = new RoomServiceClient(candidate, API_KEY, API_SECRET);
+ const rooms = await roomService.listRooms();
+ usedUrl = candidate;
+ console.log(` Server responded via ${candidate}. Active rooms: ${rooms.length}`);
+ for (const room of rooms) {
+ console.log(` - ${room.name} (${room.numParticipants} participants)`);
+ }
+ break;
+ } catch (err) {
+ lastError = err;
+ console.log(` ${candidate} unreachable${candidates.length > 1 ? ', trying next…' : ''}`);
}
}
+ if (!usedUrl) {
+ throw new Error(
+ `Could not reach the LiveKit server at any of: ${candidates.join(', ')}`,
+ { cause: lastError },
+ );
+ }
console.log(` ✓ LiveKit server is reachable at ${usedUrl} and credentials are valid`);
console.log('');
diff --git a/packages/shared/package.json b/packages/shared/package.json
index b3cf7d97..79b40de4 100644
--- a/packages/shared/package.json
+++ b/packages/shared/package.json
@@ -2,6 +2,8 @@
"name": "@backspace/shared",
"version": "1.0.0",
"private": true,
+ "license": "Elastic-2.0",
+ "author": "Jannis Braun",
"type": "module",
"main": "./src/types.ts",
"types": "./src/types.ts",
diff --git a/packages/web/package.json b/packages/web/package.json
index 41580096..21814e4c 100644
--- a/packages/web/package.json
+++ b/packages/web/package.json
@@ -2,6 +2,8 @@
"name": "@backspace/web",
"version": "1.0.0",
"private": true,
+ "license": "Elastic-2.0",
+ "author": "Jannis Braun",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/packages/web/public/fonts/OFL.txt b/packages/web/public/fonts/OFL.txt
new file mode 100644
index 00000000..19a32766
--- /dev/null
+++ b/packages/web/public/fonts/OFL.txt
@@ -0,0 +1,93 @@
+Copyright 2014 The DM Sans Project Authors (https://github.com/googlefonts/dm-fonts)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+https://openfontlicense.org
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/packages/web/src/components/chat/SystemMessage.test.tsx b/packages/web/src/components/chat/SystemMessage.test.tsx
index 18283be7..63ebdfe7 100644
--- a/packages/web/src/components/chat/SystemMessage.test.tsx
+++ b/packages/web/src/components/chat/SystemMessage.test.tsx
@@ -18,8 +18,8 @@ vi.mock('../../api/client', () => ({
const actor: User = {
id: 'U1',
- username: 'jannis',
- displayName: 'Jannis',
+ username: 'heidi',
+ displayName: 'Heidi',
avatar: null,
banner: null,
accentColor: null,
@@ -66,18 +66,18 @@ function renderSM(message: MessageWithUser, dmArg: Pick |
}
describe('SystemMessage — name_changed', () => {
- it('newName="Cool Group" with resolvable actor → "✎ Jannis renamed the group to \\"Cool Group\\""', () => {
+ it('newName="Cool Group" with resolvable actor → "✎ Heidi renamed the group to \\"Cool Group\\""', () => {
const msg = buildMessage({ event: 'name_changed', oldName: null, newName: 'Cool Group' });
renderSM(msg, dm);
expect(screen.getByText('✎')).toBeDefined();
- expect(screen.getByText(/Jannis renamed the group to "Cool Group"/)).toBeDefined();
+ expect(screen.getByText(/Heidi renamed the group to "Cool Group"/)).toBeDefined();
});
- it('newName=null (cleared) with resolvable actor → "✎ Jannis cleared the group name"', () => {
+ it('newName=null (cleared) with resolvable actor → "✎ Heidi cleared the group name"', () => {
const msg = buildMessage({ event: 'name_changed', oldName: 'Old', newName: null });
renderSM(msg, dm);
expect(screen.getByText('✎')).toBeDefined();
- expect(screen.getByText(/Jannis cleared the group name/)).toBeDefined();
+ expect(screen.getByText(/Heidi cleared the group name/)).toBeDefined();
});
it('unresolvable actor (member missing from roster) → "✎ Unknown renamed …"', () => {
@@ -88,11 +88,11 @@ describe('SystemMessage — name_changed', () => {
});
describe('SystemMessage — icon_changed', () => {
- it('resolvable actor → "🖼 Jannis updated the group icon"', () => {
+ it('resolvable actor → "🖼 Heidi updated the group icon"', () => {
const msg = buildMessage({ event: 'icon_changed' });
renderSM(msg, dm);
// The 🖼 character is U+1F5BC (FRAME WITH PICTURE), not 🖼️ (with VS-16).
expect(screen.getByText('\u{1F5BC}')).toBeDefined();
- expect(screen.getByText(/Jannis updated the group icon/)).toBeDefined();
+ expect(screen.getByText(/Heidi updated the group icon/)).toBeDefined();
});
});
diff --git a/packages/web/src/stores/instanceStore.registrySync.test.ts b/packages/web/src/stores/instanceStore.registrySync.test.ts
index 7af40561..79dee646 100644
--- a/packages/web/src/stores/instanceStore.registrySync.test.ts
+++ b/packages/web/src/stores/instanceStore.registrySync.test.ts
@@ -33,11 +33,11 @@ vi.mock('../audio/AudioManager', () => ({
const mockUser = {
id: 'user-1',
- username: 'youruser',
+ username: 'erin',
homeInstance: null,
homeUserId: 'user-1',
replicatedInstances: [
- { origin: 'https://orbit.example', username: 'youruser@nova.example' },
+ { origin: 'https://orbit.example', username: 'erin@nova.example' },
],
};
@@ -90,7 +90,7 @@ describe('instanceStore registry sync gating', () => {
registry: [{
origin: 'https://orbit.example',
label: 'Orbit',
- username: 'youruser@nova.example',
+ username: 'erin@nova.example',
remoteUserId: 'remote-1',
status: 'auth_expired',
addedAt: 1,
diff --git a/packages/web/src/stores/instanceStore.ts b/packages/web/src/stores/instanceStore.ts
index f9523872..80f31d95 100644
--- a/packages/web/src/stores/instanceStore.ts
+++ b/packages/web/src/stores/instanceStore.ts
@@ -242,7 +242,7 @@ export const useInstanceStore = create((set, get) => ({
}
// Step 2: Compute the user's true home identity
- // If we're a federated user (e.g. youruser@nova browsing orbit),
+ // If we're a federated user (e.g. erin@nova browsing orbit),
// homeInstance points to the real home, not window.location.host.
const trueHomeHost = currentUser.homeInstance ?? window.location.host;
const bareUsername = currentUser.username.includes('@')
diff --git a/packages/web/src/stores/spaceStore.userViews.test.ts b/packages/web/src/stores/spaceStore.userViews.test.ts
index 649a4910..f29f94b9 100644
--- a/packages/web/src/stores/spaceStore.userViews.test.ts
+++ b/packages/web/src/stores/spaceStore.userViews.test.ts
@@ -75,28 +75,28 @@ describe('spaceStore.upsertUserView preference rule', () => {
});
it('home view (delivered by user home) wins over an existing stub', () => {
- // orbit delivers Axel as a federated stub (axel's home is nova).
+ // orbit delivers Frank as a federated stub (frank's home is nova).
const stubAxel = makeUser({
id: 'orbit-local-id',
- username: 'axel@nova.ddns.net',
- homeUserId: 'nova-axel-id',
+ username: 'frank@nova.ddns.net',
+ homeUserId: 'nova-frank-id',
homeInstance: 'nova.ddns.net',
avatarColor: 'lavender',
avatar: 'https://nova.ddns.net/api/uploads/old.png',
});
useSpaceStore.getState().upsertUserView(stubAxel, 'https://orbit.ddns.net');
- // Then nova delivers axel natively (no homeInstance, our home origin '').
+ // Then nova delivers frank natively (no homeInstance, our home origin '').
// canonicalUserKey for the home view: needs to match the stub's key.
- // Stub key = "nova.ddns.net:nova-axel-id".
- // Home view (nova native): homeInstance=null, homeUserId=null, id="nova-axel-id"
- // → key = ":nova-axel-id"
+ // Stub key = "nova.ddns.net:nova-frank-id".
+ // Home view (nova native): homeInstance=null, homeUserId=null, id="nova-frank-id"
+ // → key = ":nova-frank-id"
// These keys are different on purpose: the home record on its home instance
// has no homeInstance/homeUserId. The cross-instance match relies on the
// stub being the federated form. Verify behavior accordingly.
const homeAxel = makeUser({
- id: 'nova-axel-id',
- username: 'axel',
+ id: 'nova-frank-id',
+ username: 'frank',
avatar: '',
avatarColor: 'teal',
});
@@ -111,19 +111,19 @@ describe('spaceStore.upsertUserView preference rule', () => {
});
it('two same-canonical-key federated views: home delivery upgrades over sibling stub', () => {
- // Same person, same canonical key (homeInstance=nova, homeUserId=nova-axel-id),
+ // Same person, same canonical key (homeInstance=nova, homeUserId=nova-frank-id),
// but delivered from two different origins.
const fromOrbit = makeUser({
id: 'orbit-local',
- username: 'axel@nova.ddns.net',
- homeUserId: 'nova-axel-id',
+ username: 'frank@nova.ddns.net',
+ homeUserId: 'nova-frank-id',
homeInstance: 'nova.ddns.net',
avatarColor: 'lavender',
});
const fromNova = makeUser({
id: 'nova-local',
- username: 'axel@nova.ddns.net',
- homeUserId: 'nova-axel-id',
+ username: 'frank@nova.ddns.net',
+ homeUserId: 'nova-frank-id',
homeInstance: 'nova.ddns.net',
avatarColor: 'teal',
});
@@ -140,15 +140,15 @@ describe('spaceStore.upsertUserView preference rule', () => {
it('stub view does NOT overwrite an existing home view', () => {
const fromNova = makeUser({
id: 'nova-local',
- username: 'axel@nova.ddns.net',
- homeUserId: 'nova-axel-id',
+ username: 'frank@nova.ddns.net',
+ homeUserId: 'nova-frank-id',
homeInstance: 'nova.ddns.net',
avatarColor: 'teal',
});
const fromOrbit = makeUser({
id: 'orbit-local',
- username: 'axel@nova.ddns.net',
- homeUserId: 'nova-axel-id',
+ username: 'frank@nova.ddns.net',
+ homeUserId: 'nova-frank-id',
homeInstance: 'nova.ddns.net',
avatarColor: 'lavender',
});
@@ -165,15 +165,15 @@ describe('spaceStore.upsertUserView preference rule', () => {
it('same-tier writes update freshness (later write wins)', () => {
const a = makeUser({
id: 'orbit-1',
- username: 'axel@nova.ddns.net',
- homeUserId: 'nova-axel-id',
+ username: 'frank@nova.ddns.net',
+ homeUserId: 'nova-frank-id',
homeInstance: 'nova.ddns.net',
avatarColor: 'lavender',
});
const b = makeUser({
id: 'orbit-1',
- username: 'axel@nova.ddns.net',
- homeUserId: 'nova-axel-id',
+ username: 'frank@nova.ddns.net',
+ homeUserId: 'nova-frank-id',
homeInstance: 'nova.ddns.net',
avatarColor: 'sky', // simulating a later profile-update event
});
@@ -195,14 +195,14 @@ describe('spaceStore.upsertUserView preference rule', () => {
it('removeInstanceSpaces prunes entries delivered by the removed origin only', () => {
const homeView = makeUser({
- id: 'nova-axel-id',
- username: 'axel',
+ id: 'nova-frank-id',
+ username: 'frank',
avatarColor: 'teal',
});
const stubView = makeUser({
- id: 'orbit-axel-stub',
- username: 'axel@nova.ddns.net',
- homeUserId: 'nova-axel-id',
+ id: 'orbit-frank-stub',
+ username: 'frank@nova.ddns.net',
+ homeUserId: 'nova-frank-id',
homeInstance: 'nova.ddns.net',
avatarColor: 'lavender',
});
@@ -221,8 +221,8 @@ describe('spaceStore.upsertUserView preference rule', () => {
it('removeInstanceSpaces of the home origin evicts entries it delivered', () => {
const homeView = makeUser({
- id: 'nova-axel-id',
- username: 'axel',
+ id: 'nova-frank-id',
+ username: 'frank',
avatarColor: 'teal',
});
useSpaceStore.getState().upsertUserView(homeView, '');
@@ -231,20 +231,20 @@ describe('spaceStore.upsertUserView preference rule', () => {
});
it('treats native users delivered by a remote as that remote\'s home view', () => {
- // jannis is native to orbit (homeInstance=null on orbit). When orbit
+ // heidi is native to orbit (homeInstance=null on orbit). When orbit
// delivers him, that's the home view. canonicalKey uses orbit-host.
- const jannis = makeUser({
- id: 'orbit-jannis-id',
- username: 'jannis',
+ const heidi = makeUser({
+ id: 'orbit-heidi-id',
+ username: 'heidi',
avatarColor: 'sky',
});
- useSpaceStore.getState().upsertUserView(jannis, 'https://orbit.ddns.net');
- // Key is built from user.homeInstance — but jannis has none. So the key is
- // ':orbit-jannis-id'. That's correct: when delivered later from a sibling,
- // jannis would arrive WITH homeInstance set (synthesized by normalizeUserAssets),
+ useSpaceStore.getState().upsertUserView(heidi, 'https://orbit.ddns.net');
+ // Key is built from user.homeInstance — but heidi has none. So the key is
+ // ':orbit-heidi-id'. That's correct: when delivered later from a sibling,
+ // heidi would arrive WITH homeInstance set (synthesized by normalizeUserAssets),
// producing a different (federated) key. The cache holds both, with the
// home view winning on a cross-key collision-free basis.
- const entry = useSpaceStore.getState().userViews.get(`:${jannis.id}`);
+ const entry = useSpaceStore.getState().userViews.get(`:${heidi.id}`);
expect(entry?.isHome).toBe(true);
});
});
diff --git a/packages/web/src/test/setup.ts b/packages/web/src/test/setup.ts
index ffce9c32..4fad696b 100644
--- a/packages/web/src/test/setup.ts
+++ b/packages/web/src/test/setup.ts
@@ -35,6 +35,23 @@ for (const name of ['localStorage', 'sessionStorage'] as const) {
});
}
+// jsdom does not implement navigator.mediaDevices. Provide a default no-op stub
+// so components that enumerate devices or subscribe to `devicechange` (e.g.
+// MobileVoiceFullScreen) don't crash during render. Tests that need real device
+// behavior override it per-test (configurable: true).
+if (!navigator.mediaDevices) {
+ Object.defineProperty(navigator, 'mediaDevices', {
+ value: {
+ enumerateDevices: () => Promise.resolve([]),
+ getUserMedia: () => Promise.reject(new Error('mediaDevices.getUserMedia not available in tests')),
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ },
+ configurable: true,
+ writable: true,
+ });
+}
+
// Polyfill ClipboardItem for jsdom (not included in jsdom)
if (typeof ClipboardItem === 'undefined') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
diff --git a/packages/web/src/utils/dmFormatters.test.ts b/packages/web/src/utils/dmFormatters.test.ts
index e2736bae..f7a4171d 100644
--- a/packages/web/src/utils/dmFormatters.test.ts
+++ b/packages/web/src/utils/dmFormatters.test.ts
@@ -185,8 +185,8 @@ describe('formatDmPreview', () => {
const actor: User = {
id: 'U1',
- username: 'jannis',
- displayName: 'Jannis',
+ username: 'heidi',
+ displayName: 'Heidi',
avatarColor: 'mint',
avatar: null,
bio: null,
@@ -217,7 +217,7 @@ describe('formatDmSidebarPreview — name_changed system message', () => {
content: JSON.stringify({ event: 'name_changed', oldName: null, newName: 'Cool Group' }),
createdAt: 1,
});
- expect(formatDmSidebarPreview(dm, { id: 'OTHER', username: 'other' })).toBe('Jannis renamed the group');
+ expect(formatDmSidebarPreview(dm, { id: 'OTHER', username: 'other' })).toBe('Heidi renamed the group');
});
it('newName=null (cleared) → " cleared the group name"', () => {
@@ -227,7 +227,7 @@ describe('formatDmSidebarPreview — name_changed system message', () => {
content: JSON.stringify({ event: 'name_changed', oldName: 'Old', newName: null }),
createdAt: 1,
});
- expect(formatDmSidebarPreview(dm, { id: 'OTHER', username: 'other' })).toBe('Jannis cleared the group name');
+ expect(formatDmSidebarPreview(dm, { id: 'OTHER', username: 'other' })).toBe('Heidi cleared the group name');
});
it('unresolvable actor → "Unknown renamed the group"', () => {
@@ -391,7 +391,7 @@ describe('formatDmSidebarPreview — icon_changed system message', () => {
content: JSON.stringify({ event: 'icon_changed' }),
createdAt: 1,
});
- expect(formatDmSidebarPreview(dm, { id: 'OTHER', username: 'other' })).toBe('Jannis updated the group icon');
+ expect(formatDmSidebarPreview(dm, { id: 'OTHER', username: 'other' })).toBe('Heidi updated the group icon');
});
it('unresolvable actor → "Unknown updated the group icon"', () => {
diff --git a/packages/web/src/utils/dmFormatters.ts b/packages/web/src/utils/dmFormatters.ts
index dfe9b752..d60d6f50 100644
--- a/packages/web/src/utils/dmFormatters.ts
+++ b/packages/web/src/utils/dmFormatters.ts
@@ -299,7 +299,7 @@ export function formatDmHeaderName(dm: DmChannel, currentUser: AuthLike): string
*
* The unnamed-group case intentionally collapses to a generic noun: the
* joined-names form is unreadable as a one-line placeholder once a group
- * has 4+ members ("Message #Test, Nova, youruser, Nova" runs off-screen
+ * has 4+ members ("Message #Test, Nova, erin, Nova" runs off-screen
* and obscures the actual call-to-action).
*/
export function formatDmInputLabel(dm: DmChannel, currentUser: AuthLike): string {
diff --git a/packages/web/src/utils/identity.test.ts b/packages/web/src/utils/identity.test.ts
index f92873ae..809dcedd 100644
--- a/packages/web/src/utils/identity.test.ts
+++ b/packages/web/src/utils/identity.test.ts
@@ -16,7 +16,7 @@ describe('normalizeOriginToHost', () => {
it('extracts host from full URLs', () => {
expect(normalizeOriginToHost('https://nova.ddns.net')).toBe('nova.ddns.net');
expect(normalizeOriginToHost('http://localhost:3000')).toBe('localhost:3000');
- expect(normalizeOriginToHost('https://orbit.example.com:8443/path')).toBe('orbit.example.com:8443');
+ expect(normalizeOriginToHost('https://orbit.ddns.net:8443/path')).toBe('orbit.ddns.net:8443');
});
it('returns bare-domain inputs unchanged', () => {
@@ -47,12 +47,12 @@ describe('canonicalUserKey', () => {
it('produces the same key for stubs of the same person across instances', () => {
const fromOrbit = canonicalUserKey({
id: 'orbitLocalId',
- homeUserId: 'nova-axel',
+ homeUserId: 'nova-frank',
homeInstance: 'nova.ddns.net',
});
const fromAnotherPeer = canonicalUserKey({
id: 'otherPeerLocalId',
- homeUserId: 'nova-axel',
+ homeUserId: 'nova-frank',
homeInstance: 'nova.ddns.net',
});
expect(fromOrbit).toBe(fromAnotherPeer);
@@ -109,7 +109,7 @@ describe('isDeliveryFromHome', () => {
)).toBe(true);
});
- it('rejects sibling-stub deliveries (orbit delivering Axel whose home is nova)', () => {
+ it('rejects sibling-stub deliveries (orbit delivering Frank whose home is nova)', () => {
expect(isDeliveryFromHome(
{ homeInstance: 'nova.ddns.net' },
'https://orbit.ddns.net',
@@ -141,16 +141,16 @@ describe('isFederationGlobeApplicable', () => {
});
it('returns false for purely-local users (no @domain in username)', () => {
- expect(isFederationGlobeApplicable({ username: 'axel' })).toBe(false);
- expect(isFederationGlobeApplicable({ username: 'youruser' })).toBe(false);
+ expect(isFederationGlobeApplicable({ username: 'frank' })).toBe(false);
+ expect(isFederationGlobeApplicable({ username: 'erin' })).toBe(false);
});
it('returns false when the username domain matches our own host (the load-bearing case)', () => {
- // Logged in to nova; viewing orbit-stub of Axel whose username is "axel@nova.ddns.net".
- expect(isFederationGlobeApplicable({ username: 'axel@nova.ddns.net' })).toBe(false);
+ // Logged in to nova; viewing orbit-stub of Frank whose username is "frank@nova.ddns.net".
+ expect(isFederationGlobeApplicable({ username: 'frank@nova.ddns.net' })).toBe(false);
});
it('returns true for genuinely remote users', () => {
- expect(isFederationGlobeApplicable({ username: 'jannis@orbit.ddns.net' })).toBe(true);
+ expect(isFederationGlobeApplicable({ username: 'heidi@orbit.ddns.net' })).toBe(true);
});
});
diff --git a/packages/web/src/utils/identity.ts b/packages/web/src/utils/identity.ts
index 5f44c6b7..03e4f1e5 100644
--- a/packages/web/src/utils/identity.ts
+++ b/packages/web/src/utils/identity.ts
@@ -2,8 +2,8 @@ import type { User } from '@backspace/shared';
/**
* Splits a potentially federated username into base name and domain.
- * "youruser@nova.ddns.net" → { baseName: "youruser", domain: "nova.ddns.net" }
- * "youruser" → { baseName: "youruser", domain: null }
+ * "erin@nova.ddns.net" → { baseName: "erin", domain: "nova.ddns.net" }
+ * "erin" → { baseName: "erin", domain: null }
*/
export function parseFederatedUsername(username: string): { baseName: string; domain: string | null } {
const atIndex = username.indexOf('@');
@@ -42,7 +42,7 @@ export function isSelf(
// Replicated user: homeInstance matches our origin
if (!user.homeInstance) return false;
if (user.homeInstance !== window.location.host) return false;
- // Username: "youruser" or "youruser@nova.ddns.net" → base must match
+ // Username: "erin" or "erin@nova.ddns.net" → base must match
const { baseName } = parseFederatedUsername(user.username);
const { baseName: homeBase } = parseFederatedUsername(homeUser.username);
return baseName === homeBase;
@@ -150,7 +150,7 @@ export function isDeliveryFromHome(
*
* True iff the user is genuinely remote: their username carries an `@domain`
* suffix AND that domain is NOT our own host. Catches the bug where a stub
- * delivered by a sibling instance (e.g. orbit-side `axel@nova.ddns.net`
+ * delivered by a sibling instance (e.g. orbit-side `frank@nova.ddns.net`
* viewed from a session logged in to nova) would otherwise show the globe.
*
* Compose with {@link useCanonicalUserView} at render sites: resolve the
diff --git a/packages/web/src/utils/userViewLookup.test.ts b/packages/web/src/utils/userViewLookup.test.ts
index f5e1ef4f..207fb138 100644
--- a/packages/web/src/utils/userViewLookup.test.ts
+++ b/packages/web/src/utils/userViewLookup.test.ts
@@ -66,9 +66,9 @@ beforeEach(() => {
describe('getCanonicalUserView', () => {
it('returns the input unchanged on cache miss', () => {
const stub = makeUser({
- id: 'orbit-axel-stub',
- username: 'axel@nova.ddns.net',
- homeUserId: 'nova-axel-id',
+ id: 'orbit-frank-stub',
+ username: 'frank@nova.ddns.net',
+ homeUserId: 'nova-frank-id',
homeInstance: 'nova.ddns.net',
avatarColor: 'lavender',
});
@@ -77,16 +77,16 @@ describe('getCanonicalUserView', () => {
it('returns the cached entry when one exists for the same canonical key', () => {
const stub = makeUser({
- id: 'orbit-axel-stub',
- username: 'axel@nova.ddns.net',
- homeUserId: 'nova-axel-id',
+ id: 'orbit-frank-stub',
+ username: 'frank@nova.ddns.net',
+ homeUserId: 'nova-frank-id',
homeInstance: 'nova.ddns.net',
avatarColor: 'lavender',
});
const homeFromNova = makeUser({
id: 'nova-local-id',
- username: 'axel@nova.ddns.net',
- homeUserId: 'nova-axel-id',
+ username: 'frank@nova.ddns.net',
+ homeUserId: 'nova-frank-id',
homeInstance: 'nova.ddns.net',
avatarColor: 'teal',
});
@@ -106,9 +106,9 @@ describe('getCanonicalUserView', () => {
useSpaceStore.getState().upsertUserView(someOther, '');
const stub = makeUser({
- id: 'orbit-axel-stub',
- username: 'axel@nova.ddns.net',
- homeUserId: 'nova-axel-id',
+ id: 'orbit-frank-stub',
+ username: 'frank@nova.ddns.net',
+ homeUserId: 'nova-frank-id',
homeInstance: 'nova.ddns.net',
});
expect(getCanonicalUserView(stub)).toBe(stub);
diff --git a/packages/web/src/utils/userViewLookup.ts b/packages/web/src/utils/userViewLookup.ts
index e752bcab..3c306400 100644
--- a/packages/web/src/utils/userViewLookup.ts
+++ b/packages/web/src/utils/userViewLookup.ts
@@ -24,7 +24,7 @@ export function getCanonicalUserView(user: User): User {
/**
* Reactive lookup into the userViews cache. Subscribes to the specific cache
* entry so the calling component re-renders when an upsert lands a better
- * view (e.g. nova's home view of Axel arriving after orbit's stub
+ * view (e.g. nova's home view of Frank arriving after orbit's stub
* populated the cache first). Returns the input unchanged on cache miss; the
* site falls back to the current best information until the cache fills.
*