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 + # 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: Elastic License 2.0](https://img.shields.io/badge/license-Elastic%20License%202.0-2563eb.svg)](LICENSE) +[![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178c6.svg)](https://www.typescriptlang.org/) +[![Node.js](https://img.shields.io/badge/node-%E2%89%A520-339933.svg)](https://nodejs.org/) +[![Status](https://img.shields.io/badge/status-active%20development-f59e0b.svg)](#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. *