Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e6c7c6042 | ||
|
|
43cab41e60 | ||
|
|
d76e06a023 | ||
|
|
c79bf91398 | ||
|
|
94fe73522d | ||
|
|
180228f2d1 | ||
|
|
c7d88481ad | ||
|
|
9b6d1b18eb | ||
|
|
628e4dec3e | ||
|
|
0727d5a3b3 | ||
|
|
3513a3dde9 | ||
|
|
95b545d8b2 | ||
|
|
85e1975fa5 | ||
|
|
26bb0be7af | ||
|
|
e27e52f267 | ||
|
|
531b496618 | ||
|
|
e7f41b5609 | ||
|
|
24accc3647 | ||
|
|
028005d487 | ||
|
|
9d3f72be75 |
@@ -4,19 +4,54 @@
|
|||||||
# Your server's public domain name (required)
|
# Your server's public domain name (required)
|
||||||
DOMAIN=example.com
|
DOMAIN=example.com
|
||||||
|
|
||||||
|
# ─── Deployment mode ───────────────────────────────────────
|
||||||
|
# How this instance is exposed to the internet. ./install.sh sets these for you;
|
||||||
|
# only touch them for a fully manual setup.
|
||||||
|
#
|
||||||
|
# allinone (default) — the bundled Caddy owns ports 80/443 and does automatic
|
||||||
|
# HTTPS for DOMAIN. Requires 80/443 free on this host.
|
||||||
|
# proxy — you run your OWN reverse proxy (nginx, Traefik, Caddy,
|
||||||
|
# Nginx Proxy Manager, SWAG …). No bundled Caddy; the app
|
||||||
|
# is published on 127.0.0.1:APP_PORT for your proxy to
|
||||||
|
# forward to. Use with:
|
||||||
|
# docker compose -f docker-compose.yml \
|
||||||
|
# -f docker-compose.proxy.yml up -d
|
||||||
|
# tunnel — same as proxy, but fronted by a tunnel (Cloudflare
|
||||||
|
# Tunnel, Tailscale …). Point the tunnel's ingress at
|
||||||
|
# http://127.0.0.1:APP_PORT. NOTE: voice/WebRTC does NOT
|
||||||
|
# traverse a tunnel, and Cloudflare caps request bodies
|
||||||
|
# at 100 MB — set MAX_UPLOAD_SIZE below that (see below).
|
||||||
|
DEPLOY_MODE=allinone
|
||||||
|
|
||||||
|
# Host loopback port the app is published on in `proxy`/`tunnel` mode (ignored in
|
||||||
|
# `allinone` mode). Your reverse proxy / tunnel forwards to 127.0.0.1:APP_PORT.
|
||||||
|
# APP_PORT=8080
|
||||||
|
|
||||||
# ─── Server ─────────────────────────────────────────────────
|
# ─── Server ─────────────────────────────────────────────────
|
||||||
# Production/Docker listen port (Caddy reverse-proxies to it). Local development
|
# 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`.
|
# ignores this and uses 3005 — the Vite dev proxy target — set by `pnpm dev`.
|
||||||
PORT=3000
|
PORT=3000
|
||||||
HOST=0.0.0.0
|
HOST=0.0.0.0
|
||||||
|
|
||||||
# Authentication — generate with: openssl rand -hex 32
|
# ─── Authentication (REQUIRED) ──────────────────────────────
|
||||||
|
# Secret that signs login tokens. This MUST be set to a strong random value.
|
||||||
|
# Leaving it empty is intentional — `docker compose up` then fails immediately
|
||||||
|
# with a clear message instead of the server boot-looping. Generate one with:
|
||||||
|
#
|
||||||
|
# openssl rand -hex 32
|
||||||
|
#
|
||||||
|
# (./install.sh fills this in for you automatically — you only touch this when
|
||||||
|
# configuring by hand.) Must be at least 32 characters.
|
||||||
JWT_SECRET=
|
JWT_SECRET=
|
||||||
|
|
||||||
# Registration — set to false to close signups after initial setup
|
# Registration — set to false to close signups after initial setup
|
||||||
REGISTRATION_OPEN=true
|
REGISTRATION_OPEN=true
|
||||||
|
|
||||||
# Max file upload size in bytes (default: 100MB)
|
# Max file upload size in bytes (default: 100MB)
|
||||||
|
# TUNNEL USERS: Cloudflare (free/pro) hard-caps request bodies at 100 MB, so the
|
||||||
|
# 100 MB default lets uploads fail at the edge. Set this below the cap, e.g.
|
||||||
|
# 94371840 (90 MB), leaving headroom for multipart overhead. install.sh does
|
||||||
|
# this automatically when you pick `tunnel` mode.
|
||||||
MAX_UPLOAD_SIZE=104857600
|
MAX_UPLOAD_SIZE=104857600
|
||||||
|
|
||||||
# ─── AGPL-3.0 § 13 Source Offer ────────────────────────────
|
# ─── AGPL-3.0 § 13 Source Offer ────────────────────────────
|
||||||
@@ -31,6 +66,13 @@ MAX_UPLOAD_SIZE=104857600
|
|||||||
# dev — the app reports commit=null. Set manually only for custom build pipelines.
|
# dev — the app reports commit=null. Set manually only for custom build pipelines.
|
||||||
# BACKSPACE_COMMIT=
|
# BACKSPACE_COMMIT=
|
||||||
|
|
||||||
|
# ─── Prebuilt image (GHCR) ─────────────────────────────────
|
||||||
|
# By default the stack pulls the prebuilt multi-arch image
|
||||||
|
# ghcr.io/thezwiss/backspace:latest (published from tagged releases). Pin a
|
||||||
|
# specific version for reproducibility, or point at your own fork's registry.
|
||||||
|
# BACKSPACE_IMAGE=ghcr.io/thezwiss/backspace
|
||||||
|
# BACKSPACE_IMAGE_TAG=latest
|
||||||
|
|
||||||
# ─── LiveKit Voice/Video ───────────────────────────────────
|
# ─── LiveKit Voice/Video ───────────────────────────────────
|
||||||
# To enable voice/video, fill in all three values below and add:
|
# To enable voice/video, fill in all three values below and add:
|
||||||
# COMPOSE_PROFILES=voice
|
# COMPOSE_PROFILES=voice
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
name: Bug report
|
||||||
|
description: Report something that is broken or behaving incorrectly.
|
||||||
|
labels: ["bug"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thanks for taking the time to file a bug. Please search existing issues first to avoid duplicates.
|
||||||
|
For security vulnerabilities, do not open a public issue. See SECURITY.md and report privately.
|
||||||
|
- type: textarea
|
||||||
|
id: what-happened
|
||||||
|
attributes:
|
||||||
|
label: What happened
|
||||||
|
description: A clear description of the bug and what you expected instead.
|
||||||
|
placeholder: When I do X, Y happens. I expected Z.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: repro
|
||||||
|
attributes:
|
||||||
|
label: Steps to reproduce
|
||||||
|
description: The exact steps that trigger the problem.
|
||||||
|
placeholder: |
|
||||||
|
1. Go to ...
|
||||||
|
2. Click ...
|
||||||
|
3. See ...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: deploy-mode
|
||||||
|
attributes:
|
||||||
|
label: Deployment mode
|
||||||
|
description: How is this instance deployed?
|
||||||
|
options:
|
||||||
|
- All-in-One (bundled Caddy)
|
||||||
|
- Behind my own reverse proxy
|
||||||
|
- Behind a tunnel (Cloudflare, Tailscale)
|
||||||
|
- Local development (pnpm dev)
|
||||||
|
- Not sure
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: surface
|
||||||
|
attributes:
|
||||||
|
label: Where did it happen
|
||||||
|
description: Which client were you using?
|
||||||
|
options:
|
||||||
|
- Web browser
|
||||||
|
- Desktop app (Electron)
|
||||||
|
- Installed PWA on mobile
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: Backspace version
|
||||||
|
description: The version shown in the app, or the image tag / commit you deployed.
|
||||||
|
placeholder: "1.0.0"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: subsystems
|
||||||
|
attributes:
|
||||||
|
label: Does it involve voice or federation
|
||||||
|
description: These subsystems have their own moving parts, so it helps to know up front.
|
||||||
|
multiple: true
|
||||||
|
options:
|
||||||
|
- Voice or video (LiveKit)
|
||||||
|
- Screen sharing
|
||||||
|
- Federation between instances
|
||||||
|
- None of these
|
||||||
|
- type: textarea
|
||||||
|
id: logs
|
||||||
|
attributes:
|
||||||
|
label: Relevant logs
|
||||||
|
description: Server logs (docker compose logs backspace) or browser console output, if any. This is automatically formatted as code.
|
||||||
|
render: shell
|
||||||
|
- type: input
|
||||||
|
id: environment
|
||||||
|
attributes:
|
||||||
|
label: Browser and OS
|
||||||
|
placeholder: "Firefox 128 on Ubuntu 24.04, or Chrome 126 on Windows 11"
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: Security vulnerability
|
||||||
|
url: https://github.com/TheZwiss/backspace/security/advisories/new
|
||||||
|
about: Do not open a public issue for security problems. Report privately here.
|
||||||
|
- name: Question or setup help
|
||||||
|
url: https://github.com/TheZwiss/backspace/discussions
|
||||||
|
about: For usage questions, deployment help, and general discussion.
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
name: Feature request
|
||||||
|
description: Suggest a new capability or an improvement to an existing one.
|
||||||
|
labels: ["enhancement"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Open an issue before starting work on anything non-trivial, so we can agree on direction first.
|
||||||
|
Please check the README feature list and existing issues before filing.
|
||||||
|
- type: textarea
|
||||||
|
id: problem
|
||||||
|
attributes:
|
||||||
|
label: The problem
|
||||||
|
description: What are you trying to do that Backspace does not support today?
|
||||||
|
placeholder: As a space admin, I want to ... so that ...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: proposal
|
||||||
|
attributes:
|
||||||
|
label: Proposed solution
|
||||||
|
description: How you imagine it working. Rough ideas are fine.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: area
|
||||||
|
attributes:
|
||||||
|
label: Area
|
||||||
|
description: Which part of the project does this touch?
|
||||||
|
options:
|
||||||
|
- Text chat and messaging
|
||||||
|
- Voice, video, or screen sharing
|
||||||
|
- Spaces, roles, and permissions
|
||||||
|
- Direct messages and friends
|
||||||
|
- Federation between instances
|
||||||
|
- Admin and moderation
|
||||||
|
- Desktop app
|
||||||
|
- Mobile and PWA
|
||||||
|
- Deployment and self-hosting
|
||||||
|
- Other
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: alternatives
|
||||||
|
attributes:
|
||||||
|
label: Alternatives considered
|
||||||
|
description: Workarounds you have tried, or how other tools handle this.
|
||||||
|
- type: dropdown
|
||||||
|
id: federation-impact
|
||||||
|
attributes:
|
||||||
|
label: Federation compatibility
|
||||||
|
description: Would this need to work across peered instances? If unsure, leave the default.
|
||||||
|
options:
|
||||||
|
- Not sure
|
||||||
|
- Yes, it should work across federated instances
|
||||||
|
- No, it is local to a single instance
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<!-- Thanks for contributing. Keep one logical change per pull request. -->
|
||||||
|
|
||||||
|
## What this changes
|
||||||
|
|
||||||
|
<!-- A short summary of the change and the problem it solves. Link the issue it addresses. -->
|
||||||
|
|
||||||
|
Closes #
|
||||||
|
|
||||||
|
## Type of change
|
||||||
|
|
||||||
|
- [ ] Bug fix
|
||||||
|
- [ ] New feature
|
||||||
|
- [ ] Refactor or cleanup
|
||||||
|
- [ ] Documentation
|
||||||
|
- [ ] Other
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] `pnpm build` succeeds (shared types, server, and web all build)
|
||||||
|
- [ ] `pnpm dev` starts the server and web client without errors
|
||||||
|
- [ ] Tests pass where applicable (`pnpm test`)
|
||||||
|
- [ ] I updated the relevant `docs/systems/` spec if this changed schema, API routes, WebSocket events, the federation protocol, permissions, or the design system
|
||||||
|
- [ ] This change resolves the correct federated identity where it compares IDs, checks permissions, or talks to remote servers (no assumption of a single global user ID)
|
||||||
|
- [ ] I have read and agree to the [CLA](../CLA.md)
|
||||||
|
|
||||||
|
## Notes for reviewers
|
||||||
|
|
||||||
|
<!-- Anything that helps review: screenshots for UI, migration notes, edge cases, follow-ups. -->
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
# Runs the workspace's typecheck, production build, and full test suite on every
|
||||||
|
# pull request and on pushes to main. Purpose: catch compile errors and test
|
||||||
|
# regressions before merge instead of relying on each contributor running tests
|
||||||
|
# locally. Once this check is green on a PR, enable branch protection on `main`
|
||||||
|
# ("Require status checks to pass" → select "Build & test") to make it blocking.
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
# A newer commit on the same branch/PR supersedes in-flight runs — cancel the
|
||||||
|
# stale one so a rapid push sequence doesn't queue redundant CI.
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-test:
|
||||||
|
name: Build & test (Node ${{ matrix.node-version }})
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
node-version: [20, 24]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v5
|
||||||
|
with:
|
||||||
|
version: 10.34.3
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: ${{ matrix.node-version }}
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
# The desktop postinstall tries to rebuild the native uiohook-napi module.
|
||||||
|
# It fails gracefully (|| warn) without X11 dev headers, and nothing in CI
|
||||||
|
# needs the native binary — the desktop tests and TS compile are pure JS —
|
||||||
|
# so we intentionally skip installing those headers to keep CI fast.
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# Compiles shared → server → web (each runs tsc; web also runs the Vite
|
||||||
|
# production build). This is the typecheck + build gate for those three.
|
||||||
|
- name: Build shared, server & web
|
||||||
|
run: pnpm build
|
||||||
|
|
||||||
|
# Desktop is not part of `pnpm build` (that produces an Electron installer,
|
||||||
|
# which release.yml owns). Type-check its source here so desktop TS errors
|
||||||
|
# surface on PRs rather than only at release-tag time.
|
||||||
|
- name: Typecheck desktop
|
||||||
|
run: pnpm --filter @backspace/desktop build:ts
|
||||||
|
|
||||||
|
# Runs every package's `test` script (server, web, desktop) via vitest.
|
||||||
|
- name: Test
|
||||||
|
run: pnpm -r test
|
||||||
|
|
||||||
|
# Aggregate gate reporting a single, matrix-independent "Build & test" status.
|
||||||
|
# Branch protection on main requires the "Build & test" context, but the matrix
|
||||||
|
# job above reports per-version contexts ("Build & test (Node 20/24)"). This job
|
||||||
|
# keeps the stable required context alive and fails unless every matrix leg
|
||||||
|
# succeeded (if: always() so a matrix failure still reports a definitive result
|
||||||
|
# instead of leaving the required check pending forever).
|
||||||
|
build-and-test-required:
|
||||||
|
name: Build & test
|
||||||
|
if: always()
|
||||||
|
needs: build-and-test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Verify matrix result
|
||||||
|
run: |
|
||||||
|
if [ "${{ needs.build-and-test.result }}" != "success" ]; then
|
||||||
|
echo "Matrix build-and-test did not succeed: ${{ needs.build-and-test.result }}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "All matrix legs passed."
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
name: Deploy landing page
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'site/**'
|
||||||
|
- '.github/workflows/deploy-pages.yml'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pages: write
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: pages
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment:
|
||||||
|
name: github-pages
|
||||||
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
- uses: actions/configure-pages@v6
|
||||||
|
- uses: actions/upload-pages-artifact@v5
|
||||||
|
with:
|
||||||
|
path: ./site
|
||||||
|
- id: deployment
|
||||||
|
uses: actions/deploy-pages@v5
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
name: Publish Container Image
|
||||||
|
|
||||||
|
# Builds and publishes the Backspace application image to the GitHub Container
|
||||||
|
# Registry (GHCR) as a multi-architecture (linux/amd64 + linux/arm64) image, so
|
||||||
|
# self-hosters — including weak/ARM boxes like a Raspberry Pi — can `docker pull`
|
||||||
|
# a prebuilt image instead of building the ~1.6 GB image locally (the Vite build
|
||||||
|
# OOMs small ARM hosts). install.sh and docker-compose.yml default to pulling
|
||||||
|
# this image, with a from-source build as the fallback.
|
||||||
|
#
|
||||||
|
# This is intentionally SEPARATE from the desktop-installer workflow
|
||||||
|
# (release.yml) — they share the `v*` tag trigger but build entirely different
|
||||||
|
# artifacts and must not be entangled.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
# Allow a manual rebuild/publish (e.g. to (re)publish `latest` or a moving tag
|
||||||
|
# without cutting a new release).
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: 'Extra tag to publish (optional, e.g. "edge")'
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
|
# The runtime image bakes the git commit for the AGPL-3.0 § 13 source
|
||||||
|
# offer (config.commit → GET /api/instance/info). The .git dir is not in
|
||||||
|
# the build context (.dockerignore), so resolve the short SHA here and feed
|
||||||
|
# it to the build as a --build-arg, matching install.sh / deploy.sh.
|
||||||
|
- name: Resolve build metadata
|
||||||
|
id: meta_commit
|
||||||
|
run: echo "commit=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Derive image tags and labels
|
||||||
|
id: docker_meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
# github.repository is "TheZwiss/backspace"; metadata-action lowercases
|
||||||
|
# it → ghcr.io/thezwiss/backspace (GHCR requires lowercase).
|
||||||
|
images: ghcr.io/${{ github.repository }}
|
||||||
|
tags: |
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||||
|
type=sha
|
||||||
|
type=raw,value=${{ github.event.inputs.tag }},enable=${{ github.event.inputs.tag != '' }}
|
||||||
|
labels: |
|
||||||
|
org.opencontainers.image.title=Backspace
|
||||||
|
org.opencontainers.image.description=Self-hosted Discord alternative — text, voice, video, and federation.
|
||||||
|
org.opencontainers.image.source=https://github.com/TheZwiss/backspace
|
||||||
|
org.opencontainers.image.licenses=AGPL-3.0-only
|
||||||
|
org.opencontainers.image.revision=${{ github.sha }}
|
||||||
|
|
||||||
|
- name: Build and push (linux/amd64, linux/arm64)
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.docker_meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.docker_meta.outputs.labels }}
|
||||||
|
build-args: |
|
||||||
|
BACKSPACE_COMMIT=${{ steps.meta_commit.outputs.commit }}
|
||||||
|
# Cache multi-arch layers across runs via the GitHub Actions cache to
|
||||||
|
# keep the ~1.6 GB build from re-running cold every release.
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
@@ -35,7 +35,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Install Linux build dependencies
|
- name: Install Linux build dependencies
|
||||||
if: runner.os == 'Linux'
|
if: runner.os == 'Linux'
|
||||||
@@ -65,12 +65,12 @@ jobs:
|
|||||||
sudo gem install --no-document fpm
|
sudo gem install --no-document fpm
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v5
|
||||||
with:
|
with:
|
||||||
version: 10
|
version: 10.34.3
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v5
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|||||||
@@ -34,6 +34,12 @@ id_ed25519*
|
|||||||
# Generated deployment config (contains secrets)
|
# Generated deployment config (contains secrets)
|
||||||
livekit.yaml
|
livekit.yaml
|
||||||
|
|
||||||
|
# Local deploy targets & private ops notes — REAL hosts/IPs/users for the
|
||||||
|
# maintainer's own infra. NEVER commit: this is a public repo. deploy.sh reads
|
||||||
|
# .deploy.local at runtime if present; the committed defaults stay placeholders.
|
||||||
|
/.deploy.local
|
||||||
|
/.deploy-local/
|
||||||
|
|
||||||
# Raw screenshot originals — optimized WebP copies live in docs/screenshots/
|
# Raw screenshot originals — optimized WebP copies live in docs/screenshots/
|
||||||
demo-pictures/
|
demo-pictures/
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ submit.
|
|||||||
|
|
||||||
**"Contribution"** means any original work of authorship, including any
|
**"Contribution"** means any original work of authorship, including any
|
||||||
modification of or addition to an existing work, that You intentionally submit
|
modification of or addition to an existing work, that You intentionally submit
|
||||||
to the Project in any form — including source code, object code, documentation,
|
to the Project in any form (including source code, object code, documentation,
|
||||||
configuration, designs, or assets — through any means, including pull requests,
|
configuration, designs, or assets) through any means, including pull requests,
|
||||||
patches, issues with attached code, or electronic communication, but excluding
|
patches, issues with attached code, or electronic communication, but excluding
|
||||||
any communication You conspicuously mark in writing as "Not a Contribution".
|
any communication You conspicuously mark in writing as "Not a Contribution".
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ fully sublicensable, and transferable license to use, reproduce, modify, prepare
|
|||||||
derivative works of, publicly display, publicly perform, distribute, relicense
|
derivative works of, publicly display, publicly perform, distribute, relicense
|
||||||
(under any terms, including open-source and commercial licenses), sell, and
|
(under any terms, including open-source and commercial licenses), sell, and
|
||||||
otherwise exploit your Contributions, by all means and in all media now known or
|
otherwise exploit your Contributions, by all means and in all media now known or
|
||||||
later developed — so that the Maintainer may offer the Project under both the
|
later developed, so that the Maintainer may offer the Project under both the
|
||||||
GNU AGPL-3.0 and one or more commercial licenses.
|
GNU AGPL-3.0 and one or more commercial licenses.
|
||||||
|
|
||||||
This grant covers all Contributions You have already submitted and all
|
This grant covers all Contributions You have already submitted and all
|
||||||
@@ -46,17 +46,17 @@ Contributions You submit in the future, effective at the moment each is created.
|
|||||||
|
|
||||||
To the extent that the exclusive license in Section 2 is, in any jurisdiction,
|
To the extent that the exclusive license in Section 2 is, in any jurisdiction,
|
||||||
narrower than stated or otherwise limited by law, You grant the Maintainer the
|
narrower than stated or otherwise limited by law, You grant the Maintainer the
|
||||||
broadest license permissible there, and — only to the extent necessary to give
|
broadest license permissible there, and, only to the extent necessary to give
|
||||||
the Maintainer equivalent rights — assign such rights to the Maintainer, so that
|
the Maintainer equivalent rights, assign such rights to the Maintainer, so that
|
||||||
the Maintainer obtains, as nearly as possible, the same rights as the exclusive
|
the Maintainer obtains, as nearly as possible, the same rights as the exclusive
|
||||||
license in Section 2.
|
license in Section 2.
|
||||||
|
|
||||||
## 4. Patent License
|
## 4. Patent License
|
||||||
|
|
||||||
You grant the Maintainer a perpetual, worldwide, non-exclusive, royalty-free,
|
You grant the Maintainer a perpetual, worldwide, non-exclusive, royalty-free,
|
||||||
irrevocable, sublicensable patent license — under any patent claims You can
|
irrevocable, sublicensable patent license (under any patent claims You can
|
||||||
license that are necessarily infringed by your Contribution alone or by
|
license that are necessarily infringed by your Contribution alone or by
|
||||||
combination of your Contribution with the Project — to make, have made, use,
|
combination of your Contribution with the Project) to make, have made, use,
|
||||||
offer to sell, sell, import, and otherwise transfer your Contributions and the
|
offer to sell, sell, import, and otherwise transfer your Contributions and the
|
||||||
Project.
|
Project.
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Thanks for considering a contribution! Backspace is free and open source software
|
Thanks for considering a contribution! Backspace is free and open source software
|
||||||
(GNU AGPL-3.0, with a commercial dual-license option), and contributions of all
|
(GNU AGPL-3.0, with a commercial dual-license option), and contributions of all
|
||||||
sizes are welcome — bug reports, fixes, features, documentation, and design.
|
sizes are welcome: bug reports, fixes, features, documentation, and design.
|
||||||
|
|
||||||
## Before you start
|
## Before you start
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ Before your first contribution can be merged, you must sign the project's
|
|||||||
|
|
||||||
Backspace is a single-owner project. Under the CLA **you keep the copyright to
|
Backspace is a single-owner project. Under the CLA **you keep the copyright to
|
||||||
your contribution** and grant the maintainer (Jannis Braun) an exclusive,
|
your contribution** and grant the maintainer (Jannis Braun) an exclusive,
|
||||||
sublicensable license to it — which is what lets the project be offered under both
|
sublicensable license to it, which is what lets the project be offered under both
|
||||||
the AGPL and a commercial license. In return, you receive a perpetual license to
|
the AGPL and a commercial license. In return, you receive a perpetual license to
|
||||||
reuse the specific code you authored in your own other projects (see CLA §5). You
|
reuse the specific code you authored in your own other projects (see CLA §5). You
|
||||||
also confirm that you have the right to contribute the code in the first place.
|
also confirm that you have the right to contribute the code in the first place.
|
||||||
@@ -36,11 +36,13 @@ Signing is automatic and takes one comment:
|
|||||||
> I have read the CLA Document and I hereby sign the CLA
|
> 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
|
4. The bot records your signature against your GitHub username. You only sign
|
||||||
once — it covers all of your future contributions.
|
once, and it covers all of your future contributions.
|
||||||
|
|
||||||
## Development setup
|
## Development setup
|
||||||
|
|
||||||
Requirements: **Node.js 20+** and **pnpm 8+**.
|
Requirements: **Node.js 20 (LTS)** and **pnpm 10**. Run `nvm use` (reads
|
||||||
|
`.nvmrc`); Corepack activates the pinned pnpm from the `packageManager` field
|
||||||
|
automatically, so don't install pnpm globally.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install # install all workspace dependencies
|
pnpm install # install all workspace dependencies
|
||||||
@@ -50,6 +52,11 @@ 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`.
|
You can run the two halves separately with `pnpm dev:server` and `pnpm dev:web`.
|
||||||
|
|
||||||
|
Working on the **desktop** app additionally needs a C++ toolchain (`make`, `g++`,
|
||||||
|
`python3`) to build the native `uiohook-napi` module. On Debian/Ubuntu:
|
||||||
|
`sudo apt install build-essential python3`. Without it `pnpm install` just warns
|
||||||
|
and skips that one rebuild; the server and web client are unaffected.
|
||||||
|
|
||||||
Voice and video are optional and require a LiveKit server; see the README for
|
Voice and video are optional and require a LiveKit server; see the README for
|
||||||
configuration. Text, federation, uploads, and everything else run fully without
|
configuration. Text, federation, uploads, and everything else run fully without
|
||||||
it.
|
it.
|
||||||
@@ -57,7 +64,7 @@ it.
|
|||||||
## Coding standards
|
## Coding standards
|
||||||
|
|
||||||
- **TypeScript strict mode**, no `any`. The codebase compiles cleanly under
|
- **TypeScript strict mode**, no `any`. The codebase compiles cleanly under
|
||||||
strict settings — keep it that way.
|
strict settings, so keep it that way.
|
||||||
- **Match the surrounding code.** Follow existing patterns, naming, and module
|
- **Match the surrounding code.** Follow existing patterns, naming, and module
|
||||||
boundaries rather than introducing new ones.
|
boundaries rather than introducing new ones.
|
||||||
- **Federation-aware.** Never assume a single global user ID. Resolve the
|
- **Federation-aware.** Never assume a single global user ID. Resolve the
|
||||||
@@ -89,10 +96,10 @@ it.
|
|||||||
Use GitHub Issues. For bugs, include reproduction steps, expected vs. actual
|
Use GitHub Issues. For bugs, include reproduction steps, expected vs. actual
|
||||||
behavior, and your environment (deployment method, browser/desktop, and whether
|
behavior, and your environment (deployment method, browser/desktop, and whether
|
||||||
federation or voice is involved). For security issues, please do **not** open a
|
federation or voice is involved). For security issues, please do **not** open a
|
||||||
public issue — see [`SECURITY.md`](SECURITY.md).
|
public issue. See [`SECURITY.md`](SECURITY.md).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
By contributing, you agree that your contributions are licensed under the
|
By contributing, you agree that your contributions are licensed under the
|
||||||
[GNU AGPL-3.0](LICENSE) and are subject to the [CLA](CLA.md) — an exclusive-license
|
[GNU AGPL-3.0](LICENSE) and are subject to the [CLA](CLA.md), an exclusive-license
|
||||||
grant that also enables the project's commercial dual-license.
|
grant that also enables the project's commercial dual-license.
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
# Stage 1: Install dependencies and build frontend
|
# Stage 1: Install dependencies and build frontend
|
||||||
FROM node:20-slim AS builder
|
FROM node:20-slim AS builder
|
||||||
|
|
||||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
RUN corepack enable && corepack prepare pnpm@10.34.3 --activate
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ RUN pnpm --filter @backspace/web build
|
|||||||
# Stage 2: Production runtime
|
# Stage 2: Production runtime
|
||||||
FROM node:20-slim AS runtime
|
FROM node:20-slim AS runtime
|
||||||
|
|
||||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
RUN corepack enable && corepack prepare pnpm@10.34.3 --activate
|
||||||
|
|
||||||
# Install build dependencies for better-sqlite3 native module
|
# Install build dependencies for better-sqlite3 native module
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
|
|||||||
@@ -4,52 +4,52 @@
|
|||||||
|
|
||||||
# Backspace
|
# Backspace
|
||||||
|
|
||||||
**A self-hosted communication platform — text, voice, video, and federation — that you own.**
|
**A self-hosted communication platform you own. Text, voice, video, and federation.**
|
||||||
|
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://www.typescriptlang.org/)
|
[](https://www.typescriptlang.org/)
|
||||||
[](https://nodejs.org/)
|
[](https://nodejs.org/)
|
||||||
[](#project-status)
|
[](#project-status)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Backspace is a Discord-style chat platform you run on your own hardware. Spaces,
|
Backspace is a self-hosted, open-source Discord alternative: a Discord-style chat
|
||||||
channels, roles, voice and video, screen sharing, direct messages, friends, file
|
platform you run on your own hardware. Spaces, channels, roles, voice and video,
|
||||||
sharing, and message search — plus **server-to-server federation**, so
|
screen sharing, direct messages, friends, file sharing, and message search. On top
|
||||||
independent Backspace instances can talk to each other while each stays under
|
of that, **server-to-server federation** lets independent Backspace instances talk
|
||||||
its own control.
|
to each other while each stays under its own control.
|
||||||
|
|
||||||
It is **free and open source** under the **GNU AGPL-3.0**, and dual-licensed: a
|
It is **free and open source** under the **GNU AGPL-3.0**, and dual-licensed: a
|
||||||
commercial license is available if the AGPL doesn't fit your use. See
|
commercial license is available if the AGPL doesn't fit your use. See
|
||||||
[License](#license) for the details.
|
[License](#license) for the details.
|
||||||
|
|
||||||
> **Project status** <a name="project-status"></a>
|
> **Project status** <a name="project-status"></a>
|
||||||
> Backspace 1.0 — stable, self-hostable, and actively developed.
|
> Backspace 1.0. Stable, self-hostable, and actively developed.
|
||||||
|
|
||||||
## What makes Backspace different
|
## What makes Backspace different
|
||||||
|
|
||||||
Self-hosted chat usually forces a trade-off: gaming-grade voice and video, *or* a
|
Self-hosted chat usually forces a trade-off: gaming-grade voice and video, *or* a
|
||||||
polished Discord-style experience, *or* federation between independent servers —
|
polished Discord-style experience, *or* federation between independent servers.
|
||||||
rarely all three, and rarely with the fine-grained media controls people expect.
|
Rarely all three, and rarely with the fine-grained media controls people expect.
|
||||||
|
|
||||||
Backspace does all three at once:
|
Backspace does all three at once:
|
||||||
|
|
||||||
- **Voice & video with a real control surface.** Not just "it has screen share":
|
- **Voice and video with a real control surface.** This goes past a screen-share
|
||||||
choose resolution, frame rate, codec (VP9 or hardware H.264), and bitrate; set
|
button. Choose resolution, frame rate, codec (VP9 or hardware H.264), and
|
||||||
independent 0–200% volume for every person *and* every screen-share; RNNoise
|
bitrate; set independent 0-200% volume for every person and every screen-share;
|
||||||
noise suppression; a live connection inspector (bitrate, codec, ping, packet
|
RNNoise noise suppression; a live connection inspector (bitrate, codec, ping,
|
||||||
loss, jitter); and a per-tile badge showing each stream's measured
|
packet loss, jitter); and a per-tile badge showing each stream's measured
|
||||||
resolution/frame-rate. Screen sharing goes up to 4K/120fps within admin-set
|
resolution and frame-rate. Screen sharing goes up to 4K/120fps within admin-set
|
||||||
bounds.
|
bounds.
|
||||||
- **Federation, not a walled garden.** Run your own instance and peer it with
|
- **Federation, not a walled garden.** Run your own instance and peer it with
|
||||||
others: cross-instance friends, DMs, calls, and presence — each server
|
others for cross-instance friends, DMs, calls, and presence. Each server stays
|
||||||
independently owned, requests HMAC-authenticated.
|
independently owned, and requests are HMAC-authenticated.
|
||||||
- **A complete, polished platform — not a demo.** Role-based permissions with
|
- **A complete platform, not a demo.** Role-based permissions with per-category
|
||||||
per-category and per-channel overrides, friends and group DMs, inline playable
|
and per-channel overrides, friends and group DMs, inline playable media,
|
||||||
media, moderation with audit trails, search, a desktop app, and an installable
|
moderation with audit trails, search, a desktop app, and an installable mobile
|
||||||
mobile PWA — all in the warm, calm "Aether Drift" interface.
|
PWA, all in the warm, calm "Aether Drift" interface.
|
||||||
|
|
||||||
You own the server, the data, and the network it federates into.
|
You own the server, the data, and the network it federates into.
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ You own the server, the data, and the network it federates into.
|
|||||||
|
|
||||||
<img src="docs/screenshots/voice-video-grid.webp" alt="A voice channel with a grid of camera and screen-share tiles" width="900" />
|
<img src="docs/screenshots/voice-video-grid.webp" alt="A voice channel with a grid of camera and screen-share tiles" width="900" />
|
||||||
|
|
||||||
<sub><em>A voice channel in full swing — camera tiles alongside live screen-shares, each with its own resolution / frame-rate label.</em></sub>
|
<sub><em>A voice channel in full swing. Camera tiles alongside live screen-shares, each with its own resolution and frame-rate label.</em></sub>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -67,31 +67,31 @@ You own the server, the data, and the network it federates into.
|
|||||||
<tr>
|
<tr>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<img src="docs/screenshots/chat.webp" alt="A text channel with messages and a typing indicator" /><br/>
|
<img src="docs/screenshots/chat.webp" alt="A text channel with messages and a typing indicator" /><br/>
|
||||||
<sub><b>Text channels</b> — Markdown, replies, reactions, and live typing indicators.</sub>
|
<sub><b>Text channels.</b> Markdown, replies, reactions, and live typing indicators.</sub>
|
||||||
</td>
|
</td>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<img src="docs/screenshots/screen-share-settings.webp" alt="The screen-share settings popover" /><br/>
|
<img src="docs/screenshots/screen-share-settings.webp" alt="The screen-share settings popover" /><br/>
|
||||||
<sub><b>Screen-share controls</b> — resolution, frame rate, codec, and bitrate, within admin-set bounds.</sub>
|
<sub><b>Screen-share controls.</b> Resolution, frame rate, codec, and bitrate, within admin-set bounds.</sub>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<img src="docs/screenshots/space-discovery.webp" alt="The space discovery / Explore view" /><br/>
|
<img src="docs/screenshots/space-discovery.webp" alt="The space discovery / Explore view" /><br/>
|
||||||
<sub><b>Spaces & discovery</b> — browse public, request-to-join, and joined spaces.</sub>
|
<sub><b>Spaces and discovery.</b> Browse public, request-to-join, and joined spaces.</sub>
|
||||||
</td>
|
</td>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<img src="docs/screenshots/group-dm.webp" alt="A federated group direct message" /><br/>
|
<img src="docs/screenshots/group-dm.webp" alt="A federated group direct message" /><br/>
|
||||||
<sub><b>Direct messages</b> — 1-on-1 and group DMs, including members on peer instances.</sub>
|
<sub><b>Direct messages.</b> 1-on-1 and group DMs, including members on peer instances.</sub>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<img src="docs/screenshots/user-discovery.webp" alt="The find-people / user discovery view" /><br/>
|
<img src="docs/screenshots/user-discovery.webp" alt="The find-people / user discovery view" /><br/>
|
||||||
<sub><b>Friends & social</b> — find people across instances with mutual friends and spaces.</sub>
|
<sub><b>Friends and social.</b> Find people across instances with mutual friends and spaces.</sub>
|
||||||
</td>
|
</td>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<img src="docs/screenshots/admin-federation.webp" alt="The federation admin panel showing peered instances" /><br/>
|
<img src="docs/screenshots/admin-federation.webp" alt="The federation admin panel showing peered instances" /><br/>
|
||||||
<sub><b>Federation admin</b> — manage peered instances, relay, and secret rotation.</sub>
|
<sub><b>Federation admin.</b> Manage peered instances, relay, and secret rotation.</sub>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -106,22 +106,22 @@ You own the server, the data, and the network it federates into.
|
|||||||
- Message reactions, replies, editing, deletion, and per-message mark-as-unread
|
- Message reactions, replies, editing, deletion, and per-message mark-as-unread
|
||||||
- Rich link embeds (YouTube, Vimeo, Spotify, and generic OpenGraph) with SSRF-protected scraping, plus GIF search (Klipy)
|
- Rich link embeds (YouTube, Vimeo, Spotify, and generic OpenGraph) with SSRF-protected scraping, plus GIF search (Klipy)
|
||||||
- Typing indicators, unread badges, and presence
|
- Typing indicators, unread badges, and presence
|
||||||
- Direct messages — 1-on-1 and group DMs (up to 10 people), with voice/video calls (ring / accept / reject)
|
- Direct messages: 1-on-1 and group DMs (up to 10 people), with voice/video calls (ring, accept, reject)
|
||||||
|
|
||||||
**Voice, video & screen sharing** (via [LiveKit](https://livekit.io/)):
|
**Voice, video, and screen sharing** (via [LiveKit](https://livekit.io/)):
|
||||||
- Screen sharing up to 4K / 120fps — VP9 by default, an optional hardware-accelerated H.264 mode, and a VP8 simulcast fallback
|
- Screen sharing up to 4K/120fps: VP9 by default, an optional hardware-accelerated H.264 mode, and a VP8 simulcast fallback
|
||||||
- Per-stream quality controls — resolution, frame rate, codec, and bitrate, within admin-set bounds
|
- Per-stream quality controls: resolution, frame rate, codec, and bitrate, within admin-set bounds
|
||||||
- Independent 0–200% volume for every participant *and* every screen-share
|
- Independent 0-200% volume for every participant and every screen-share
|
||||||
- RNNoise noise suppression (on by default), plus echo-cancellation and auto-gain toggles and mic/speaker device selection
|
- RNNoise noise suppression (on by default), plus echo-cancellation and auto-gain toggles and mic/speaker device selection
|
||||||
- Live connection inspector — per-participant bitrate, codec, ping, packet loss, and jitter — plus a per-tile badge showing each stream's measured resolution/frame-rate
|
- Live connection inspector for per-participant bitrate, codec, ping, packet loss, and jitter, plus a per-tile badge showing each stream's measured resolution and frame-rate
|
||||||
- Screen-share viewer detection ("who's watching") and auto-ducking that lowers stream audio when someone speaks
|
- Screen-share viewer detection ("who's watching") and auto-ducking that lowers stream audio when someone speaks
|
||||||
- Selective subscription — mute or stop watching any camera/stream to save bandwidth
|
- Selective subscription: mute or stop watching any camera or stream to save bandwidth
|
||||||
- Push-to-talk and fully customizable keybinds (including mouse buttons), in the browser and the desktop app
|
- Push-to-talk and fully customizable keybinds (including mouse buttons), in the browser and the desktop app
|
||||||
- Picture-in-Picture for voice and video
|
- Picture-in-Picture for voice and video
|
||||||
|
|
||||||
### Organization
|
### Organization
|
||||||
- Spaces with channel categories
|
- Spaces with channel categories
|
||||||
- Role-based permissions — bitwise RBAC with category- and channel-level overrides
|
- Role-based permissions: bitwise RBAC with category- and channel-level overrides
|
||||||
- Customizable user sidebar layout, with personal color-coded folders that group whole spaces
|
- Customizable user sidebar layout, with personal color-coded folders that group whole spaces
|
||||||
- Space discovery (public, request-to-join, and private)
|
- Space discovery (public, request-to-join, and private)
|
||||||
- Shareable invite codes
|
- Shareable invite codes
|
||||||
@@ -132,8 +132,8 @@ You own the server, the data, and the network it federates into.
|
|||||||
- Mutual friends and mutual spaces
|
- Mutual friends and mutual spaces
|
||||||
- User profiles with banner, bio, and accent color
|
- User profiles with banner, bio, and accent color
|
||||||
- Presence and rich activities (playing, listening, watching, streaming, custom)
|
- Presence and rich activities (playing, listening, watching, streaming, custom)
|
||||||
- Manual status — Online, Idle, or Do Not Disturb — with a custom status message
|
- Manual status (Online, Idle, or Do Not Disturb) with a custom status message
|
||||||
- Privacy controls — toggle discoverability and activity-status sharing
|
- Privacy controls: toggle discoverability and activity-status sharing
|
||||||
|
|
||||||
### Moderation
|
### Moderation
|
||||||
- Bans with reason and moderator attribution (who, why, and when)
|
- Bans with reason and moderator attribution (who, why, and when)
|
||||||
@@ -144,7 +144,7 @@ You own the server, the data, and the network it federates into.
|
|||||||
### Federation
|
### Federation
|
||||||
- Multi-instance peering with HMAC-signed server-to-server requests
|
- Multi-instance peering with HMAC-signed server-to-server requests
|
||||||
- Federated identity resolution (`username@instance`)
|
- Federated identity resolution (`username@instance`)
|
||||||
- Cross-instance DMs — messages, reactions, and membership relay
|
- Cross-instance DMs: messages, reactions, and membership relay
|
||||||
- Cross-instance friends and presence
|
- Cross-instance friends and presence
|
||||||
- File replication with size validation
|
- File replication with size validation
|
||||||
- Background workers for outbox delivery, file download, peer health, and cleanup
|
- Background workers for outbox delivery, file download, peer health, and cleanup
|
||||||
@@ -152,26 +152,38 @@ You own the server, the data, and the network it federates into.
|
|||||||
### Platform
|
### Platform
|
||||||
- File uploads with image thumbnails (via `sharp`), drag-and-drop and paste-to-upload, and in-app avatar/banner cropping
|
- File uploads with image thumbnails (via `sharp`), drag-and-drop and paste-to-upload, and in-app avatar/banner cropping
|
||||||
- Message search with `from:`, `has:`, `before:`, and `after:` filters, plus jump-to-message
|
- Message search with `from:`, `has:`, `before:`, and `after:` filters, plus jump-to-message
|
||||||
- Admin panel — instance settings, user management, registration controls, storage management, and federation/peering, plus granular streaming controls (a per-resolution × per-frame-rate bitrate matrix, min/max caps, quality-slider step, and an optional user-set-bitrate mode)
|
- Admin panel: instance settings, user management, registration controls, storage management, and federation/peering, plus granular streaming controls (a per-resolution by per-frame-rate bitrate matrix, min/max caps, quality-slider step, and an optional user-set-bitrate mode)
|
||||||
- Automatic SQLite backups (pre-migration, scheduled, and manual) with restore tooling
|
- Automatic SQLite backups (pre-migration, scheduled, and manual) with restore tooling
|
||||||
- Electron desktop app (Windows, macOS, Linux) with global keybinds (push-to-talk, mute, deafen) and activity detection
|
- Electron desktop app (Windows, macOS, Linux) with global keybinds (push-to-talk, mute, deafen) and activity detection
|
||||||
- Native desktop notifications and unread badge counts
|
- Native desktop notifications and unread badge counts
|
||||||
- Mobile-responsive web UI with a dedicated touch layout (bottom navigation, swipe gestures, full-screen views)
|
- Mobile-responsive web UI with a dedicated touch layout (bottom navigation, swipe gestures, full-screen views)
|
||||||
- Installable PWA — add it to your phone's home screen to run it as a standalone app, with service-worker caching and an offline message queue (messages send once you reconnect)
|
- Installable PWA: add it to your phone's home screen to run it as a standalone app, with service-worker caching and an offline message queue (messages send once you reconnect)
|
||||||
- Account management — password change and account deletion with safeguards
|
- Account management: password change and account deletion with safeguards
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
The intended way to deploy Backspace is the **interactive installer** — it
|
The intended way to deploy Backspace is the **interactive installer**. It
|
||||||
configures everything (`.env`, secrets, HTTPS, optional voice) and brings the
|
configures everything (`.env`, secrets, HTTPS, optional voice) and brings the
|
||||||
stack up for you. Everything you need for a working instance is below.
|
stack up for you. It **auto-detects your environment** and picks one of three
|
||||||
|
deployment modes. The default "All-in-One" (below) needs nothing but a host and
|
||||||
|
a domain, but if ports 80/443 are already taken (an existing reverse proxy, a
|
||||||
|
tunnel, another app) the installer steers you to the right mode instead of
|
||||||
|
dead-ending. See [Deployment modes](#deployment-modes) for the full picture.
|
||||||
|
|
||||||
|
By default the installer **pulls a prebuilt multi-architecture image** from the
|
||||||
|
GitHub Container Registry (`linux/amd64` + `linux/arm64`), so weak or ARM boxes
|
||||||
|
(a Raspberry Pi) skip the heavy local build. It falls back to building from
|
||||||
|
source automatically if the image can't be pulled.
|
||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
|
|
||||||
- A **Linux host** (VPS, VM, or home server) with **Docker** and **Docker Compose**.
|
- A **Linux host** (VPS, VM, or home server) with **Docker** and **Docker Compose**.
|
||||||
- A **domain name** pointed at the host's public IP — Caddy uses it to obtain
|
- A **domain name** for your instance. In the default All-in-One mode it must
|
||||||
HTTPS certificates automatically.
|
point at the host's public IP (Caddy obtains HTTPS certificates for it
|
||||||
- The ability to open the firewall ports in step 2.
|
automatically); behind your own reverse proxy or a tunnel it points at that
|
||||||
|
edge instead. See [Deployment modes](#deployment-modes).
|
||||||
|
- The ability to open the firewall ports in step 2 (All-in-One), or a reverse
|
||||||
|
proxy / tunnel already terminating HTTPS for you.
|
||||||
|
|
||||||
### 1. Run the installer
|
### 1. Run the installer
|
||||||
|
|
||||||
@@ -191,34 +203,34 @@ The installer walks you through everything interactively:
|
|||||||
|
|
||||||
### 2. Open the firewall ports
|
### 2. Open the firewall ports
|
||||||
|
|
||||||
Open these on the host — and, if it's behind a router, port-forward them to the host:
|
Open these on the host (and, if it's behind a router, port-forward them to the host):
|
||||||
|
|
||||||
| Port | Proto | When | Purpose |
|
| Port | Proto | When | Purpose |
|
||||||
|------|-------|------|---------|
|
|------|-------|------|---------|
|
||||||
| `80` | TCP | **Always** | HTTP — Caddy's automatic-HTTPS (ACME) challenge + redirect to HTTPS |
|
| `80` | TCP | **Always** | HTTP. Caddy's automatic-HTTPS (ACME) challenge + redirect to HTTPS |
|
||||||
| `443` | TCP | **Always** | HTTPS — web app, REST API, WebSocket, and LiveKit signaling (proxied) |
|
| `443` | TCP | **Always** | HTTPS. Web app, REST API, WebSocket, and LiveKit signaling (proxied) |
|
||||||
| `3478` | UDP | If voice enabled | TURN — NAT traversal for WebRTC |
|
| `3478` | UDP | If voice enabled | TURN. NAT traversal for WebRTC |
|
||||||
| `7881` | TCP | If voice enabled | WebRTC TCP fallback (clients that can't use UDP) |
|
| `7881` | TCP | If voice enabled | WebRTC TCP fallback (clients that can't use UDP) |
|
||||||
| `50000–60000` | UDP | If voice enabled | WebRTC media (voice / video / screen-share streams) |
|
| `50000–60000` | UDP | If voice enabled | WebRTC media (voice / video / screen-share streams) |
|
||||||
|
|
||||||
Without voice, you only need `80` and `443`. The voice ports are required only
|
Without voice, you only need `80` and `443`. The voice ports are required only
|
||||||
when you enable LiveKit. LiveKit's own signaling port (`7880`) stays internal —
|
when you enable LiveKit. LiveKit's own signaling port (`7880`) stays internal.
|
||||||
it's reverse-proxied through Caddy on `443`, so you do **not** forward it.
|
It's reverse-proxied through Caddy on `443`, so you do **not** forward it.
|
||||||
|
|
||||||
> **Do this together with DNS, ideally before (or right after) running the
|
> **Do this together with DNS, ideally before (or right after) running the
|
||||||
> installer.** Caddy gets your HTTPS certificate from Let's Encrypt the first
|
> installer.** Caddy gets your HTTPS certificate from Let's Encrypt the first
|
||||||
> time the stack starts, which requires your domain to resolve to this host
|
> time the stack starts, which requires your domain to resolve to this host
|
||||||
> **and** ports `80`/`443` reachable from the internet. If they aren't ready
|
> **and** ports `80`/`443` reachable from the internet. If they aren't ready
|
||||||
> yet, that's fine — Caddy keeps retrying, and HTTPS comes up automatically once
|
> yet, that's fine. Caddy keeps retrying, and HTTPS comes up automatically once
|
||||||
> DNS and the ports are in place.
|
> DNS and the ports are in place.
|
||||||
|
|
||||||
### 3. Create your admin account
|
### 3. Create your admin account
|
||||||
|
|
||||||
Open `https://your-domain` and register. **The first account created becomes the
|
Open `https://your-domain` and register. **The first account created becomes the
|
||||||
instance admin** — there is no default username or password.
|
instance admin**. There is no default username or password.
|
||||||
|
|
||||||
If the page doesn't load over HTTPS, it's almost always DNS or ports `80`/`443`
|
If the page doesn't load over HTTPS, it's almost always DNS or ports `80`/`443`
|
||||||
not being reachable from outside — check `docker compose logs caddy` for
|
not being reachable from outside. Check `docker compose logs caddy` for
|
||||||
certificate errors. (The installer's health check confirms the app is up
|
certificate errors. (The installer's health check confirms the app is up
|
||||||
internally, not that the certificate was issued.)
|
internally, not that the certificate was issued.)
|
||||||
|
|
||||||
@@ -232,7 +244,7 @@ backup/restore and image-pinning guide.
|
|||||||
### Manual setup (advanced, optional)
|
### Manual setup (advanced, optional)
|
||||||
|
|
||||||
The installer above is the supported path. If you'd rather configure everything
|
The installer above is the supported path. If you'd rather configure everything
|
||||||
by hand, you can skip it and drive Docker Compose directly — but then DNS,
|
by hand, you can skip it and drive Docker Compose directly, but then DNS,
|
||||||
`.env`, secrets, voice config, and the same firewall ports from step 2 are your
|
`.env`, secrets, voice config, and the same firewall ports from step 2 are your
|
||||||
responsibility:
|
responsibility:
|
||||||
|
|
||||||
@@ -253,11 +265,210 @@ The stack runs three services via Docker Compose:
|
|||||||
|-------------|---------------------------------------------------|
|
|-------------|---------------------------------------------------|
|
||||||
| `backspace` | The app (API + WebSocket + built web client) on internal port `3000` |
|
| `backspace` | The app (API + WebSocket + built web client) on internal port `3000` |
|
||||||
| `caddy` | Reverse proxy with automatic HTTPS for your `DOMAIN` (ports `80`/`443`) |
|
| `caddy` | Reverse proxy with automatic HTTPS for your `DOMAIN` (ports `80`/`443`) |
|
||||||
| `livekit` | Voice/video server — optional, enabled with `COMPOSE_PROFILES=voice` |
|
| `livekit` | Voice/video server; optional, enabled with `COMPOSE_PROFILES=voice` |
|
||||||
|
|
||||||
|
## Deployment modes
|
||||||
|
|
||||||
|
Homelabs differ. Backspace supports three deployment modes from **one installer**,
|
||||||
|
which auto-detects which one fits and, in non-obvious cases, asks. The mode is
|
||||||
|
recorded as `DEPLOY_MODE` in `.env`; you can also set it up front for a
|
||||||
|
non-interactive install (`DEPLOY_MODE=proxy ./install.sh`).
|
||||||
|
|
||||||
|
| Mode | When | HTTPS handled by | Voice |
|
||||||
|
|------|------|------------------|-------|
|
||||||
|
| **`allinone`** (default) | Ports 80/443 are free and you have a domain | The bundled **Caddy** (automatic Let's Encrypt) | Yes, with UDP media ports open |
|
||||||
|
| **`proxy`** | You already run a reverse proxy (nginx, Traefik, Caddy, Nginx Proxy Manager, SWAG…) | **Your** reverse proxy | Yes, if you also proxy `/livekit` and open the media ports |
|
||||||
|
| **`tunnel`** | You expose the box through a tunnel (Cloudflare Tunnel, Tailscale…) | The **tunnel** provider | No, WebRTC/UDP can't traverse a tunnel |
|
||||||
|
|
||||||
|
In `proxy` and `tunnel` mode the bundled Caddy is **not** started; instead the app
|
||||||
|
is published on **`127.0.0.1:APP_PORT`** (loopback only, never exposed directly)
|
||||||
|
for your proxy or tunnel to forward to. This is driven by a small overlay,
|
||||||
|
`docker-compose.proxy.yml`, which the installer wires in for you by setting
|
||||||
|
`COMPOSE_FILE=docker-compose.yml:docker-compose.proxy.yml` in `.env`, so every
|
||||||
|
later `docker compose …` command in the directory keeps working with no `-f`
|
||||||
|
flags. The installer auto-picks a free `APP_PORT` (3000/8080 are often taken);
|
||||||
|
override it with `APP_PORT=…`.
|
||||||
|
|
||||||
|
The installer prints ready-to-paste config for your mode at the end. The
|
||||||
|
canonical snippets are below.
|
||||||
|
|
||||||
|
### Mode 2: behind your own reverse proxy
|
||||||
|
|
||||||
|
The app answers plain HTTP on `127.0.0.1:APP_PORT`; your proxy terminates TLS and
|
||||||
|
forwards to it. Every snippet already includes the three things people get wrong:
|
||||||
|
**WebSocket upgrade** (chat and live events won't work without it),
|
||||||
|
**`X-Forwarded-*`** (the server runs with `trustProxy` and needs the real client
|
||||||
|
scheme/IP), and a **body-size limit** matching `MAX_UPLOAD_SIZE` (default 100 MB).
|
||||||
|
|
||||||
|
Replace `chat.example.com` and `8080` with your domain and `APP_PORT`.
|
||||||
|
|
||||||
|
**nginx.** The `map` goes in `http { }` once; the `server` block per site:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
map $http_upgrade $connection_upgrade { default upgrade; '' close; }
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name chat.example.com;
|
||||||
|
|
||||||
|
# ssl_certificate /etc/letsencrypt/live/chat.example.com/fullchain.pem;
|
||||||
|
# ssl_certificate_key /etc/letsencrypt/live/chat.example.com/privkey.pem;
|
||||||
|
|
||||||
|
client_max_body_size 100m; # match MAX_UPLOAD_SIZE
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
proxy_set_header Upgrade $http_upgrade; # WebSocket
|
||||||
|
proxy_set_header Connection $connection_upgrade; # WebSocket
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Voice only: forward LiveKit signaling (strips the /livekit prefix):
|
||||||
|
# location /livekit/ {
|
||||||
|
# proxy_pass http://127.0.0.1:7880/;
|
||||||
|
# proxy_http_version 1.1;
|
||||||
|
# proxy_set_header Host $host;
|
||||||
|
# proxy_set_header Upgrade $http_upgrade;
|
||||||
|
# proxy_set_header Connection $connection_upgrade;
|
||||||
|
# }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Caddy** (if you run your own; it handles WebSocket and `X-Forwarded-*` itself):
|
||||||
|
|
||||||
|
```caddy
|
||||||
|
chat.example.com {
|
||||||
|
reverse_proxy 127.0.0.1:8080
|
||||||
|
request_body { max_size 100MB }
|
||||||
|
|
||||||
|
# Voice only:
|
||||||
|
# handle_path /livekit/* { reverse_proxy 127.0.0.1:7880 }
|
||||||
|
# handle { reverse_proxy 127.0.0.1:8080 }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Traefik** (file provider; Traefik handles WebSocket automatically):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
http:
|
||||||
|
routers:
|
||||||
|
backspace:
|
||||||
|
rule: "Host(`chat.example.com`)"
|
||||||
|
entryPoints: [websecure]
|
||||||
|
service: backspace
|
||||||
|
tls: { certResolver: letsencrypt }
|
||||||
|
services:
|
||||||
|
backspace:
|
||||||
|
loadBalancer:
|
||||||
|
servers:
|
||||||
|
- url: "http://127.0.0.1:8080"
|
||||||
|
# Voice only: add a higher-priority router + stripPrefix middleware for
|
||||||
|
# PathPrefix(`/livekit`) → http://127.0.0.1:7880.
|
||||||
|
```
|
||||||
|
|
||||||
|
#### GUI proxies (Nginx Proxy Manager, SWAG, etc.)
|
||||||
|
|
||||||
|
You can't paste a config file into a point-and-click proxy, so set these fields
|
||||||
|
by hand. In **Nginx Proxy Manager**, add a **Proxy Host**:
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| **Domain Names** | `chat.example.com` |
|
||||||
|
| **Scheme** | `http` |
|
||||||
|
| **Forward Hostname / IP** | `127.0.0.1`, but **if NPM runs in Docker**, `127.0.0.1` is NPM's *own* container. Use the host's LAN IP, or `host.docker.internal` with `extra_hosts: ["host.docker.internal:host-gateway"]` on the NPM container. |
|
||||||
|
| **Forward Port** | your `APP_PORT` (e.g. `8080`) |
|
||||||
|
| **Websockets Support** | **ON** (required; chat/live events break without it) |
|
||||||
|
| **Block Common Exploits** | fine to leave on |
|
||||||
|
| **SSL tab** | request a Let's Encrypt cert and enable **Force SSL** |
|
||||||
|
| **Advanced tab** | add `client_max_body_size 100m;` (match `MAX_UPLOAD_SIZE`) |
|
||||||
|
|
||||||
|
The same three ideas apply to any GUI proxy: forward to the app's host+port,
|
||||||
|
enable WebSocket support, and raise the request-body limit.
|
||||||
|
|
||||||
|
### Mode 3: behind a tunnel (Cloudflare, Tailscale)
|
||||||
|
|
||||||
|
Same loopback publish as Mode 2, but the tunnel daemon on the host reaches
|
||||||
|
`127.0.0.1:APP_PORT` and no inbound ports are opened at all. For **Cloudflare
|
||||||
|
Tunnel** (`cloudflared`):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# ~/.cloudflared/config.yml
|
||||||
|
tunnel: <YOUR-TUNNEL-ID>
|
||||||
|
credentials-file: /root/.cloudflared/<YOUR-TUNNEL-ID>.json
|
||||||
|
|
||||||
|
ingress:
|
||||||
|
- hostname: chat.example.com
|
||||||
|
service: http://127.0.0.1:8080
|
||||||
|
- service: http_status:404
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cloudflared tunnel route dns <YOUR-TUNNEL-ID> chat.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Two tunnel-specific caveats, both handled by the installer:
|
||||||
|
|
||||||
|
- **Upload cap.** Cloudflare (free/pro) hard-caps request bodies at **100 MB**, so
|
||||||
|
the 100 MB default would let large uploads fail *at the edge*. In `tunnel` mode
|
||||||
|
the installer sets `MAX_UPLOAD_SIZE=94371840` (90 MB) with headroom. Don't raise
|
||||||
|
it back above ~100 MB behind Cloudflare.
|
||||||
|
- **No voice.** Voice/video is **WebRTC over UDP**, which a tunnel can't carry, so
|
||||||
|
it's disabled in `tunnel` mode. If you need voice, use Mode 2 (reverse proxy)
|
||||||
|
with the media ports opened, or All-in-One.
|
||||||
|
|
||||||
|
### Voice per mode
|
||||||
|
|
||||||
|
Voice/video (LiveKit) needs its **UDP media ports** reachable from clients.
|
||||||
|
These carry the actual audio/video and never pass through your HTTP proxy or
|
||||||
|
tunnel:
|
||||||
|
|
||||||
|
| Port | Proto | Purpose |
|
||||||
|
|------|-------|---------|
|
||||||
|
| `3478` | UDP | TURN. WebRTC NAT traversal |
|
||||||
|
| `7881` | TCP | WebRTC TCP fallback |
|
||||||
|
| `50000–60000` | UDP | WebRTC media (voice / video / screen-share) |
|
||||||
|
|
||||||
|
- **All-in-One.** Voice works once those ports are open/forwarded. LiveKit
|
||||||
|
*signaling* is proxied through Caddy on 443 (`/livekit`); port `7880` stays
|
||||||
|
internal, never forwarded.
|
||||||
|
- **Reverse proxy.** You must **also** route `/livekit` to `127.0.0.1:7880` (see
|
||||||
|
the commented lines in the snippets) **and** open the media ports above.
|
||||||
|
- **Tunnel.** Voice does **not** work (UDP can't traverse the tunnel). This is a
|
||||||
|
known, unavoidable limitation, not a misconfiguration.
|
||||||
|
|
||||||
|
### Updating a running instance
|
||||||
|
|
||||||
|
Back up first. The app auto-snapshots the SQLite DB, and you can take one on
|
||||||
|
demand with `./backup.sh` (see [`docs/systems/deployment.md`](docs/systems/deployment.md)).
|
||||||
|
Then, from the install directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git pull # refresh compose files / install.sh / docs
|
||||||
|
|
||||||
|
# Prebuilt-image installs (the default):
|
||||||
|
docker compose pull && docker compose up -d
|
||||||
|
|
||||||
|
# From-source installs (a fork, or BACKSPACE_BUILD=true):
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Because `COMPOSE_FILE` lives in `.env`, these commands automatically use the
|
||||||
|
right compose files in every mode, with no `-f` flags to remember. A redeploy
|
||||||
|
briefly restarts the `backspace` container (clients reconnect automatically).
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
Requirements: **Node.js 20+** and **pnpm 8+**.
|
Requirements: **Node.js 20 or newer** and **pnpm 10**. The `.nvmrc` file keeps
|
||||||
|
Node 20 as the default development and production baseline; CI additionally
|
||||||
|
exercises Node 24, and newer majors generally work but are not part of the test
|
||||||
|
matrix. The Docker image continues to build on Node 20 regardless of your host.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install
|
pnpm install
|
||||||
@@ -265,6 +476,29 @@ cp .env.example .env # set JWT_SECRET (openssl rand -hex 32)
|
|||||||
pnpm dev # API server on :3005, Vite dev server on :5173
|
pnpm dev # API server on :3005, Vite dev server on :5173
|
||||||
```
|
```
|
||||||
|
|
||||||
|
On Windows PowerShell, confirm Node 20 or newer and use the native copy command:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
node --version
|
||||||
|
pnpm install
|
||||||
|
Copy-Item .env.example .env
|
||||||
|
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||||
|
```
|
||||||
|
|
||||||
|
Paste the generated value after `JWT_SECRET=` in `.env`, then start both
|
||||||
|
development servers with `pnpm dev`. Use `node --version` to confirm the active
|
||||||
|
version if pnpm reports an engine warning. This covers the server and web dev
|
||||||
|
servers; building the Electron desktop app still expects a POSIX shell (macOS or
|
||||||
|
Linux).
|
||||||
|
|
||||||
|
> **Server/web only?** `pnpm install` also builds the desktop app's native
|
||||||
|
> keyboard-hook module (`uiohook-napi`), which needs a C++ toolchain
|
||||||
|
> (`make`, `g++`, `python3`). If those are missing it now **warns and continues**,
|
||||||
|
> and the server and web client don't need it. Install a build toolchain
|
||||||
|
> (Debian/Ubuntu: `sudo apt install build-essential python3`) only if you're
|
||||||
|
> building the **desktop** app. And to *self-host*, use the Docker installer
|
||||||
|
> above; it never touches the desktop package.
|
||||||
|
|
||||||
Run the halves separately if you prefer:
|
Run the halves separately if you prefer:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -287,19 +521,22 @@ The most important:
|
|||||||
|
|
||||||
| Variable | Required | Default | Description |
|
| Variable | Required | Default | Description |
|
||||||
|----------------------|----------|-------------|-------------|
|
|----------------------|----------|-------------|-------------|
|
||||||
| `DOMAIN` | yes | — | Public domain name of your instance |
|
| `DOMAIN` | yes | none | Public domain name of your instance |
|
||||||
| `JWT_SECRET` | yes | — | Auth signing secret, **min 32 chars** (`openssl rand -hex 32`) |
|
| `JWT_SECRET` | yes | none | Auth signing secret, **min 32 chars** (`openssl rand -hex 32`) |
|
||||||
|
| `DEPLOY_MODE` | no | `allinone` | `allinone` \| `proxy` \| `tunnel`, see [Deployment modes](#deployment-modes) |
|
||||||
|
| `APP_PORT` | no | auto | `proxy`/`tunnel` only: host loopback port the app is published on |
|
||||||
| `PORT` | no | `3000` | App listen port (behind Caddy in Docker) |
|
| `PORT` | no | `3000` | App listen port (behind Caddy in Docker) |
|
||||||
| `HOST` | no | `0.0.0.0` | Bind address |
|
| `HOST` | no | `0.0.0.0` | Bind address |
|
||||||
| `REGISTRATION_OPEN` | no | `true` | Set `false` to close signups after setup |
|
| `REGISTRATION_OPEN` | no | `true` | Set `false` to close signups after setup |
|
||||||
| `MAX_UPLOAD_SIZE` | no | `104857600` | Max upload size in bytes (100 MB) |
|
| `MAX_UPLOAD_SIZE` | no | `104857600` | Max upload size in bytes (100 MB; 90 MB in `tunnel` mode) |
|
||||||
| `LIVEKIT_URL` / `LIVEKIT_API_KEY` / `LIVEKIT_API_SECRET` | no | — | Enable voice/video |
|
| `BACKSPACE_IMAGE` / `BACKSPACE_IMAGE_TAG` | no | `ghcr.io/thezwiss/backspace` / `latest` | Prebuilt image to pull; pin a tag or point at your fork's registry |
|
||||||
| `COMPOSE_PROFILES` | no | — | Set to `voice` to start the bundled LiveKit service |
|
| `LIVEKIT_URL` / `LIVEKIT_API_KEY` / `LIVEKIT_API_SECRET` | no | none | Enable voice/video |
|
||||||
|
| `COMPOSE_PROFILES` | no | none | Set to `voice` to start the bundled LiveKit service |
|
||||||
|
|
||||||
## Voice & Video
|
## Voice & Video
|
||||||
|
|
||||||
Voice, video, and screen sharing require a [LiveKit](https://livekit.io/) server.
|
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`:
|
The Docker Compose file bundles one. Enable it by setting these in `.env`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
COMPOSE_PROFILES=voice
|
COMPOSE_PROFILES=voice
|
||||||
@@ -309,10 +546,10 @@ LIVEKIT_API_SECRET=your-api-secret
|
|||||||
```
|
```
|
||||||
|
|
||||||
Enabling voice also requires opening the WebRTC ports (`3478/UDP`, `7881/TCP`,
|
Enabling voice also requires opening the WebRTC ports (`3478/UDP`, `7881/TCP`,
|
||||||
`50000–60000/UDP`) — see [Open the firewall ports](#2-open-the-firewall-ports).
|
`50000–60000/UDP`). See [Open the firewall ports](#2-open-the-firewall-ports).
|
||||||
|
|
||||||
Without LiveKit configured, everything else — text, federation, DMs, uploads,
|
Without LiveKit configured, everything else works fully (text, federation, DMs,
|
||||||
search — works fully; only voice/video channels won't connect.
|
uploads, search); only voice/video channels won't connect.
|
||||||
|
|
||||||
## Federation
|
## Federation
|
||||||
|
|
||||||
@@ -336,11 +573,11 @@ Grab the installer for your platform from the
|
|||||||
|
|
||||||
| Platform | File | Notes |
|
| Platform | File | Notes |
|
||||||
|----------|------|-------|
|
|----------|------|-------|
|
||||||
| Windows | `Backspace-<version>.exe` | Universal installer (x64 + arm64). SmartScreen may warn on first run — choose "More info" → "Run anyway". Auto-updates. |
|
| Windows | `Backspace-<version>.exe` | Universal installer (x64 + arm64). SmartScreen may warn on first run; choose "More info" → "Run anyway". Auto-updates. |
|
||||||
| macOS | `Backspace-<version>-arm64.dmg` (Apple Silicon) / `Backspace-<version>-x64.dmg` (Intel) | Builds are currently **unsigned**: on first launch, right-click the app → **Open** → **Open**. Auto-update is not available on macOS yet — check the releases page for new versions. |
|
| macOS | `Backspace-<version>-arm64.dmg` (Apple Silicon) / `Backspace-<version>-x64.dmg` (Intel) | Builds are currently **unsigned**: on first launch, right-click the app → **Open** → **Open**. Auto-update is not available on macOS yet, so check the releases page for new versions. |
|
||||||
| Linux | `Backspace-<version>-x86_64.AppImage` / `-arm64.AppImage`, or `.deb` (`amd64` / `arm64`) | AppImage auto-updates; `.deb` installs update via new releases. |
|
| Linux | `Backspace-<version>-x86_64.AppImage` / `-arm64.AppImage`, or `.deb` (`amd64` / `arm64`) | AppImage auto-updates; `.deb` installs update via new releases. |
|
||||||
|
|
||||||
On first launch the app asks for your instance URL — enter the address of the
|
On first launch the app asks for your instance URL. Enter the address of the
|
||||||
Backspace server you use (e.g. `https://chat.example.com`).
|
Backspace server you use (e.g. `https://chat.example.com`).
|
||||||
|
|
||||||
### Building from source
|
### Building from source
|
||||||
@@ -357,14 +594,14 @@ Cross-platform builds are produced for Windows, macOS, and Linux. See
|
|||||||
|
|
||||||
## Mobile
|
## Mobile
|
||||||
|
|
||||||
Backspace works on mobile today — just open your instance in a phone browser.
|
Backspace works on mobile today. Just open your instance in a phone browser.
|
||||||
The UI has a dedicated touch layout (bottom navigation, swipe gestures, and
|
The UI has a dedicated touch layout (bottom navigation, swipe gestures, and
|
||||||
full-screen views), and because it ships as an installable **PWA** you can use
|
full-screen views), and because it ships as an installable **PWA** you can use
|
||||||
your browser's **Add to Home Screen** to install it as a standalone app: its own
|
your browser's **Add to Home Screen** to install it as a standalone app: its own
|
||||||
icon, no browser chrome, and an offline message queue that flushes when you
|
icon, no browser chrome, and an offline message queue that flushes when you
|
||||||
reconnect.
|
reconnect.
|
||||||
|
|
||||||
Native **iOS and Android app-store apps are planned** — once the project gains
|
Native **iOS and Android app-store apps are planned**, once the project gains
|
||||||
traction and the funding for the developer-program licenses is secured. Until
|
traction and the funding for the developer-program licenses is secured. Until
|
||||||
then, the installable PWA is the supported way to run Backspace on a phone.
|
then, the installable PWA is the supported way to run Backspace on a phone.
|
||||||
|
|
||||||
@@ -374,10 +611,10 @@ Backspace is a TypeScript monorepo managed with pnpm workspaces.
|
|||||||
|
|
||||||
```
|
```
|
||||||
packages/
|
packages/
|
||||||
shared/ — Shared types, permission bits, constants
|
shared/ - Shared types, permission bits, constants
|
||||||
server/ — Fastify API + WebSocket server, Drizzle/SQLite, federation
|
server/ - Fastify API + WebSocket server, Drizzle/SQLite, federation
|
||||||
web/ — React 18 SPA (Vite, Tailwind, Zustand)
|
web/ - React 18 SPA (Vite, Tailwind, Zustand)
|
||||||
desktop/ — Electron wrapper
|
desktop/ - Electron wrapper
|
||||||
```
|
```
|
||||||
|
|
||||||
| Layer | Technology |
|
| Layer | Technology |
|
||||||
@@ -392,26 +629,61 @@ packages/
|
|||||||
| Deployment | Docker Compose + Caddy (auto-HTTPS) |
|
| Deployment | Docker Compose + Caddy (auto-HTTPS) |
|
||||||
|
|
||||||
Every subsystem has a dedicated specification under
|
Every subsystem has a dedicated specification under
|
||||||
[`docs/systems/`](docs/systems/) — database schema, REST API, WebSocket
|
[`docs/systems/`](docs/systems/): database schema, REST API, WebSocket
|
||||||
protocol, federation, permissions, voice, the design system, and more. **These
|
protocol, federation, permissions, voice, the design system, and more. **These
|
||||||
are the reference for how Backspace works**; start there if you want to
|
are the reference for how Backspace works**; start there if you want to
|
||||||
understand or extend a subsystem.
|
understand or extend a subsystem.
|
||||||
|
|
||||||
|
## FAQ
|
||||||
|
|
||||||
|
**Is Backspace a self-hosted Discord alternative?**
|
||||||
|
Yes. It gives you a Discord-style experience (spaces, channels, roles, voice,
|
||||||
|
video, screen sharing, DMs, friends) that you run entirely on your own server, so
|
||||||
|
you own the data and set the rules.
|
||||||
|
|
||||||
|
**How is it different from Revolt, Spacebar, Matrix, or Mumble?**
|
||||||
|
See the full [comparison](docs/comparison.md), including where each of those is the
|
||||||
|
better choice. In short: Backspace pairs a Discord-style client with a serious
|
||||||
|
voice and screen-share control surface and optional server-to-server federation.
|
||||||
|
|
||||||
|
**Does it have screen sharing and high-quality video?**
|
||||||
|
Yes. Screen sharing goes up to 4K/120fps within admin-set bounds, with per-stream
|
||||||
|
codec, bitrate, and resolution controls, RNNoise noise suppression, and a live
|
||||||
|
connection inspector. Voice and video use [LiveKit](https://livekit.io/) and are
|
||||||
|
optional; text, federation, DMs, and everything else run fully without them.
|
||||||
|
|
||||||
|
**Can I self-host it on a Raspberry Pi?**
|
||||||
|
Yes. The installer pulls a prebuilt multi-architecture image (amd64 and arm64), so
|
||||||
|
low-power and ARM boxes skip the heavy local build.
|
||||||
|
|
||||||
|
**Is it really open source?**
|
||||||
|
Yes, under the GNU AGPL-3.0. A separate commercial license is available for cases
|
||||||
|
the AGPL does not fit. Every released version stays available under the AGPL.
|
||||||
|
|
||||||
|
**Does it work on mobile?**
|
||||||
|
Yes, as an installable PWA with a dedicated touch layout. Native iOS and Android
|
||||||
|
apps are planned.
|
||||||
|
|
||||||
|
**What does "federation" mean here?**
|
||||||
|
Independent Backspace instances can peer with each other so users on different
|
||||||
|
servers can be friends, DM, and call across instances, while each server stays
|
||||||
|
independently owned. Requests between servers are HMAC-authenticated.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Contributions are welcome. Please read [`CONTRIBUTING.md`](CONTRIBUTING.md)
|
Contributions are welcome. Please read [`CONTRIBUTING.md`](CONTRIBUTING.md)
|
||||||
first. Backspace is a single-owner project, so all contributors sign a
|
first. Backspace is a single-owner project, so all contributors sign a
|
||||||
[Contributor License Agreement](CLA.md) — a one-time comment on your pull
|
[Contributor License Agreement](CLA.md), a one-time comment on your pull
|
||||||
request, handled automatically by a bot. **You keep the copyright to your
|
request, handled automatically by a bot. **You keep the copyright to your
|
||||||
contribution** and grant the maintainer (Jannis Braun) an exclusive license to
|
contribution** and grant the maintainer (Jannis Braun) an exclusive license to
|
||||||
it — which is what lets Backspace be offered under both the AGPL and a commercial
|
it, which is what lets Backspace be offered under both the AGPL and a commercial
|
||||||
license. You also receive a perpetual license to reuse the specific code you
|
license. You also receive a perpetual license to reuse the specific code you
|
||||||
wrote in your own other projects.
|
wrote in your own other projects.
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
If you discover a security vulnerability, please **do not** open a public issue.
|
If you discover a security vulnerability, please **do not** open a public issue.
|
||||||
Report it privately via a GitHub security advisory on this repository — see
|
Report it privately via a GitHub security advisory on this repository. See
|
||||||
[`SECURITY.md`](SECURITY.md). We'll work with you on a fix and coordinated
|
[`SECURITY.md`](SECURITY.md). We'll work with you on a fix and coordinated
|
||||||
disclosure.
|
disclosure.
|
||||||
|
|
||||||
@@ -422,23 +694,23 @@ Backspace is **free and open source software**, licensed under the
|
|||||||
|
|
||||||
In plain terms:
|
In plain terms:
|
||||||
|
|
||||||
- ✅ Self-host, run, study, and modify it — including commercially and inside a business.
|
- Yes: self-host, run, study, and modify it, including commercially and inside a business.
|
||||||
- ✅ Redistribute it and your changes under the same AGPL-3.0 license.
|
- Yes: redistribute it and your changes under the same AGPL-3.0 license.
|
||||||
- ⚠️ If you run a **modified** version as a network service, you must offer your
|
- Note: if you run a **modified** version as a network service, you must offer your
|
||||||
users its complete corresponding source (AGPL § 13). Backspace makes this easy —
|
users its complete corresponding source (AGPL § 13). Backspace makes this easy:
|
||||||
set `BACKSPACE_SOURCE_URL` to your fork so the in-app "Source code" link points
|
set `BACKSPACE_SOURCE_URL` to your fork so the in-app "Source code" link points
|
||||||
at what you actually run.
|
at what you actually run.
|
||||||
- ⚠️ Preserve the copyright and license notices.
|
- Note: preserve the copyright and license notices.
|
||||||
|
|
||||||
**Commercial license.** If the AGPL doesn't fit — embedding Backspace in a
|
**Commercial license.** If the AGPL doesn't fit (embedding Backspace in a
|
||||||
closed-source product, offering it as a managed service without publishing your
|
closed-source product, offering it as a managed service without publishing your
|
||||||
modifications, or an organization that can't use AGPL software — a separate
|
modifications, or an organization that can't use AGPL software), a separate
|
||||||
commercial license is available on request. See
|
commercial license is available on request. See
|
||||||
[`LICENSE-COMMERCIAL.md`](LICENSE-COMMERCIAL.md).
|
[`LICENSE-COMMERCIAL.md`](LICENSE-COMMERCIAL.md).
|
||||||
|
|
||||||
> **Our open-source commitment.** Every released version of Backspace is, and
|
> **Our open-source commitment.** Every released version of Backspace is, and
|
||||||
> will remain, available under the AGPL-3.0. The Contributor License Agreement
|
> will remain, available under the AGPL-3.0. The Contributor License Agreement
|
||||||
> exists to enable a commercial license and optional enterprise add-ons — **not**
|
> exists to enable a commercial license and optional enterprise add-ons, **not**
|
||||||
> to take the open-source edition private. If this project is ever abandoned, or
|
> to take the open-source edition private. If this project is ever abandoned, or
|
||||||
> the open-source edition is relicensed under non-free terms, the community stays
|
> the open-source edition is relicensed under non-free terms, the community stays
|
||||||
> free to fork the last AGPL release.
|
> free to fork the last AGPL release.
|
||||||
|
|||||||
|
After Width: | Height: | Size: 222 KiB |
@@ -32,6 +32,16 @@ PI_PATH="~/backspace"
|
|||||||
BETA_HOST="orbit.ddns.net"
|
BETA_HOST="orbit.ddns.net"
|
||||||
BETA_PATH="~/backspace"
|
BETA_PATH="~/backspace"
|
||||||
|
|
||||||
|
# ── Local target override (gitignored) ──────────────────────
|
||||||
|
# The values above are public placeholders. A maintainer can point this script
|
||||||
|
# at real infrastructure by creating ./.deploy.local (git-ignored) that reassigns
|
||||||
|
# PI_USER / PI_LOCAL / PI_REMOTE / PI_PATH / BETA_HOST / BETA_PATH. This keeps
|
||||||
|
# real hostnames, IPs, and usernames out of the public repo. Never commit it.
|
||||||
|
if [[ -f ./.deploy.local ]]; then
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
source ./.deploy.local
|
||||||
|
fi
|
||||||
|
|
||||||
# ── Rsync excludes ──────────────────────────────────────────
|
# ── Rsync excludes ──────────────────────────────────────────
|
||||||
|
|
||||||
EXCLUDES=(
|
EXCLUDES=(
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# ============================================================
|
||||||
|
# Backspace — Reverse-proxy / Tunnel override
|
||||||
|
# ============================================================
|
||||||
|
# Overlay for Mode 2 (behind your own reverse proxy) and Mode 3 (tunnel, e.g.
|
||||||
|
# Cloudflare Tunnel / Tailscale). Layer it on top of the base compose file:
|
||||||
|
#
|
||||||
|
# docker compose -f docker-compose.yml -f docker-compose.proxy.yml up -d
|
||||||
|
#
|
||||||
|
# What it changes versus the all-in-one base:
|
||||||
|
# 1. Publishes the app on 127.0.0.1:${APP_PORT} (loopback only) so your own
|
||||||
|
# reverse proxy or tunnel daemon on the same host can reach it. Nothing is
|
||||||
|
# exposed on a public interface by this stack — TLS/termination is the
|
||||||
|
# proxy's job.
|
||||||
|
# 2. Moves the bundled Caddy into a profile that is never activated here, so it
|
||||||
|
# does NOT start (your proxy owns 80/443). The base file leaves Caddy in the
|
||||||
|
# default profile, so Mode 1 (`-f docker-compose.yml` alone) is unchanged.
|
||||||
|
#
|
||||||
|
# ./install.sh selects the right `-f` combination automatically per mode; this
|
||||||
|
# file is also usable by hand for a fully manual setup.
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
services:
|
||||||
|
backspace:
|
||||||
|
# Bind to loopback only. The reverse proxy / tunnel connects over 127.0.0.1;
|
||||||
|
# the app is never reachable directly from the network. Container listens on
|
||||||
|
# PORT (default 3000); APP_PORT is the host-side port your proxy forwards to.
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${APP_PORT:-8080}:${PORT:-3000}"
|
||||||
|
|
||||||
|
caddy:
|
||||||
|
# Park Caddy in a profile that install.sh / the documented commands never
|
||||||
|
# enable, so the merged config drops it in proxy/tunnel mode. (Compose
|
||||||
|
# replaces the `profiles` list on merge; the base service has none, so this
|
||||||
|
# is the effective value only when this override is layered on.)
|
||||||
|
profiles:
|
||||||
|
- _proxy_mode_no_caddy
|
||||||
@@ -10,6 +10,14 @@
|
|||||||
services:
|
services:
|
||||||
# ── Backspace application server ──────────────────────────
|
# ── Backspace application server ──────────────────────────
|
||||||
backspace:
|
backspace:
|
||||||
|
# Prebuilt multi-arch image on GHCR (published by .github/workflows/
|
||||||
|
# docker-publish.yml). `docker compose pull` / install.sh's default path
|
||||||
|
# fetches this so weak/ARM hosts skip the heavy local build. Both `image:`
|
||||||
|
# and `build:` are declared: if the image isn't present locally and can't be
|
||||||
|
# pulled, `docker compose up --build` (install.sh's fallback, and deploy.sh)
|
||||||
|
# builds from source instead and tags the result under this same ref.
|
||||||
|
# Override the tag with BACKSPACE_IMAGE_TAG in .env (defaults to `latest`).
|
||||||
|
image: ${BACKSPACE_IMAGE:-ghcr.io/thezwiss/backspace}:${BACKSPACE_IMAGE_TAG:-latest}
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
args:
|
args:
|
||||||
@@ -27,6 +35,12 @@ services:
|
|||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- DB_PATH=/app/data/backspace.db
|
- DB_PATH=/app/data/backspace.db
|
||||||
- UPLOAD_DIR=/app/data/uploads
|
- UPLOAD_DIR=/app/data/uploads
|
||||||
|
# Fail fast with a clear message if the secret is missing/empty, instead of
|
||||||
|
# letting the container boot-crash on every `restart: unless-stopped` cycle
|
||||||
|
# (a silent loop that only shows up in `docker compose logs backspace`).
|
||||||
|
# install.sh generates this before bringing the stack up; this guard only
|
||||||
|
# bites the manual `cp .env.example .env && docker compose up` path.
|
||||||
|
- "JWT_SECRET=${JWT_SECRET:?Set JWT_SECRET in .env - generate one with: openssl rand -hex 32}"
|
||||||
networks:
|
networks:
|
||||||
- internal
|
- internal
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# Backspace compared to other chat platforms
|
||||||
|
|
||||||
|
This page is an honest look at how Backspace fits next to the tools people usually
|
||||||
|
weigh against it: Discord, Revolt, Spacebar, Matrix/Element, and Mumble. It
|
||||||
|
includes the places where those tools are the better choice. Feature sets change,
|
||||||
|
so verify current details on each project before you decide, and open an issue if
|
||||||
|
anything here is out of date.
|
||||||
|
|
||||||
|
Short version: Backspace is for people who want a Discord-style experience they
|
||||||
|
fully self-host, with serious voice and screen-share controls, and the option to
|
||||||
|
federate independently owned servers. If you need the largest ecosystem, a mature
|
||||||
|
open federation standard, or native mobile apps today, one of the others may suit
|
||||||
|
you better.
|
||||||
|
|
||||||
|
## Feature matrix
|
||||||
|
|
||||||
|
| | Backspace | Discord | Revolt | Spacebar | Matrix / Element | Mumble |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| Self-hostable | Yes | No | Yes | Yes | Yes | Yes |
|
||||||
|
| Open source | Yes (AGPL-3.0) | No | Yes | Yes | Yes | Yes |
|
||||||
|
| Discord-style UX | Yes | Yes | Yes | Yes (Discord client) | Different model | No |
|
||||||
|
| Text chat, roles, reactions | Yes | Yes | Yes | Yes | Yes | Limited |
|
||||||
|
| Voice channels | Yes | Yes | Yes | Partial | Yes | Yes (focus) |
|
||||||
|
| Video and screen share | Yes, up to 4K/120fps within admin bounds | Yes | Limited | Partial | Yes | No |
|
||||||
|
| Per-stream media controls (codec, bitrate, resolution) | Yes | No | No | No | Partial | Some audio |
|
||||||
|
| Federation between servers | Yes, between Backspace instances | No | No | No | Yes, open standard | No |
|
||||||
|
| Native mobile apps | Installable PWA | Yes | Yes | Desktop client only | Yes | Yes |
|
||||||
|
| Runs on a Raspberry Pi | Yes (prebuilt arm64 image) | No | Yes | Yes | Yes | Yes |
|
||||||
|
|
||||||
|
"Partial" and "Limited" mean the capability exists but is less complete or less
|
||||||
|
polished than the leaders in that row at the time of writing. Check the current
|
||||||
|
state of each project.
|
||||||
|
|
||||||
|
## Backspace vs Discord
|
||||||
|
|
||||||
|
Discord is the reference experience and has the ecosystem, the bots, and the user
|
||||||
|
base. It is also proprietary, you cannot host it, and you do not own the data or
|
||||||
|
the moderation policy. Backspace exists for the people who want the Discord shape
|
||||||
|
without giving up ownership. You trade the ecosystem and the network effect for
|
||||||
|
control of the server, the data, and the rules. If you want the biggest community
|
||||||
|
and the deepest bot ecosystem, use Discord. If you want to own your instance, use
|
||||||
|
Backspace.
|
||||||
|
|
||||||
|
## Backspace vs Revolt
|
||||||
|
|
||||||
|
Revolt is the closest peer: an open-source, self-hostable, Discord-style chat with
|
||||||
|
an active community and a Rust backend. The main differences are the media stack
|
||||||
|
and federation. Backspace is built around a full voice and video control surface
|
||||||
|
(per-stream codec, bitrate, and resolution, RNNoise, a live connection inspector,
|
||||||
|
screen share up to 4K/120fps within admin limits) and supports peering independent
|
||||||
|
instances. If high-quality voice and screen sharing or cross-instance federation
|
||||||
|
are central to you, Backspace is aimed squarely at that. If you want a larger
|
||||||
|
existing community and a longer track record, look at Revolt.
|
||||||
|
|
||||||
|
## Backspace vs Spacebar
|
||||||
|
|
||||||
|
Spacebar reimplements the Discord backend so the actual Discord client can talk to
|
||||||
|
a server you host. That is a clever path to instant client familiarity, and it is
|
||||||
|
the right pick if using the real Discord app against your own backend is the goal.
|
||||||
|
The trade-off is that it inherits Discord's client and its constraints, and its
|
||||||
|
voice stack is still maturing. Backspace ships its own client and its own voice
|
||||||
|
and video stack, and it is federation-first rather than Discord-protocol-first.
|
||||||
|
|
||||||
|
## Backspace vs Matrix and Element
|
||||||
|
|
||||||
|
Matrix is the mature, standardized answer to open federation, and Element is its
|
||||||
|
best-known client. If interoperable, standards-based federation across many
|
||||||
|
different server and client implementations is your priority, Matrix is the
|
||||||
|
stronger choice and Backspace does not try to replace it. Backspace federation is
|
||||||
|
newer, simpler, and currently peers Backspace instances with each other rather
|
||||||
|
than speaking an open cross-ecosystem protocol. Where Backspace differs is the
|
||||||
|
experience: a tightly integrated Discord-style client with a purpose-built media
|
||||||
|
control surface, rather than a protocol with many clients of varying polish.
|
||||||
|
|
||||||
|
## Backspace vs Mumble
|
||||||
|
|
||||||
|
Mumble is outstanding at exactly one thing: low-latency voice for groups, self
|
||||||
|
hosted, lightweight. It has no rich text platform, no video, and no federation.
|
||||||
|
If all you need is the best self-hosted push-to-talk voice, Mumble is a great,
|
||||||
|
proven choice. Backspace is a full communication platform (text, voice, video,
|
||||||
|
files, social, federation) rather than a dedicated voice server, so pick it when
|
||||||
|
you want more than voice.
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
# Design: Split `routes/federation.ts` into cohesive modules
|
||||||
|
|
||||||
|
**Date:** 2026-07-10
|
||||||
|
**Status:** Approved (strategy) — pending implementation plan
|
||||||
|
**Author:** Lead Developer
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
`packages/server/src/routes/federation.ts` is **7,626 lines** — the largest file in the
|
||||||
|
codebase by nearly 3×. It bundles at least five unrelated responsibilities:
|
||||||
|
|
||||||
|
1. HTTP route registration (`federationRoutes()` alone is ~2,800 lines / 30 endpoints)
|
||||||
|
2. Federated identity resolution (`resolveLocalUser`, `resolveOrCreateReplicatedUser`, …)
|
||||||
|
3. ~30 inbound relay event processors (`process*Event`)
|
||||||
|
4. DM channel / federated-id reconciliation helpers
|
||||||
|
5. Rate-limiting, nonce, and peer-approval-queue internals
|
||||||
|
|
||||||
|
A file this size is unreviewable (the originating complaint) and unsafe to change: any
|
||||||
|
diff touching it is hard to reason about, and the file is too large to hold in working
|
||||||
|
memory (human or model) at once.
|
||||||
|
|
||||||
|
This is a **structural** problem, not a behavioral one. The fix is to split the file
|
||||||
|
along its existing responsibility seams — **not** to change any behavior.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Every module has **one clear purpose**, is independently readable (~150–850 lines),
|
||||||
|
and communicates through explicit imports/exports.
|
||||||
|
- **Zero behavior change.** The ~21,700 lines of existing federation tests are the proof
|
||||||
|
and must pass unchanged.
|
||||||
|
- **Zero import-path churn** anywhere else in the codebase or tests.
|
||||||
|
|
||||||
|
## Non-Goals (explicitly deferred to a separate follow-up — "Phase C")
|
||||||
|
|
||||||
|
- Refactoring or de-duplicating logic *inside* the moved functions.
|
||||||
|
- Removing intra-function dead branches.
|
||||||
|
- Renaming symbols or changing signatures.
|
||||||
|
|
||||||
|
These are real but must not be mixed into the move: an edit-while-moving diff destroys
|
||||||
|
`git`'s move detection and re-creates the unreviewability problem. Cleanup, if warranted,
|
||||||
|
lands afterward as small edits scoped to the now-isolated modules.
|
||||||
|
|
||||||
|
## Key findings from analysis
|
||||||
|
|
||||||
|
- **No dead top-level code.** All 78 top-level symbols are reachable (self-referenced,
|
||||||
|
imported by another source module, or exercised by tests). A "prune dead functions"
|
||||||
|
phase has **no targets** — this is a pure move. (Verified by a per-symbol reference
|
||||||
|
count across all `packages/**/*.ts`.)
|
||||||
|
- **Small public surface.** Only **5 symbols** are imported by other *source* files:
|
||||||
|
`validateOrigin`, `extractDomain`, `backfillReplicatedProfileAssets`,
|
||||||
|
`sweepDeadIncarnationArtifacts`, `reconcileDriftedDmFederatedIds`, plus `federationRoutes`
|
||||||
|
(from `index.ts`). Tests import the 22 currently-`export`ed symbols. Everything else is
|
||||||
|
file-internal.
|
||||||
|
- **`federationRoutes()` has no function-local shared state.** Its body is a flat list of
|
||||||
|
`app.<verb>()` registrations; each handler closes only over module-level imports and the
|
||||||
|
5 module-level rate-limit maps + nonce store. Splitting it into independent registrar
|
||||||
|
functions requires no hoisting.
|
||||||
|
- **Build mechanics:** `moduleResolution: "bundler"`, `isolatedModules: true`,
|
||||||
|
`noUnusedLocals: false`. Importers use explicit `./routes/federation.js` specifiers.
|
||||||
|
|
||||||
|
## Strategy: barrel + submodule directory
|
||||||
|
|
||||||
|
`routes/federation.ts` **stays as a file** and becomes a thin **barrel**:
|
||||||
|
|
||||||
|
- It re-exports the 22 public symbols from their new homes, so every existing
|
||||||
|
`from '.../routes/federation.js'` import (source **and** test) resolves unchanged.
|
||||||
|
The one interface (`DmReconcileResult`) is re-exported via `export type { … }`
|
||||||
|
(required by `isolatedModules`).
|
||||||
|
- It defines `federationRoutes(app)` as a thin function that calls the six route
|
||||||
|
registrars in order.
|
||||||
|
|
||||||
|
Because importers use the explicit `.js` specifier, the barrel **must** remain a file at
|
||||||
|
`routes/federation.ts`; the extracted modules live beside it in a new `routes/federation/`
|
||||||
|
directory (a file and a same-named directory coexist fine on disk and under bundler
|
||||||
|
resolution).
|
||||||
|
|
||||||
|
## Target module layout
|
||||||
|
|
||||||
|
```
|
||||||
|
packages/server/src/routes/
|
||||||
|
federation.ts ← BARREL: re-exports public API + federationRoutes()
|
||||||
|
federation/
|
||||||
|
rateLimits.ts ← rate-limit consts+maps+fns, nonce store, _resetLookupRateBuckets
|
||||||
|
origin.ts ← validateOrigin, resolveLocalOrigin, sanitizePeer, SanitizedPeer
|
||||||
|
identity.ts ← extractDomain, getOurIdentityDomain, verifyAttribution,
|
||||||
|
resolveLocalUser, findFederatedUser,
|
||||||
|
resolveOrCreateReplicatedUser, backfillHomeUserId
|
||||||
|
dmChannels.ts ← buildDmChannelPayload, findOrCreateDmChannel,
|
||||||
|
buildDmMessagePayload, isUrlFromPeer, resolveLocalDmMessage
|
||||||
|
profile.ts ← hydrateReplicatedUserProfile, downloadProfileAsset,
|
||||||
|
processProfileUpdateEvent, backfillReplicatedProfileAssets
|
||||||
|
reconciliation.ts ← DmReconcileResult, reconcileDmChannelFederatedId,
|
||||||
|
reconcileDriftedDmFederatedIds, sweepDeadIncarnationArtifacts
|
||||||
|
events/
|
||||||
|
dmMessages.ts ← processCreate/Update/Delete/ReactionAdd/ReactionRemove Event
|
||||||
|
membership.ts ← processMemberAdd/MemberRemove/OwnershipTransfer/GroupMetadataUpdate Event
|
||||||
|
friends.ts ← processFriendRequestCreate/Update/Cancel, FriendAdd/Remove Event
|
||||||
|
calls.ts ← processDmCallStart/Accept/Reject/End, TypingStart/Stop,
|
||||||
|
fanOutCallEvent, emitHostFanoutUndeliverable
|
||||||
|
dmState.ts ← processDmClose/DmReopen/ReadStateUpdate/PresenceUpdate/FileRejected Event
|
||||||
|
dispatch.ts ← processRelayEvents (imports every processor above)
|
||||||
|
handlers/
|
||||||
|
peerHandshake.ts ← POST peer/initiate, peer/accept, peer/ensure, peer/rotate, peer/denied
|
||||||
|
peerAdmin.ts ← GET peers, reset-events(+ack), peers/:id GET/DELETE/permanent,
|
||||||
|
peers/:id reset/recheck/rotate
|
||||||
|
approvals.ts ← approval-requests(+approve/deny), peering-subscriptions,
|
||||||
|
peering-notifications + queueApprovalRequest,
|
||||||
|
handleInbound/OutboundApprove, handleInbound/OutboundDeny
|
||||||
|
relay.ts ← POST identity, relay, epoch, sync
|
||||||
|
lookup.ts ← POST users/lookup, users/by-home-id
|
||||||
|
files.ts ← POST verify-attach-proof
|
||||||
|
```
|
||||||
|
|
||||||
|
~19 modules, ~150–850 lines each (avg ~400). Route-registration order across registrars
|
||||||
|
is preserved by calling them in path order; Fastify does not depend on cross-path
|
||||||
|
registration order, so intra-group reordering (grouping interleaved endpoints) is safe.
|
||||||
|
|
||||||
|
### Dependency layering (acyclic)
|
||||||
|
|
||||||
|
```
|
||||||
|
L0 leaves: rateLimits · origin · identity · dmChannels
|
||||||
|
L1: profile · reconciliation · events/* (use L0)
|
||||||
|
L2: events/dispatch (uses all events/*)
|
||||||
|
L3: handlers/* (use L0–L2)
|
||||||
|
L4 barrel: federation.ts (re-exports + calls handlers/*)
|
||||||
|
```
|
||||||
|
|
||||||
|
No leaf imports upward, so no import cycles.
|
||||||
|
|
||||||
|
## Correctness / verification strategy
|
||||||
|
|
||||||
|
- **Test net:** the full server suite (`pnpm --filter @backspace/server test`, ~30
|
||||||
|
federation test files / ~21.7k lines) runs after **each** module group is extracted.
|
||||||
|
Green throughout = behavior preserved.
|
||||||
|
- **Typecheck + build** (`pnpm -w typecheck && pnpm --filter @backspace/server build`)
|
||||||
|
after each group catches import/type regressions immediately.
|
||||||
|
- **Move discipline:** functions are moved **verbatim**. The only permitted edits are
|
||||||
|
(a) adding `import`/`export` statements, and (b) the barrel re-exports. No logic edits.
|
||||||
|
- **`git diff -M`** on the final result should render as moves + a small barrel — the
|
||||||
|
reviewability property we are buying.
|
||||||
|
|
||||||
|
## Rollout (phased so each commit is independently verifiable)
|
||||||
|
|
||||||
|
- **Phase A — Prune.** *Empty by analysis* (no dead top-level code). Skipped; documented
|
||||||
|
here so the absence is deliberate, not overlooked.
|
||||||
|
- **Phase B — Extract (this design).** One commit per module group, tests green at each:
|
||||||
|
1. Leaf helpers: `rateLimits`, `origin`, `identity`, `dmChannels`
|
||||||
|
2. `profile`, `reconciliation`
|
||||||
|
3. `events/*` + `events/dispatch`
|
||||||
|
4. `handlers/*` + convert `federation.ts` to the barrel
|
||||||
|
5. Update `docs/systems/federation.md` source-file map
|
||||||
|
- **Phase C — Cleanup (separate, later, optional).** Intra-module logic simplification,
|
||||||
|
now reviewable because each concern is isolated in a small file.
|
||||||
|
|
||||||
|
## Risks & mitigations
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|------|-----------|
|
||||||
|
| Import cycle between extracted modules | Enforced L0–L4 layering; typecheck catches any cycle immediately |
|
||||||
|
| A test imports a symbol the barrel forgot to re-export | Barrel re-exports the exact set of 22 currently-`export`ed symbols; verified against the export grep |
|
||||||
|
| `isolatedModules` breaks type re-export | `DmReconcileResult` re-exported via `export type { … }` |
|
||||||
|
| Route path resolution changes | Barrel stays a file at `routes/federation.ts`; no importer specifier changes |
|
||||||
|
| Hidden shared local state in `federationRoutes` | Verified none exists (flat `app.<verb>()` body) |
|
||||||
|
```
|
||||||
@@ -4,9 +4,11 @@ Operator- and contributor-facing reference for hosting Backspace: the Docker bui
|
|||||||
|
|
||||||
Source files:
|
Source files:
|
||||||
- `Dockerfile` -- multi-stage build (builder → runtime)
|
- `Dockerfile` -- multi-stage build (builder → runtime)
|
||||||
- `docker-compose.yml` -- `backspace` + `caddy` (+ optional `livekit`) services, healthcheck
|
- `docker-compose.yml` -- base stack: `backspace` + `caddy` (+ optional `livekit`) services, healthcheck
|
||||||
- `Caddyfile` -- reverse proxy / auto-HTTPS config
|
- `docker-compose.proxy.yml` -- proxy/tunnel overlay: publishes the app on `127.0.0.1:APP_PORT` and drops Caddy
|
||||||
- `install.sh` -- interactive first-time setup
|
- `.github/workflows/docker-publish.yml` -- multi-arch (amd64+arm64) GHCR image publish
|
||||||
|
- `Caddyfile` -- reverse proxy / auto-HTTPS config (All-in-One mode only)
|
||||||
|
- `install.sh` -- interactive first-time setup, mode-aware (allinone / proxy / tunnel)
|
||||||
- `deploy.sh` -- rsync + rebuild to Heidi's two boxes
|
- `deploy.sh` -- rsync + rebuild to Heidi's two boxes
|
||||||
- `backup.sh` / `restore.sh` -- manual snapshot + restore tooling (host side)
|
- `backup.sh` / `restore.sh` -- manual snapshot + restore tooling (host side)
|
||||||
- `packages/server/src/config.ts` -- `config.backup.*` env parsing
|
- `packages/server/src/config.ts` -- `config.backup.*` env parsing
|
||||||
@@ -25,7 +27,35 @@ Source files:
|
|||||||
|
|
||||||
## 1. Pipeline Overview
|
## 1. Pipeline Overview
|
||||||
|
|
||||||
Backspace ships as a single application container fronted by Caddy. Everything is built and run via Docker Compose; there is no separate CI artifact — **the image is built on each target host** from source.
|
Backspace ships as a single application container. In the default **All-in-One** deployment it is fronted by the bundled Caddy (automatic HTTPS); behind an operator's own reverse proxy or a tunnel, Caddy is dropped and the container is published on a host loopback port instead (see [Deployment modes](#deployment-modes) below). The application image is a **prebuilt multi-architecture image published to GHCR** — `docker compose pull` (install.sh's default path) fetches `ghcr.io/thezwiss/backspace` for `linux/amd64` or `linux/arm64`, so weak/ARM hosts skip the heavy local build; a from-source build is the fallback when the image can't be pulled.
|
||||||
|
|
||||||
|
### Prebuilt image (GHCR)
|
||||||
|
|
||||||
|
`.github/workflows/docker-publish.yml` builds and pushes the application image to `ghcr.io/thezwiss/backspace` on every `v*` tag (and on manual `workflow_dispatch`). It is deliberately **separate from** the desktop-installer workflow (`release.yml`): the two share the `v*` tag trigger but build entirely different artifacts and must not be entangled.
|
||||||
|
|
||||||
|
- **Multi-arch.** `docker/setup-qemu-action` + `buildx` build `linux/amd64,linux/arm64` in one push, so a Raspberry Pi pulls a native image instead of cross-building (the Vite build OOMs small ARM boxes).
|
||||||
|
- **Tags.** `docker/metadata-action` derives `{version}`, `{major}.{minor}`, `latest` (on `v*` tags), and `sha-<short>`. A `workflow_dispatch` with an extra `tag` input publishes that tag too (e.g. `latest` without cutting a release).
|
||||||
|
- **AGPL § 13 commit stamping is preserved.** The workflow resolves `git rev-parse --short HEAD` and passes it as `--build-arg BACKSPACE_COMMIT=…`, exactly like `install.sh`/`deploy.sh`, plus OCI labels (`source`, `licenses=AGPL-3.0-only`, `revision`). The pulled image therefore advertises its exact source version via `GET /api/instance/info`.
|
||||||
|
- **Auth.** The push authenticates with the built-in `GITHUB_TOKEN` (`permissions: packages: write`). The GHCR package must be set **public** once for unauthenticated `docker pull` to work.
|
||||||
|
- **Compose wiring.** `docker-compose.yml` declares **both** `image: ${BACKSPACE_IMAGE:-ghcr.io/thezwiss/backspace}:${BACKSPACE_IMAGE_TAG:-latest}` **and** `build: .`. `pull`/`up` uses the image; `up --build` (deploy.sh, or install.sh's fallback) builds from source and tags the result under the same ref. Operators pin a version or point at a fork's registry via `BACKSPACE_IMAGE` / `BACKSPACE_IMAGE_TAG`.
|
||||||
|
|
||||||
|
### Deployment modes
|
||||||
|
|
||||||
|
One installer, three modes, recorded as `DEPLOY_MODE` in `.env`. `install.sh` auto-detects (and, when ambiguous, prompts); a non-interactive run honors an explicit `DEPLOY_MODE`.
|
||||||
|
|
||||||
|
| Mode | Ports 80/443 | Topology | TLS | Voice |
|
||||||
|
|------|--------------|----------|-----|-------|
|
||||||
|
| `allinone` (default) | must be free | base `docker-compose.yml`: `backspace` + `caddy` (+ `livekit`) | bundled Caddy (Let's Encrypt) | ✅ with UDP media ports open |
|
||||||
|
| `proxy` | already taken | base **+** `docker-compose.proxy.yml`: `backspace` on `127.0.0.1:APP_PORT`, no Caddy | operator's reverse proxy | ✅ if operator proxies `/livekit` and opens media ports |
|
||||||
|
| `tunnel` | already taken | same overlay as `proxy` | tunnel provider (Cloudflare, Tailscale…) | ❌ WebRTC/UDP can't traverse a tunnel |
|
||||||
|
|
||||||
|
**The overlay (`docker-compose.proxy.yml`).** Layered on top of the base file it (1) publishes `backspace` on `127.0.0.1:${APP_PORT:-8080}:${PORT:-3000}` — loopback only, so nothing is exposed on a public interface — and (2) parks `caddy` in an inert profile (`_proxy_mode_no_caddy`) that is never activated, so Caddy does not start. The base file is unchanged, so All-in-One (`docker-compose.yml` alone) behaves exactly as before.
|
||||||
|
|
||||||
|
**`COMPOSE_FILE` wiring.** In proxy/tunnel mode install.sh writes `COMPOSE_FILE=docker-compose.yml:docker-compose.proxy.yml` into `.env`. Docker Compose reads `COMPOSE_FILE` from `.env`, so **every** later `docker compose …` command in the directory transparently uses both files — the operator (and the update commands) never need `-f` flags. All-in-One leaves `COMPOSE_FILE` unset (defaults to `docker-compose.yml`).
|
||||||
|
|
||||||
|
**Server proxy-awareness.** The server sets Fastify `trustProxy: true`, so it trusts `X-Forwarded-*` from the fronting proxy/tunnel. `getOurOrigin()` returns `https://${DOMAIN}` (federation/public identity) whenever `DOMAIN` is set and `PUBLIC_ORIGIN` is unset — correct in all three modes, since the public URL is `https://DOMAIN` regardless of which layer terminates TLS. No `PUBLIC_ORIGIN` is needed for a normal proxy/tunnel deployment.
|
||||||
|
|
||||||
|
**Voice per mode.** LiveKit media is WebRTC over UDP and never flows through the HTTP proxy/tunnel — the media ports (`3478/udp` TURN, `7881/tcp` fallback, `50000-60000/udp` media) must be reachable from clients directly. All-in-One proxies LiveKit *signaling* through Caddy (`/livekit` → `host.docker.internal:7880`); a reverse-proxy operator must replicate that route (`/livekit` → `127.0.0.1:7880`, prefix stripped) **and** open the media ports. Over a tunnel, voice is unavailable and install.sh force-disables it. See `docs/systems/voice.md` for LiveKit tuning.
|
||||||
|
|
||||||
### Build: multi-stage Dockerfile
|
### Build: multi-stage Dockerfile
|
||||||
|
|
||||||
@@ -65,7 +95,13 @@ Caddy provisions and renews TLS certificates automatically for `DOMAIN`; the per
|
|||||||
|
|
||||||
### First-time setup: `install.sh`
|
### First-time setup: `install.sh`
|
||||||
|
|
||||||
`./install.sh` is the interactive installer for a fresh Linux host. It prompts for the domain, whether to enable voice, and an instance name (each skippable via the `DOMAIN` / `ENABLE_VOICE` / `INSTANCE_NAME` env vars for a non-interactive run), generates a `JWT_SECRET`, writes `.env`, optionally configures LiveKit (`livekit.yaml` + `COMPOSE_PROFILES=voice`), and brings the stack up (`docker compose build` then `up -d`).
|
`./install.sh` is the interactive installer for a fresh Linux host. It prompts for the domain, whether to enable voice, and an instance name (each skippable via the `DOMAIN` / `ENABLE_VOICE` / `INSTANCE_NAME` env vars for a non-interactive run), generates a `JWT_SECRET`, writes `.env`, optionally configures LiveKit (`livekit.yaml` + `COMPOSE_PROFILES=voice`), and brings the stack up.
|
||||||
|
|
||||||
|
**Mode selection.** Before configuring, it determines the deployment mode (precedence: explicit `DEPLOY_MODE` env → existing `.env` → auto-detect + prompt). Auto-detection checks whether ports 80/443 are free — and does so **Docker-aware**: it consults both `ss` *and* `docker ps` published ports, because a host running Docker with the userland proxy disabled DNATs 80/443 via iptables with **no listening socket for `ss` to see** (a box whose Caddy already owns those ports would otherwise be misread as "ports free"). When 80/443 are free it offers All-in-One (default); when taken it never dead-ends — it explains what holds them and steers to `proxy`/`tunnel`. In proxy/tunnel mode it auto-picks a free loopback `APP_PORT` (scanning past commonly-taken 3000/8080), force-lowers `MAX_UPLOAD_SIZE` to 90 MB for `tunnel` (Cloudflare's 100 MB body cap), and force-disables voice for `tunnel`.
|
||||||
|
|
||||||
|
**Image acquisition.** By default it pulls the prebuilt image (`docker compose pull backspace`). If the pull fails but a usable image is already present on the host (a prior run, an air-gapped `docker load`, or a previous from-source build tagged under the ref) it uses that copy rather than forcing a needless rebuild; only if neither pull nor a local image is available does it fall back to `docker compose build` (or when `BACKSPACE_BUILD=true` forces a source build, e.g. a fork). The commit is captured from the checkout and passed as `--build-arg BACKSPACE_COMMIT=<sha>` on the build path.
|
||||||
|
|
||||||
|
**Reverse-proxy / tunnel output.** In proxy/tunnel mode the post-deploy check verifies the app answers on `127.0.0.1:APP_PORT` (TLS is the operator's edge's job, not ours to test), and the summary prints paste-ready nginx / Caddy / Traefik snippets (proxy) or a `cloudflared` ingress rule (tunnel), each with WebSocket upgrade, `X-Forwarded-*`, and a body-size cap matching `MAX_UPLOAD_SIZE` already correct — plus the `/livekit` route and media ports when voice is on.
|
||||||
|
|
||||||
**Post-install HTTPS reachability check.** The container healthcheck only proves the app is up *inside* Docker — not that `https://DOMAIN` actually works, which additionally requires Caddy to have obtained a publicly-trusted certificate (DNS pointing here **and** ports 80/443 reachable from the internet). After the stack is healthy, the installer verifies this and reports it honestly instead of always printing success:
|
**Post-install HTTPS reachability check.** The container healthcheck only proves the app is up *inside* Docker — not that `https://DOMAIN` actually works, which additionally requires Caddy to have obtained a publicly-trusted certificate (DNS pointing here **and** ports 80/443 reachable from the internet). After the stack is healthy, the installer verifies this and reports it honestly instead of always printing success:
|
||||||
|
|
||||||
@@ -247,14 +283,16 @@ The pre-restore copy means a mistaken restore is itself undoable: the previous D
|
|||||||
|
|
||||||
## 5. Image Pinning & Upgrades
|
## 5. Image Pinning & Upgrades
|
||||||
|
|
||||||
The two pulled images are **pinned to explicit tags** in `docker-compose.yml`, never `latest`:
|
The third-party images are **pinned to explicit tags** in `docker-compose.yml`, never `latest`:
|
||||||
|
|
||||||
| Service | Pinned image |
|
| Service | Pinned image |
|
||||||
|---------|--------------|
|
|---------|--------------|
|
||||||
| `caddy` | `caddy:2.11.1-alpine` |
|
| `caddy` | `caddy:2.11.1-alpine` |
|
||||||
| `livekit` | `livekit/livekit-server:v1.9.11` |
|
| `livekit` | `livekit/livekit-server:v1.9.11` |
|
||||||
|
|
||||||
Pinning makes deploys reproducible — a rebuild pulls the exact same proxy/SFU version every time, so an upstream release can't silently change behavior under you. (The `backspace` image is built from source via the `Dockerfile`, which itself pins the `node:20-slim` base.)
|
Pinning makes deploys reproducible — a rebuild pulls the exact same proxy/SFU version every time, so an upstream release can't silently change behavior under you.
|
||||||
|
|
||||||
|
The `backspace` image itself defaults to `ghcr.io/thezwiss/backspace:latest` (`BACKSPACE_IMAGE` / `BACKSPACE_IMAGE_TAG`). `latest` is chosen for a frictionless first install, but it is a **moving** tag: operators who want reproducible upgrades should pin `BACKSPACE_IMAGE_TAG` to a released version (e.g. `1.0.0`) in `.env` and bump it deliberately. On the source-build paths (`deploy.sh`, `install.sh`'s fallback) the image is built from the `Dockerfile`, which pins the `node:20-slim` base.
|
||||||
|
|
||||||
**Upgrade procedure:** bump the tag in `docker-compose.yml` → test the new version (locally or on one box) → redeploy. Concretely:
|
**Upgrade procedure:** bump the tag in `docker-compose.yml` → test the new version (locally or on one box) → redeploy. Concretely:
|
||||||
|
|
||||||
@@ -270,7 +308,7 @@ Never pin to a floating tag like `latest` or a bare major — it defeats reprodu
|
|||||||
|
|
||||||
These are accepted constraints of the current deploy model, documented so operators aren't surprised:
|
These are accepted constraints of the current deploy model, documented so operators aren't surprised:
|
||||||
|
|
||||||
- **The image is built on each target host, including the ARM Raspberry Pi.** There is no cross-built/registry-pushed artifact. The Pi build is slower and consumes build resources on the box (`deploy.sh` caps the build cache and prunes old images to compensate). A native-module or toolchain regression can surface on ARM but not x86, or vice-versa.
|
- **`deploy.sh` still builds on each target host.** The public `install.sh` path now defaults to the prebuilt GHCR image (multi-arch, so a Pi pulls a native image), but `deploy.sh` — Heidi's rsync-then-`up -d --build` helper for `nova`/`orbit` — deliberately builds from the rsynced working tree on the box (it caps the build cache and prunes old images to compensate). A native-module or toolchain regression can still surface on ARM but not x86, or vice-versa, on that path; the CI multi-arch build catches most such regressions before release.
|
||||||
- **A deploy causes brief downtime + WebSocket reconnect.** `docker compose up -d --build` rebuilds and recreates the `backspace` container; while it restarts, the server is briefly unavailable and every connected client's WebSocket drops and must reconnect. There is no rolling/zero-downtime deploy. Clients reconnect automatically, but in-flight requests during the swap can fail.
|
- **A deploy causes brief downtime + WebSocket reconnect.** `docker compose up -d --build` rebuilds and recreates the `backspace` container; while it restarts, the server is briefly unavailable and every connected client's WebSocket drops and must reconnect. There is no rolling/zero-downtime deploy. Clients reconnect automatically, but in-flight requests during the swap can fail.
|
||||||
- **`deploy.sh all` can mask one host failing.** The `all` target runs both deploys in parallel (`deploy … & deploy … & wait`). The visible "Deployment complete." is printed regardless of whether one host's build failed mid-stream; the failure scrolls by in the interleaved output. After an `all` deploy, **confirm `/api/health` on both boxes** rather than trusting the final line. For a high-stakes change, deploy to one box at a time.
|
- **`deploy.sh all` can mask one host failing.** The `all` target runs both deploys in parallel (`deploy … & deploy … & wait`). The visible "Deployment complete." is printed regardless of whether one host's build failed mid-stream; the failure scrolls by in the interleaved output. After an `all` deploy, **confirm `/api/health` on both boxes** rather than trusting the final line. For a high-stakes change, deploy to one box at a time.
|
||||||
|
|
||||||
@@ -280,10 +318,12 @@ These are accepted constraints of the current deploy model, documented so operat
|
|||||||
|
|
||||||
| Task | Command |
|
| Task | Command |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| First-time install | `./install.sh` (or `DOMAIN=chat.example.com ./install.sh`) |
|
| First-time install | `./install.sh` (or `DOMAIN=chat.example.com DEPLOY_MODE=proxy ./install.sh`) |
|
||||||
|
| Update (prebuilt image — default) | `git pull && docker compose pull && docker compose up -d` |
|
||||||
|
| Update (from source / fork) | `git pull && docker compose up -d --build` |
|
||||||
| Redeploy both boxes | `./deploy.sh all` |
|
| Redeploy both boxes | `./deploy.sh all` |
|
||||||
| Redeploy one box | `./deploy.sh pi` / `./deploy.sh vm` |
|
| Redeploy one box | `./deploy.sh pi` / `./deploy.sh vm` |
|
||||||
| Bring stack up manually | `docker compose up -d --build` |
|
| Bring stack up manually | `docker compose up -d` (proxy/tunnel: `.env`'s `COMPOSE_FILE` auto-adds the overlay) |
|
||||||
| Check health | `curl -fsS https://<domain>/api/health` |
|
| Check health | `curl -fsS https://<domain>/api/health` |
|
||||||
| Take a manual snapshot | `./backup.sh` |
|
| Take a manual snapshot | `./backup.sh` |
|
||||||
| List snapshots | `./restore.sh` |
|
| List snapshots | `./restore.sh` |
|
||||||
|
|||||||
@@ -3,7 +3,16 @@
|
|||||||
> **Companion spec:** This document covers **S2S (server-to-server)** federation — the relay protocol, HMAC auth, identity resolution, and background workers. For the **client-side** multi-instance architecture (how the web/desktop app connects to multiple instances, federated account creation, origin-aware routing), see [`client-federation.md`](client-federation.md). Both systems work together.
|
> **Companion spec:** This document covers **S2S (server-to-server)** federation — the relay protocol, HMAC auth, identity resolution, and background workers. For the **client-side** multi-instance architecture (how the web/desktop app connects to multiple instances, federated account creation, origin-aware routing), see [`client-federation.md`](client-federation.md). Both systems work together.
|
||||||
|
|
||||||
Source files:
|
Source files:
|
||||||
- `packages/server/src/routes/federation.ts` -- API endpoints (peer handshake, relay, sync) + all inbound event processors + identity resolution functions
|
- `packages/server/src/routes/federation.ts` -- **Barrel** for the federation route subsystem. Re-exports the public API (identity resolution, event processors, reconciliation, `validateOrigin`) so `from '.../routes/federation.js'` imports resolve unchanged, and composes the HTTP registrars into `federationRoutes()`. The implementation lives in `routes/federation/` (split out of the former single 7.6k-line file; see `docs/superpowers/specs/2026-07-10-federation-ts-split-design.md`):
|
||||||
|
- `routes/federation/rateLimits.ts` -- In-memory sliding-window rate limiters (accept/relay/lookup/ensure) + replay-nonce store + eviction timers
|
||||||
|
- `routes/federation/origin.ts` -- `validateOrigin`, `resolveLocalOrigin`, `sanitizePeer` (+ `SanitizedPeer` shape)
|
||||||
|
- `routes/federation/identity.ts` -- Federated identity resolution: `extractDomain`, `getOurIdentityDomain`, `verifyAttribution`, `resolveLocalUser`, `findFederatedUser`, `resolveOrCreateReplicatedUser`, `backfillHomeUserId`
|
||||||
|
- `routes/federation/dmChannels.ts` -- DM channel/message payload builders, `findOrCreateDmChannel`, `resolveLocalDmMessage`, `isUrlFromPeer`
|
||||||
|
- `routes/federation/profile.ts` -- Replicated-profile hydration + asset download, `processProfileUpdateEvent`, `backfillReplicatedProfileAssets`
|
||||||
|
- `routes/federation/reconciliation.ts` -- DM federated-id reconciliation + dead-incarnation artifact sweeps (worker-facing maintenance)
|
||||||
|
- `routes/federation/events/*.ts` -- Inbound relay event processors, grouped by domain: `dmMessages`, `membership`, `friends`, `calls`, `dmState` (presence/read-state/close/reopen/file-rejected), and `dispatch` (`processRelayEvents`, the fan-out entry point shared by the HTTP relay handler and the initial-sync worker)
|
||||||
|
- `routes/federation/handlers/*.ts` -- Fastify route registrars, grouped by endpoint concern: `peerHandshake` (initiate/accept/ensure/rotate/denied), `peerAdmin` (peer list/CRUD/reset/recheck/rotate), `approvals` (approval queue + peering subscriptions/notifications + approve/deny helpers), `relay` (identity delete, relay, epoch, sync), `lookup` (user lookups), `attach` (verify-attach-proof, `/api/users/@me/reattach`)
|
||||||
|
- `routes/federation/handlers/s2sAuth.ts` -- `authenticateS2SPeer(request, reply, opts?)`: the shared inbound S2S-HMAC auth preamble (parse headers → resolve active peer → optional per-peer rate limit **before** signature → verify HMAC signature → nonce replay). Adopted by the six endpoints whose preamble is byte-identical: `DELETE /identity`, `POST /relay`, `POST /sync` (`relay.ts`), `POST /users/lookup`, `POST /users/by-home-id` (`lookup.ts`), and `POST /verify-attach-proof` (`attach.ts`). Returns `{ ok: true, peer, nonce }` or, having already sent the rejection reply, `{ ok: false }` (caller must `return`). **Intentional non-adopters** (each keeps a load-bearing gate the helper would flatten, documented in its own docstring/comment): `POST /epoch` (revoked-only gate for peer recovery, 400 on missing headers, no nonce check), `POST /peer/rotate` (active-only, no nonce check), `POST /peer/denied` (`awaiting_approval` gate, synthetic no-grace secret verify).
|
||||||
- `packages/server/src/utils/federationAuth.ts` -- HMAC signing, verification, header parsing, `getOurOrigin()`
|
- `packages/server/src/utils/federationAuth.ts` -- HMAC signing, verification, header parsing, `getOurOrigin()`
|
||||||
- `packages/server/src/utils/federationOutbox.ts` -- Event queuing, coalescing, relay payload construction, mutation log, participant/target resolution
|
- `packages/server/src/utils/federationOutbox.ts` -- Event queuing, coalescing, relay payload construction, mutation log, participant/target resolution
|
||||||
- `packages/server/src/utils/federationLookup.ts` -- HMAC-signed remote-user lookups: `lookupRemoteUser` (by username) and `lookupRemoteUserByHomeId` (reverse lookup, used by stub backfill)
|
- `packages/server/src/utils/federationLookup.ts` -- HMAC-signed remote-user lookups: `lookupRemoteUser` (by username) and `lookupRemoteUserByHomeId` (reverse lookup, used by stub backfill)
|
||||||
|
|||||||
@@ -45,6 +45,15 @@ Storage: Bigint decimal strings in SQLite TEXT columns (bigint not JSON-safe).
|
|||||||
### Step 1: Owner/Admin Check
|
### Step 1: Owner/Admin Check
|
||||||
- Space owner OR instance admin (`isAdmin === 1`) → return ALL_PERMISSIONS
|
- Space owner OR instance admin (`isAdmin === 1`) → return ALL_PERMISSIONS
|
||||||
|
|
||||||
|
### Step 1b: Membership Gate
|
||||||
|
- If the user is **not** a member of the space (`getMember` returns nothing) → return `0n`
|
||||||
|
- A non-member has no permissions in a space they have not joined. Without this,
|
||||||
|
the @everyone role in Step 2 would leak default member rights (VIEW_CHANNEL,
|
||||||
|
READ_MESSAGE_HISTORY, CREATE_INVITE, …) to any authenticated non-member —
|
||||||
|
allowing them to read channels and mint invite codes for spaces they never
|
||||||
|
joined. Owner and instance admin are already resolved in Step 1, so they are
|
||||||
|
unaffected.
|
||||||
|
|
||||||
### Step 2: Compute Base (space-level)
|
### Step 2: Compute Base (space-level)
|
||||||
- Start with @everyone role permissions (role where `id === spaceId`)
|
- Start with @everyone role permissions (role where `id === spaceId`)
|
||||||
- OR together all permissions from user's assigned roles
|
- OR together all permissions from user's assigned roles
|
||||||
@@ -89,7 +98,6 @@ if channelOverride: base = (base & ~deny) | allow
|
|||||||
| `permissionsToString(perms)` | Bigint → decimal string for JSON |
|
| `permissionsToString(perms)` | Bigint → decimal string for JSON |
|
||||||
| `stringToPermissions(str)` | Decimal string → bigint (supports legacy JSON array format) |
|
| `stringToPermissions(str)` | Decimal string → bigint (supports legacy JSON array format) |
|
||||||
| `computePermissions(userId, spaceId, channelId?)` | Full resolution algorithm |
|
| `computePermissions(userId, spaceId, channelId?)` | Full resolution algorithm |
|
||||||
| `computeCategoryPermissions(userId, spaceId, categoryId)` | Stops at category level (no channel overrides) |
|
|
||||||
| `hasPermission(userId, spaceId, permission, channelId?)` | Boolean wrapper |
|
| `hasPermission(userId, spaceId, permission, channelId?)` | Boolean wrapper |
|
||||||
| `getMember/isMember/isSpaceOwner` | Membership checks |
|
| `getMember/isMember/isSpaceOwner` | Membership checks |
|
||||||
| `isDmMember/isBanned` | DM/ban checks |
|
| `isDmMember/isBanned` | DM/ban checks |
|
||||||
|
|||||||
@@ -113,6 +113,8 @@ Cross-references: [database.md](database.md) (table schemas), [permissions.md](p
|
|||||||
|
|
||||||
**Behavior:** Returns existing `inviteCode` if one exists. Only generates a new one (`crypto.randomBytes(4).toString('hex')`) if the space has no invite code. Invite codes are permanent (no expiration).
|
**Behavior:** Returns existing `inviteCode` if one exists. Only generates a new one (`crypto.randomBytes(4).toString('hex')`) if the space has no invite code. Invite codes are permanent (no expiration).
|
||||||
|
|
||||||
|
**Visibility gate:** returns `403` for `request`-visibility spaces — they are approval-gated and have no usable invite link (the join endpoints reject invite-code joins for them), so the endpoint refuses to hand one out. The client (`InviteModal`) shows an "invite by join request" notice instead of the invite UI for such spaces, and `POST /api/dm/space-invite` likewise rejects a `request`-visibility **local** space with `403 space_requires_approval` (remote request spaces are enforced by their home instance at join time).
|
||||||
|
|
||||||
**Response:** `{ inviteCode: string }`
|
**Response:** `{ inviteCode: string }`
|
||||||
|
|
||||||
### Invite URL Format
|
### Invite URL Format
|
||||||
@@ -168,7 +170,9 @@ Two endpoints serve the same purpose:
|
|||||||
| `POST /api/spaces/:id/join` | Join when spaceId is known (body: `{ inviteCode }`) |
|
| `POST /api/spaces/:id/join` | Join when spaceId is known (body: `{ inviteCode }`) |
|
||||||
| `POST /api/spaces/join` | Join by code only, spaceId looked up from `inviteCode` |
|
| `POST /api/spaces/join` | Join by code only, spaceId looked up from `inviteCode` |
|
||||||
|
|
||||||
**Validations:** invite code match, not banned, not already a member.
|
**Validations:** invite code match, not banned, not already a member, and **space visibility is not `request`**.
|
||||||
|
|
||||||
|
**Visibility gate:** invite-code joins are rejected (`403`) for `request`-visibility spaces — entry to a request-only space must go through `POST /api/spaces/:id/request-join` + manager approval, never a bearer invite code. `private` spaces remain invite-joinable (an invite is their only entry path); `public` spaces are joinable by code or via `POST /api/spaces/:id/public-join`. Combined with the permission membership gate (a non-member cannot obtain `CREATE_INVITE`, see [permissions.md](permissions.md)), this closes the invite-bypass path where a non-member could mint a code for a request-only space and self-join without approval.
|
||||||
|
|
||||||
**Side effects:**
|
**Side effects:**
|
||||||
1. Insert `space_members` row
|
1. Insert `space_members` row
|
||||||
|
|||||||
@@ -2,13 +2,31 @@
|
|||||||
# ============================================================
|
# ============================================================
|
||||||
# Backspace — Production Installer
|
# Backspace — Production Installer
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Sets up Backspace with Caddy (auto-HTTPS) and optional
|
# Sets up Backspace on a Linux server in one of three deployment modes,
|
||||||
# LiveKit (voice/video) on a Linux server.
|
# auto-detecting which one fits your environment:
|
||||||
|
#
|
||||||
|
# allinone The bundled Caddy owns ports 80/443 and does automatic HTTPS
|
||||||
|
# for your domain. The simplest setup — pick this if 80/443 are free.
|
||||||
|
# proxy You already run a reverse proxy (nginx, Traefik, Caddy, Nginx Proxy
|
||||||
|
# Manager, SWAG…). Backspace is published on 127.0.0.1:APP_PORT and
|
||||||
|
# the installer prints ready-to-paste proxy config. No bundled Caddy.
|
||||||
|
# tunnel You expose the box through a tunnel (Cloudflare Tunnel, Tailscale…).
|
||||||
|
# Same as proxy, plus tunnel-specific guidance. (Voice does not work
|
||||||
|
# over a tunnel — WebRTC/UDP can't traverse it.)
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# ./install.sh Interactive setup
|
# ./install.sh Interactive setup (auto-detects the mode)
|
||||||
|
#
|
||||||
# Non-interactive — set any/all of these to skip the matching prompt:
|
# Non-interactive — set any/all of these to skip the matching prompt:
|
||||||
# DOMAIN=chat.example.com ENABLE_VOICE=true INSTANCE_NAME="My Chat" ./install.sh
|
# DOMAIN=chat.example.com \
|
||||||
|
# DEPLOY_MODE=allinone|proxy|tunnel \
|
||||||
|
# APP_PORT=8080 \ # proxy/tunnel only; auto-picked if unset
|
||||||
|
# ENABLE_VOICE=true \
|
||||||
|
# INSTANCE_NAME="My Chat" \
|
||||||
|
# ./install.sh
|
||||||
|
#
|
||||||
|
# BACKSPACE_BUILD=true ./install.sh Force a local from-source build instead of
|
||||||
|
# pulling the prebuilt image (fork operators).
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -97,56 +115,84 @@ if ! $COMPOSE version &>/dev/null 2>&1; then
|
|||||||
fi
|
fi
|
||||||
success "Docker Compose $($COMPOSE version --short 2>/dev/null || echo 'installed')"
|
success "Docker Compose $($COMPOSE version --short 2>/dev/null || echo 'installed')"
|
||||||
|
|
||||||
# Check if ports 80/443 are available
|
# ── Port helpers ────────────────────────────────────────────
|
||||||
check_port() {
|
# A host port can be held in two ways this installer must detect:
|
||||||
local port=$1
|
# 1. A normal listening socket (host process) → visible via `ss -tln`.
|
||||||
if ss -tlnp 2>/dev/null | grep -qE ":${port}\b"; then
|
# 2. A Docker-published port. With the userland proxy *disabled*
|
||||||
local proc
|
# (`userland-proxy: false`, common on tuned hosts), Docker DNATs the port
|
||||||
proc=$(ss -tlnp 2>/dev/null | grep -E ":${port}\b" | grep -oP 'users:\(\("\K[^"]+' | head -1 || echo "unknown")
|
# with iptables and there is NO listening socket for `ss` to see. So we must
|
||||||
error "Port $port is already in use by: $proc"
|
# also consult `docker ps` — otherwise a box whose Caddy already owns 80/443
|
||||||
error "Free port $port before running this script."
|
# via iptables would be misreported as "ports free".
|
||||||
return 1
|
|
||||||
fi
|
# Does any running container publish this host port? (matches "…:<port>->" in the
|
||||||
return 0
|
# Ports column; the ":" before the port and "->" after pin it to the HOST port,
|
||||||
|
# so :80 doesn't match :8080 and never matches the container-side port.)
|
||||||
|
docker_publishes_port() {
|
||||||
|
$DOCKER ps --format '{{.Names}} {{.Ports}}' 2>/dev/null | grep -qE ":${1}->"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Only check ports if we're NOT already running (upgrade scenario)
|
# Same, but ignore Backspace's own container (used when re-running on a host that
|
||||||
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q '^caddy$'; then
|
# already runs this instance — its published port must not count as a conflict).
|
||||||
port_ok=true
|
docker_publishes_port_other() {
|
||||||
check_port 80 || port_ok=false
|
$DOCKER ps --format '{{.Names}} {{.Ports}}' 2>/dev/null \
|
||||||
check_port 443 || port_ok=false
|
| grep -vE '^backspace ' | grep -qE ":${1}->"
|
||||||
if [[ "$port_ok" == false ]]; then
|
}
|
||||||
exit 1
|
|
||||||
|
# Is the port in use by anything (host socket OR any container)?
|
||||||
|
port_in_use() {
|
||||||
|
local port=$1
|
||||||
|
if ss -tln 2>/dev/null | grep -qE ":${port}[[:space:]]"; then
|
||||||
|
return 0
|
||||||
fi
|
fi
|
||||||
success "Ports 80 and 443 are available"
|
docker_publishes_port "$port"
|
||||||
else
|
}
|
||||||
success "Caddy is already running (upgrade mode)"
|
|
||||||
|
# In use by something OTHER than our own Backspace container?
|
||||||
|
port_in_use_by_other() {
|
||||||
|
local port=$1
|
||||||
|
if ss -tln 2>/dev/null | grep -qE ":${port}[[:space:]]"; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
docker_publishes_port_other "$port"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Best-effort human description of what holds a port (for helpful warnings).
|
||||||
|
port_holder() {
|
||||||
|
local port=$1 holder=""
|
||||||
|
holder=$($DOCKER ps --format '{{.Names}} {{.Ports}}' 2>/dev/null \
|
||||||
|
| grep -E ":${port}->" | awk '{print $1}' | head -1 || true)
|
||||||
|
if [[ -n "$holder" ]]; then
|
||||||
|
echo "docker container '$holder'"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
holder=$(ss -tlnp 2>/dev/null | grep -E ":${port}[[:space:]]" \
|
||||||
|
| grep -oP 'users:\(\("\K[^"]+' | head -1 || true)
|
||||||
|
echo "${holder:-another process}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── openssl (needed for secret generation) ──────────────────
|
||||||
|
if ! command -v openssl &>/dev/null; then
|
||||||
|
error "openssl is required for generating secrets."
|
||||||
|
error "Install: sudo apt-get install openssl"
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Disk space check
|
# Disk space check
|
||||||
available_kb=$(df -k . 2>/dev/null | tail -1 | awk '{print $4}' || true)
|
available_kb=$(df -k . 2>/dev/null | tail -1 | awk '{print $4}' || true)
|
||||||
if [[ -n "$available_kb" ]] && (( available_kb < 3000000 )); then
|
if [[ -n "$available_kb" ]] && (( available_kb < 3000000 )); then
|
||||||
warn "Low disk space: $((available_kb / 1024))MB available (recommend 3GB+)"
|
warn "Low disk space: $((available_kb / 1024))MB available (recommend 3GB+)"
|
||||||
|
warn "The prebuilt image (~1.6GB pulled) needs less than a from-source build."
|
||||||
else
|
else
|
||||||
success "Disk space OK"
|
success "Disk space OK"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Check for openssl (needed for secret generation)
|
|
||||||
if ! command -v openssl &>/dev/null; then
|
|
||||||
error "openssl is required for generating secrets."
|
|
||||||
error "Install: sudo apt-get install openssl"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Phase 2: Configuration ─────────────────────────────────
|
# ── Phase 2: Configuration ─────────────────────────────────
|
||||||
|
|
||||||
step "Configuration"
|
step "Configuration"
|
||||||
|
|
||||||
# Detect existing installation
|
# Detect existing installation
|
||||||
EXISTING_ENV=false
|
|
||||||
if [[ -f .env ]]; then
|
if [[ -f .env ]]; then
|
||||||
EXISTING_ENV=true
|
info "Existing .env detected — secrets and settings will be preserved."
|
||||||
info "Existing .env detected — secrets will be preserved."
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Helper: read existing .env value (|| true prevents set -e from killing
|
# Helper: read existing .env value (|| true prevents set -e from killing
|
||||||
@@ -157,17 +203,131 @@ env_val() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ── Deployment mode ─────────────────────────────────────────
|
||||||
|
# Precedence: explicit DEPLOY_MODE env → existing .env → auto-detect + prompt.
|
||||||
|
# Auto-detect never dead-ends: if 80/443 are taken, All-in-One is simply off the
|
||||||
|
# menu and we steer the operator to proxy/tunnel instead.
|
||||||
|
|
||||||
|
existing_mode=$(env_val DEPLOY_MODE)
|
||||||
|
CADDY_RUNNING=false
|
||||||
|
if $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q '^caddy$'; then
|
||||||
|
CADDY_RUNNING=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
port80_free=true; port443_free=true
|
||||||
|
port_in_use 80 && port80_free=false
|
||||||
|
port_in_use 443 && port443_free=false
|
||||||
|
|
||||||
|
choose_mode_interactively() {
|
||||||
|
# Writes the chosen mode to the global DEPLOY_MODE.
|
||||||
|
local choice=""
|
||||||
|
if [[ "$port80_free" == true && "$port443_free" == true ]]; then
|
||||||
|
echo " Ports 80 and 443 are free — All-in-One (bundled Caddy + auto-HTTPS) is available."
|
||||||
|
echo ""
|
||||||
|
echo " How do you want to expose Backspace?"
|
||||||
|
echo -e " ${BOLD}1)${NC} All-in-One — bundled Caddy handles HTTPS for you ${GREEN}(recommended)${NC}"
|
||||||
|
echo -e " ${BOLD}2)${NC} Behind my own reverse proxy (nginx, Traefik, Caddy, Nginx Proxy Manager, SWAG…)"
|
||||||
|
echo -e " ${BOLD}3)${NC} Behind a tunnel (Cloudflare Tunnel, Tailscale…)"
|
||||||
|
echo ""
|
||||||
|
read -rp " Choice [1]: " choice || choice=""
|
||||||
|
case "${choice:-1}" in
|
||||||
|
1) DEPLOY_MODE=allinone ;;
|
||||||
|
2) DEPLOY_MODE=proxy ;;
|
||||||
|
3) DEPLOY_MODE=tunnel ;;
|
||||||
|
*) DEPLOY_MODE=allinone ;;
|
||||||
|
esac
|
||||||
|
else
|
||||||
|
warn "Ports 80/443 are already in use on this host:"
|
||||||
|
[[ "$port80_free" == false ]] && echo " 80 → held by $(port_holder 80)"
|
||||||
|
[[ "$port443_free" == false ]] && echo " 443 → held by $(port_holder 443)"
|
||||||
|
echo ""
|
||||||
|
info "All-in-One needs 80 and 443 free, so it's unavailable here."
|
||||||
|
info "That's fine — run Backspace behind what already owns those ports:"
|
||||||
|
echo ""
|
||||||
|
echo -e " ${BOLD}1)${NC} Behind my own reverse proxy (nginx, Traefik, Caddy, Nginx Proxy Manager, SWAG…) ${GREEN}(recommended)${NC}"
|
||||||
|
echo -e " ${BOLD}2)${NC} Behind a tunnel (Cloudflare Tunnel, Tailscale…)"
|
||||||
|
echo -e " ${BOLD}3)${NC} Nothing yet — I'll free 80/443 and use All-in-One (exit so I can do that)"
|
||||||
|
echo ""
|
||||||
|
read -rp " Choice [1]: " choice || choice=""
|
||||||
|
case "${choice:-1}" in
|
||||||
|
1) DEPLOY_MODE=proxy ;;
|
||||||
|
2) DEPLOY_MODE=tunnel ;;
|
||||||
|
3)
|
||||||
|
error "Free ports 80 and 443, then re-run ./install.sh for All-in-One mode."
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
*) DEPLOY_MODE=proxy ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ -n "${DEPLOY_MODE:-}" ]]; then
|
||||||
|
info "Deployment mode: ${DEPLOY_MODE} (from environment)"
|
||||||
|
elif [[ -n "$existing_mode" ]]; then
|
||||||
|
DEPLOY_MODE="$existing_mode"
|
||||||
|
info "Deployment mode: ${DEPLOY_MODE} (from existing .env)"
|
||||||
|
info "To switch modes, re-run with DEPLOY_MODE=allinone|proxy|tunnel."
|
||||||
|
elif [[ -t 0 ]]; then
|
||||||
|
echo ""
|
||||||
|
choose_mode_interactively
|
||||||
|
else
|
||||||
|
# Non-interactive with no DEPLOY_MODE: pick a safe, never-dead-end default.
|
||||||
|
if [[ "$port80_free" == true && "$port443_free" == true ]]; then
|
||||||
|
DEPLOY_MODE=allinone
|
||||||
|
else
|
||||||
|
DEPLOY_MODE=proxy
|
||||||
|
warn "Ports 80/443 are in use and no DEPLOY_MODE was given — defaulting to 'proxy'."
|
||||||
|
fi
|
||||||
|
info "Deployment mode: ${DEPLOY_MODE} (auto-detected)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$DEPLOY_MODE" in
|
||||||
|
allinone|proxy|tunnel) ;;
|
||||||
|
*)
|
||||||
|
error "Invalid DEPLOY_MODE='${DEPLOY_MODE}'. Use allinone, proxy, or tunnel."
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# All-in-One requires 80/443 — but a re-run over an existing All-in-One instance
|
||||||
|
# legitimately finds *its own* Caddy holding them (an upgrade), which is fine.
|
||||||
|
if [[ "$DEPLOY_MODE" == "allinone" ]]; then
|
||||||
|
if [[ "$CADDY_RUNNING" == true ]]; then
|
||||||
|
success "Caddy is already running (All-in-One upgrade)"
|
||||||
|
elif [[ "$port80_free" == false || "$port443_free" == false ]]; then
|
||||||
|
error "All-in-One mode needs ports 80 and 443 free, but:"
|
||||||
|
[[ "$port80_free" == false ]] && error " 80 is held by $(port_holder 80)"
|
||||||
|
[[ "$port443_free" == false ]] && error " 443 is held by $(port_holder 443)"
|
||||||
|
error "Free them, or re-run with DEPLOY_MODE=proxy (or =tunnel) to run behind them."
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
success "Ports 80 and 443 are available"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# In proxy/tunnel mode, layer the proxy override so Caddy is dropped and the app
|
||||||
|
# is published on a loopback port. Compose reads COMPOSE_FILE (from the shell and
|
||||||
|
# from .env) to pick up both files for every command — the operator never needs
|
||||||
|
# to remember `-f` flags afterwards.
|
||||||
|
if [[ "$DEPLOY_MODE" == "proxy" || "$DEPLOY_MODE" == "tunnel" ]]; then
|
||||||
|
export COMPOSE_FILE="docker-compose.yml:docker-compose.proxy.yml"
|
||||||
|
else
|
||||||
|
unset COMPOSE_FILE 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
# ── Domain ──────────────────────────────────────────────────
|
# ── Domain ──────────────────────────────────────────────────
|
||||||
|
# Required in every mode: it's the public hostname clients use and it drives the
|
||||||
|
# federation identity + LiveKit URL. In proxy/tunnel mode TLS is terminated at
|
||||||
|
# your edge, but the app still advertises https://DOMAIN.
|
||||||
|
|
||||||
existing_domain=$(env_val DOMAIN)
|
existing_domain=$(env_val DOMAIN)
|
||||||
if [[ -n "${DOMAIN:-}" ]]; then
|
if [[ -n "${DOMAIN:-}" ]]; then
|
||||||
# Non-interactive: DOMAIN set via environment
|
|
||||||
:
|
:
|
||||||
elif [[ -n "$existing_domain" ]]; then
|
elif [[ -n "$existing_domain" ]]; then
|
||||||
read -rp "Domain [$existing_domain]: " DOMAIN
|
read -rp "Domain [$existing_domain]: " DOMAIN || DOMAIN=""
|
||||||
DOMAIN="${DOMAIN:-$existing_domain}"
|
DOMAIN="${DOMAIN:-$existing_domain}"
|
||||||
else
|
else
|
||||||
read -rp "Domain (e.g., chat.example.com): " DOMAIN
|
read -rp "Domain (e.g., chat.example.com): " DOMAIN || DOMAIN=""
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -z "${DOMAIN:-}" ]]; then
|
if [[ -z "${DOMAIN:-}" ]]; then
|
||||||
@@ -175,22 +335,20 @@ if [[ -z "${DOMAIN:-}" ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# DNS verification
|
# DNS verification is meaningful for All-in-One (Caddy must reach this host to
|
||||||
info "Verifying DNS for ${DOMAIN}..."
|
# issue a certificate). In proxy/tunnel mode the DNS record points at your proxy
|
||||||
|
# or tunnel edge — often NOT this host's IP (that's the whole point) — so we only
|
||||||
|
# note what it resolves to, without warning about a mismatch.
|
||||||
|
info "Checking DNS for ${DOMAIN}..."
|
||||||
resolved_ip=""
|
resolved_ip=""
|
||||||
if command -v dig &>/dev/null; then
|
if command -v dig &>/dev/null; then
|
||||||
resolved_ip=$(dig +short "$DOMAIN" A 2>/dev/null | tail -1 || true)
|
resolved_ip=$(dig +short "$DOMAIN" A 2>/dev/null | tail -1 || true)
|
||||||
elif command -v getent &>/dev/null; then
|
elif command -v getent &>/dev/null; then
|
||||||
# A non-resolving domain makes `getent hosts` exit 2, and with `set -o
|
|
||||||
# pipefail` that would abort the installer here — before the graceful
|
|
||||||
# "Could not resolve" warning below. Swallow it: an empty result is the
|
|
||||||
# intended "not resolved yet" signal (installing before DNS is set up is
|
|
||||||
# explicitly supported).
|
|
||||||
resolved_ip=$(getent hosts "$DOMAIN" 2>/dev/null | awk '{print $1}' | head -1 || true)
|
resolved_ip=$(getent hosts "$DOMAIN" 2>/dev/null | awk '{print $1}' | head -1 || true)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ "$DEPLOY_MODE" == "allinone" ]]; then
|
||||||
my_ip=$(curl -s4 --connect-timeout 5 ifconfig.me 2>/dev/null || curl -s4 --connect-timeout 5 icanhazip.com 2>/dev/null || echo "")
|
my_ip=$(curl -s4 --connect-timeout 5 ifconfig.me 2>/dev/null || curl -s4 --connect-timeout 5 icanhazip.com 2>/dev/null || echo "")
|
||||||
|
|
||||||
if [[ -z "$resolved_ip" ]]; then
|
if [[ -z "$resolved_ip" ]]; then
|
||||||
warn "Could not resolve ${DOMAIN}. Ensure DNS is configured before Caddy can issue certificates."
|
warn "Could not resolve ${DOMAIN}. Ensure DNS is configured before Caddy can issue certificates."
|
||||||
elif [[ -n "$my_ip" && "$resolved_ip" != "$my_ip" ]]; then
|
elif [[ -n "$my_ip" && "$resolved_ip" != "$my_ip" ]]; then
|
||||||
@@ -199,18 +357,71 @@ elif [[ -n "$my_ip" && "$resolved_ip" != "$my_ip" ]]; then
|
|||||||
else
|
else
|
||||||
success "${DOMAIN} resolves to ${resolved_ip:-verified}"
|
success "${DOMAIN} resolves to ${resolved_ip:-verified}"
|
||||||
fi
|
fi
|
||||||
|
else
|
||||||
|
if [[ -n "$resolved_ip" ]]; then
|
||||||
|
info "${DOMAIN} currently resolves to ${resolved_ip} (should point at your proxy/tunnel edge)."
|
||||||
|
else
|
||||||
|
info "${DOMAIN} does not resolve yet — point it at your proxy/tunnel edge when ready."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── App port (proxy / tunnel only) ──────────────────────────
|
||||||
|
# The loopback host port your proxy/tunnel forwards to. Auto-picked to avoid
|
||||||
|
# common clashes (3000/8080 are frequently taken) unless APP_PORT is set.
|
||||||
|
|
||||||
|
pick_free_port() {
|
||||||
|
if [[ -n "${APP_PORT:-}" ]]; then
|
||||||
|
if port_in_use_by_other "$APP_PORT"; then
|
||||||
|
error "Requested APP_PORT=$APP_PORT is already in use by $(port_holder "$APP_PORT")."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "$APP_PORT"; return
|
||||||
|
fi
|
||||||
|
local existing; existing=$(env_val APP_PORT)
|
||||||
|
if [[ -n "$existing" ]]; then
|
||||||
|
# Re-run: keep the existing port so the proxy/tunnel config stays valid, even
|
||||||
|
# though our own container is currently publishing it.
|
||||||
|
echo "$existing"; return
|
||||||
|
fi
|
||||||
|
local p
|
||||||
|
for p in 8080 8081 8082 8090 8095 3001 3002 18080 28080; do
|
||||||
|
if ! port_in_use "$p"; then echo "$p"; return; fi
|
||||||
|
done
|
||||||
|
for p in $(seq 8100 8200); do
|
||||||
|
if ! port_in_use "$p"; then echo "$p"; return; fi
|
||||||
|
done
|
||||||
|
error "Could not find a free host port for the app. Set APP_PORT to a free port and re-run."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
APP_PORT_FINAL=""
|
||||||
|
if [[ "$DEPLOY_MODE" == "proxy" || "$DEPLOY_MODE" == "tunnel" ]]; then
|
||||||
|
APP_PORT_FINAL="$(pick_free_port)"
|
||||||
|
success "App will be published on 127.0.0.1:${APP_PORT_FINAL}"
|
||||||
|
fi
|
||||||
|
|
||||||
# ── Voice/Video ─────────────────────────────────────────────
|
# ── Voice/Video ─────────────────────────────────────────────
|
||||||
|
# Voice needs open UDP media ports and (in proxy mode) a /livekit route. Over a
|
||||||
|
# tunnel, WebRTC/UDP cannot traverse the edge at all — so voice is force-disabled
|
||||||
|
# in tunnel mode rather than silently configured and then failing at call time.
|
||||||
|
|
||||||
existing_profiles=$(env_val COMPOSE_PROFILES)
|
existing_profiles=$(env_val COMPOSE_PROFILES)
|
||||||
if [[ -n "${ENABLE_VOICE:-}" ]]; then
|
if [[ "$DEPLOY_MODE" == "tunnel" ]]; then
|
||||||
# Non-interactive
|
if [[ "${ENABLE_VOICE:-}" == "true" ]]; then
|
||||||
|
warn "Voice cannot work over a tunnel (WebRTC/UDP can't traverse it) — disabling it."
|
||||||
|
fi
|
||||||
|
ENABLE_VOICE=false
|
||||||
|
info "Voice/video is disabled in tunnel mode (a known, unavoidable limitation)."
|
||||||
|
elif [[ -n "${ENABLE_VOICE:-}" ]]; then
|
||||||
:
|
:
|
||||||
elif [[ "$existing_profiles" == *"voice"* ]]; then
|
elif [[ "$existing_profiles" == *"voice"* ]]; then
|
||||||
read -rp "Voice/video is currently enabled. Keep it? [Y/n] " yn
|
read -rp "Voice/video is currently enabled. Keep it? [Y/n] " yn || yn=""
|
||||||
ENABLE_VOICE=$([[ "${yn,,}" == "n" ]] && echo false || echo true)
|
ENABLE_VOICE=$([[ "${yn,,}" == "n" ]] && echo false || echo true)
|
||||||
else
|
else
|
||||||
read -rp "Enable voice/video? (requires open UDP ports) [Y/n] " yn
|
if [[ "$DEPLOY_MODE" == "proxy" ]]; then
|
||||||
|
info "Voice needs open UDP media ports AND a /livekit route in your proxy (snippet printed at the end)."
|
||||||
|
fi
|
||||||
|
read -rp "Enable voice/video? (requires open UDP ports) [Y/n] " yn || yn=""
|
||||||
ENABLE_VOICE=$([[ "${yn,,}" == "n" ]] && echo false || echo true)
|
ENABLE_VOICE=$([[ "${yn,,}" == "n" ]] && echo false || echo true)
|
||||||
fi
|
fi
|
||||||
ENABLE_VOICE="${ENABLE_VOICE:-true}"
|
ENABLE_VOICE="${ENABLE_VOICE:-true}"
|
||||||
@@ -219,10 +430,26 @@ ENABLE_VOICE="${ENABLE_VOICE:-true}"
|
|||||||
|
|
||||||
existing_name=$(env_val INSTANCE_NAME)
|
existing_name=$(env_val INSTANCE_NAME)
|
||||||
if [[ -z "${INSTANCE_NAME:-}" ]]; then
|
if [[ -z "${INSTANCE_NAME:-}" ]]; then
|
||||||
read -rp "Instance name [${existing_name:-Backspace}]: " INSTANCE_NAME
|
read -rp "Instance name [${existing_name:-Backspace}]: " INSTANCE_NAME || INSTANCE_NAME=""
|
||||||
INSTANCE_NAME="${INSTANCE_NAME:-${existing_name:-Backspace}}"
|
INSTANCE_NAME="${INSTANCE_NAME:-${existing_name:-Backspace}}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ── Max upload size ─────────────────────────────────────────
|
||||||
|
# Cloudflare (free/pro) hard-caps request bodies at 100MB, so a 100MB app limit
|
||||||
|
# lets uploads fail at the edge instead of in-app. In tunnel mode we default the
|
||||||
|
# cap below that (90MB) with headroom for multipart overhead. An explicit
|
||||||
|
# MAX_UPLOAD_SIZE (env or existing .env) always wins.
|
||||||
|
existing_max=$(env_val MAX_UPLOAD_SIZE)
|
||||||
|
if [[ -n "${MAX_UPLOAD_SIZE:-}" ]]; then
|
||||||
|
:
|
||||||
|
elif [[ -n "$existing_max" ]]; then
|
||||||
|
MAX_UPLOAD_SIZE="$existing_max"
|
||||||
|
elif [[ "$DEPLOY_MODE" == "tunnel" ]]; then
|
||||||
|
MAX_UPLOAD_SIZE=94371840 # 90 MB — under Cloudflare's 100MB body cap
|
||||||
|
else
|
||||||
|
MAX_UPLOAD_SIZE=104857600 # 100 MB
|
||||||
|
fi
|
||||||
|
|
||||||
# ── Phase 3: Generate Secrets ───────────────────────────────
|
# ── Phase 3: Generate Secrets ───────────────────────────────
|
||||||
|
|
||||||
step "Generating configuration"
|
step "Generating configuration"
|
||||||
@@ -259,12 +486,31 @@ step "Writing configuration files"
|
|||||||
|
|
||||||
# ── .env ────────────────────────────────────────────────────
|
# ── .env ────────────────────────────────────────────────────
|
||||||
|
|
||||||
cat > .env << EOF
|
{
|
||||||
|
cat << EOF
|
||||||
# Backspace Configuration
|
# Backspace Configuration
|
||||||
# Generated by install.sh on $(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
# Generated by install.sh on $(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
|
||||||
DOMAIN=${DOMAIN}
|
DOMAIN=${DOMAIN}
|
||||||
|
|
||||||
|
# Deployment mode: allinone | proxy | tunnel (see ./install.sh --help / README)
|
||||||
|
DEPLOY_MODE=${DEPLOY_MODE}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
if [[ "$DEPLOY_MODE" == "proxy" || "$DEPLOY_MODE" == "tunnel" ]]; then
|
||||||
|
cat << EOF
|
||||||
|
|
||||||
|
# Reverse-proxy / tunnel mode: layer the proxy override so the bundled Caddy is
|
||||||
|
# dropped and the app is published on 127.0.0.1:APP_PORT for your edge to reach.
|
||||||
|
# COMPOSE_FILE makes every 'docker compose' command in this directory use both
|
||||||
|
# files automatically — no -f flags needed.
|
||||||
|
COMPOSE_FILE=docker-compose.yml:docker-compose.proxy.yml
|
||||||
|
APP_PORT=${APP_PORT_FINAL}
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat << EOF
|
||||||
|
|
||||||
# Server
|
# Server
|
||||||
PORT=3000
|
PORT=3000
|
||||||
HOST=0.0.0.0
|
HOST=0.0.0.0
|
||||||
@@ -275,12 +521,12 @@ JWT_SECRET=${JWT_SECRET}
|
|||||||
# Registration
|
# Registration
|
||||||
REGISTRATION_OPEN=true
|
REGISTRATION_OPEN=true
|
||||||
|
|
||||||
# Max upload size in bytes (100MB)
|
# Max upload size in bytes
|
||||||
MAX_UPLOAD_SIZE=104857600
|
MAX_UPLOAD_SIZE=${MAX_UPLOAD_SIZE}
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
if [[ "$ENABLE_VOICE" == true ]]; then
|
if [[ "$ENABLE_VOICE" == true ]]; then
|
||||||
cat >> .env << EOF
|
cat << EOF
|
||||||
|
|
||||||
# LiveKit Voice/Video
|
# LiveKit Voice/Video
|
||||||
LIVEKIT_URL=wss://${DOMAIN}/livekit
|
LIVEKIT_URL=wss://${DOMAIN}/livekit
|
||||||
@@ -291,7 +537,7 @@ LIVEKIT_API_SECRET=${LIVEKIT_API_SECRET}
|
|||||||
COMPOSE_PROFILES=voice
|
COMPOSE_PROFILES=voice
|
||||||
EOF
|
EOF
|
||||||
else
|
else
|
||||||
cat >> .env << EOF
|
cat << EOF
|
||||||
|
|
||||||
# LiveKit Voice/Video (disabled)
|
# LiveKit Voice/Video (disabled)
|
||||||
# To enable: fill in credentials and add COMPOSE_PROFILES=voice
|
# To enable: fill in credentials and add COMPOSE_PROFILES=voice
|
||||||
@@ -300,6 +546,7 @@ else
|
|||||||
# LIVEKIT_API_SECRET=
|
# LIVEKIT_API_SECRET=
|
||||||
EOF
|
EOF
|
||||||
fi
|
fi
|
||||||
|
} > .env
|
||||||
|
|
||||||
success ".env"
|
success ".env"
|
||||||
|
|
||||||
@@ -382,14 +629,43 @@ step "Deploying Backspace"
|
|||||||
# so GET /api/instance/info advertises the exact source version. Passed straight
|
# so GET /api/instance/info advertises the exact source version. Passed straight
|
||||||
# to the build as --build-arg (survives the sudo/non-sudo $COMPOSE split, unlike
|
# to the build as --build-arg (survives the sudo/non-sudo $COMPOSE split, unlike
|
||||||
# an exported env var). Empty when this isn't a git checkout (e.g. tarball
|
# an exported env var). Empty when this isn't a git checkout (e.g. tarball
|
||||||
# install) or git is unavailable → the server treats the commit as null.
|
# install) or git is unavailable → the server treats the commit as null. A pulled
|
||||||
|
# prebuilt image already carries the commit baked at CI build time.
|
||||||
BUILD_COMMIT="$(git rev-parse --short HEAD 2>/dev/null || echo '')"
|
BUILD_COMMIT="$(git rev-parse --short HEAD 2>/dev/null || echo '')"
|
||||||
|
|
||||||
|
build_from_source() {
|
||||||
if [[ -n "$BUILD_COMMIT" ]]; then
|
if [[ -n "$BUILD_COMMIT" ]]; then
|
||||||
info "Building Backspace image from commit ${BUILD_COMMIT} (this may take a few minutes on first run)..."
|
info "Building Backspace image from commit ${BUILD_COMMIT} (first build takes a few minutes)..."
|
||||||
else
|
else
|
||||||
info "Building Backspace image (this may take a few minutes on first run)..."
|
info "Building Backspace image (first build takes a few minutes)..."
|
||||||
|
fi
|
||||||
|
$COMPOSE build --build-arg BACKSPACE_COMMIT="$BUILD_COMMIT"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Default path: pull the prebuilt multi-arch image (fast, and it spares weak/ARM
|
||||||
|
# hosts the ~1.6GB local build that OOMs small boxes). Fall back to a from-source
|
||||||
|
# build if the image can't be pulled (not published yet, private, or offline), or
|
||||||
|
# if the operator forces a build (BACKSPACE_BUILD=true — e.g. running a fork).
|
||||||
|
if [[ "${BACKSPACE_BUILD:-false}" == "true" ]]; then
|
||||||
|
info "BACKSPACE_BUILD=true — building from source (skipping the prebuilt image)."
|
||||||
|
build_from_source
|
||||||
|
else
|
||||||
|
image_ref="${BACKSPACE_IMAGE:-ghcr.io/thezwiss/backspace}:${BACKSPACE_IMAGE_TAG:-latest}"
|
||||||
|
info "Fetching prebuilt image ${image_ref} ..."
|
||||||
|
if $COMPOSE pull backspace; then
|
||||||
|
success "Pulled prebuilt image"
|
||||||
|
elif $DOCKER image inspect "$image_ref" >/dev/null 2>&1; then
|
||||||
|
# Pull failed (offline / registry hiccup / private) but a usable copy is
|
||||||
|
# already on this host (a prior run, an air-gapped `docker load`, or a
|
||||||
|
# previous from-source build tagged under this ref) — use it instead of
|
||||||
|
# forcing a needless multi-hundred-MB rebuild.
|
||||||
|
warn "Could not pull ${image_ref} — using the copy already present on this host."
|
||||||
|
else
|
||||||
|
warn "Prebuilt image unavailable (not published yet, private, or offline)."
|
||||||
|
warn "Falling back to a from-source build — slower, and heavy on low-RAM/ARM hosts."
|
||||||
|
build_from_source
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
$COMPOSE build --quiet --build-arg BACKSPACE_COMMIT="$BUILD_COMMIT"
|
|
||||||
|
|
||||||
info "Starting services..."
|
info "Starting services..."
|
||||||
$COMPOSE up -d
|
$COMPOSE up -d
|
||||||
@@ -427,26 +703,24 @@ if [[ "$healthy" == true && -n "$INSTANCE_NAME" && "$INSTANCE_NAME" != "Backspac
|
|||||||
' 2>/dev/null && success "Instance name set to: ${INSTANCE_NAME}" || warn "Could not set instance name (set it manually in admin settings)"
|
' 2>/dev/null && success "Instance name set to: ${INSTANCE_NAME}" || warn "Could not set instance name (set it manually in admin settings)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Phase 7.5: Verify HTTPS reachability ───────────────────
|
# ── Phase 7.5: Post-deploy reachability check ──────────────
|
||||||
# The internal healthcheck only proves the app is up *inside* Docker. What the
|
# What's verifiable differs by mode:
|
||||||
# operator actually cares about is whether https://DOMAIN works — which needs
|
# allinone → prove https://DOMAIN works end-to-end (Caddy has a valid,
|
||||||
# Caddy to have obtained a publicly-trusted TLS certificate, and that only
|
# publicly-trusted certificate AND the app answers over it).
|
||||||
# happens once DNS points here AND ports 80/443 are reachable from the internet.
|
# proxy/tunnel → prove the app answers on its loopback port (your edge then
|
||||||
#
|
# fronts it); TLS is your proxy/tunnel's job, not ours to test.
|
||||||
# We test this hairpin-safely with `curl --resolve DOMAIN:443:127.0.0.1`: it
|
|
||||||
# connects to the LOCAL Caddy but presents the real SNI/Host and performs full
|
|
||||||
# certificate verification. Success means Caddy is serving a valid, publicly-
|
|
||||||
# trusted certificate for DOMAIN *and* the app answers over it — which is only
|
|
||||||
# possible once issuance has succeeded. Crucially this avoids a false negative
|
|
||||||
# on self-hosted boxes that can't reach their own public address (router NAT
|
|
||||||
# hairpin), where a plain external request would time out even though the site
|
|
||||||
# is perfectly reachable for everyone else.
|
|
||||||
|
|
||||||
https_status="skipped"
|
https_status="skipped"
|
||||||
|
app_reachable="unknown"
|
||||||
|
|
||||||
|
if [[ "$DEPLOY_MODE" == "allinone" ]]; then
|
||||||
if [[ "$healthy" == true ]]; then
|
if [[ "$healthy" == true ]]; then
|
||||||
step "Verifying HTTPS"
|
step "Verifying HTTPS"
|
||||||
https_status="pending"
|
https_status="pending"
|
||||||
|
# `curl --resolve DOMAIN:443:127.0.0.1` connects to the LOCAL Caddy but
|
||||||
|
# presents the real SNI/Host and does full certificate verification. A pass
|
||||||
|
# proves a valid public cert is installed AND the app answers over it — and
|
||||||
|
# it's hairpin-safe (many self-hosted boxes can't reach their own public IP).
|
||||||
info "Checking for a valid TLS certificate on ${DOMAIN} (Caddy issues it on first start)..."
|
info "Checking for a valid TLS certificate on ${DOMAIN} (Caddy issues it on first start)..."
|
||||||
for i in $(seq 1 15); do
|
for i in $(seq 1 15); do
|
||||||
if curl -fsS --max-time 6 --resolve "${DOMAIN}:443:127.0.0.1" "https://${DOMAIN}/api/health" >/dev/null 2>&1; then
|
if curl -fsS --max-time 6 --resolve "${DOMAIN}:443:127.0.0.1" "https://${DOMAIN}/api/health" >/dev/null 2>&1; then
|
||||||
@@ -455,13 +729,192 @@ if [[ "$healthy" == true ]]; then
|
|||||||
fi
|
fi
|
||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
|
|
||||||
if [[ "$https_status" == "live" ]]; then
|
if [[ "$https_status" == "live" ]]; then
|
||||||
success "HTTPS is live — a valid TLS certificate is installed and Backspace is serving over it."
|
success "HTTPS is live — a valid TLS certificate is installed and Backspace is serving over it."
|
||||||
else
|
else
|
||||||
warn "HTTPS is not live yet — Caddy hasn't obtained a publicly-trusted certificate."
|
warn "HTTPS is not live yet — Caddy hasn't obtained a publicly-trusted certificate."
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
else
|
||||||
|
if [[ "$healthy" == true ]]; then
|
||||||
|
step "Verifying the app"
|
||||||
|
app_reachable="no"
|
||||||
|
for i in $(seq 1 10); do
|
||||||
|
if curl -fsS --max-time 6 "http://127.0.0.1:${APP_PORT_FINAL}/api/health" >/dev/null 2>&1; then
|
||||||
|
app_reachable="yes"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
if [[ "$app_reachable" == "yes" ]]; then
|
||||||
|
success "Backspace is answering on http://127.0.0.1:${APP_PORT_FINAL} — point your $( [[ "$DEPLOY_MODE" == tunnel ]] && echo tunnel || echo 'reverse proxy') at it."
|
||||||
|
else
|
||||||
|
warn "Could not reach http://127.0.0.1:${APP_PORT_FINAL}/api/health yet — check: docker compose logs backspace"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Reverse-proxy / tunnel snippet generators ──────────────
|
||||||
|
# Paste-ready configs with WebSocket upgrade, X-Forwarded-*, and a body-size cap
|
||||||
|
# already correct. Printed for proxy/tunnel modes so the operator's edge routes
|
||||||
|
# to 127.0.0.1:APP_PORT (and, if voice is on, /livekit → the host LiveKit).
|
||||||
|
|
||||||
|
hr() { echo -e "${CYAN}────────────────────────────────────────────────────────────${NC}"; }
|
||||||
|
snip() { echo -e "${BOLD}$*${NC}"; }
|
||||||
|
|
||||||
|
# Body-size cap in the proxy should match the app's MAX_UPLOAD_SIZE. Express it in
|
||||||
|
# MB for the human-facing proxy directives (round up so the proxy never rejects a
|
||||||
|
# body the app would accept).
|
||||||
|
max_mb=$(( (MAX_UPLOAD_SIZE + 1048575) / 1048576 ))
|
||||||
|
|
||||||
|
print_nginx_snippet() {
|
||||||
|
snip "nginx — add the map once inside http { }, then a server block:"
|
||||||
|
hr
|
||||||
|
cat << EOF
|
||||||
|
# --- inside http { } (once) --------------------------------
|
||||||
|
map \$http_upgrade \$connection_upgrade {
|
||||||
|
default upgrade;
|
||||||
|
'' close;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name ${DOMAIN};
|
||||||
|
|
||||||
|
# Your TLS certs (certbot, your proxy manager, etc.):
|
||||||
|
# ssl_certificate /etc/letsencrypt/live/${DOMAIN}/fullchain.pem;
|
||||||
|
# ssl_certificate_key /etc/letsencrypt/live/${DOMAIN}/privkey.pem;
|
||||||
|
|
||||||
|
client_max_body_size ${max_mb}m; # match MAX_UPLOAD_SIZE (${MAX_UPLOAD_SIZE} bytes)
|
||||||
|
EOF
|
||||||
|
if [[ "$ENABLE_VOICE" == true ]]; then
|
||||||
|
cat << EOF
|
||||||
|
|
||||||
|
# Voice signaling → host-networked LiveKit (strips the /livekit prefix):
|
||||||
|
location /livekit/ {
|
||||||
|
proxy_pass http://127.0.0.1:7880/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host \$host;
|
||||||
|
proxy_set_header Upgrade \$http_upgrade;
|
||||||
|
proxy_set_header Connection \$connection_upgrade;
|
||||||
|
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
cat << EOF
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:${APP_PORT_FINAL};
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host \$host;
|
||||||
|
proxy_set_header X-Real-IP \$remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||||
|
proxy_set_header X-Forwarded-Host \$host;
|
||||||
|
# WebSocket upgrade (chat, live events, voice signaling):
|
||||||
|
proxy_set_header Upgrade \$http_upgrade;
|
||||||
|
proxy_set_header Connection \$connection_upgrade;
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
hr
|
||||||
|
}
|
||||||
|
|
||||||
|
print_caddy_snippet() {
|
||||||
|
snip "Caddy — if you run your OWN Caddy (it auto-handles WebSocket + HTTPS):"
|
||||||
|
hr
|
||||||
|
cat << EOF
|
||||||
|
${DOMAIN} {
|
||||||
|
EOF
|
||||||
|
if [[ "$ENABLE_VOICE" == true ]]; then
|
||||||
|
cat << EOF
|
||||||
|
handle_path /livekit/* {
|
||||||
|
reverse_proxy 127.0.0.1:7880
|
||||||
|
}
|
||||||
|
handle {
|
||||||
|
reverse_proxy 127.0.0.1:${APP_PORT_FINAL}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
else
|
||||||
|
cat << EOF
|
||||||
|
reverse_proxy 127.0.0.1:${APP_PORT_FINAL}
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
cat << EOF
|
||||||
|
request_body {
|
||||||
|
max_size ${max_mb}MB
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
hr
|
||||||
|
}
|
||||||
|
|
||||||
|
print_traefik_snippet() {
|
||||||
|
snip "Traefik — dynamic (file-provider) config; Traefik handles WebSocket itself:"
|
||||||
|
hr
|
||||||
|
cat << EOF
|
||||||
|
http:
|
||||||
|
routers:
|
||||||
|
backspace:
|
||||||
|
rule: "Host(\`${DOMAIN}\`)"
|
||||||
|
entryPoints: [websecure]
|
||||||
|
service: backspace
|
||||||
|
tls:
|
||||||
|
certResolver: letsencrypt
|
||||||
|
EOF
|
||||||
|
if [[ "$ENABLE_VOICE" == true ]]; then
|
||||||
|
cat << EOF
|
||||||
|
backspace-livekit:
|
||||||
|
rule: "Host(\`${DOMAIN}\`) && PathPrefix(\`/livekit\`)"
|
||||||
|
entryPoints: [websecure]
|
||||||
|
service: backspace-livekit
|
||||||
|
priority: 100
|
||||||
|
middlewares: [strip-livekit]
|
||||||
|
tls:
|
||||||
|
certResolver: letsencrypt
|
||||||
|
middlewares:
|
||||||
|
strip-livekit:
|
||||||
|
stripPrefix:
|
||||||
|
prefixes: ["/livekit"]
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
cat << EOF
|
||||||
|
services:
|
||||||
|
backspace:
|
||||||
|
loadBalancer:
|
||||||
|
servers:
|
||||||
|
- url: "http://127.0.0.1:${APP_PORT_FINAL}"
|
||||||
|
EOF
|
||||||
|
if [[ "$ENABLE_VOICE" == true ]]; then
|
||||||
|
cat << EOF
|
||||||
|
backspace-livekit:
|
||||||
|
loadBalancer:
|
||||||
|
servers:
|
||||||
|
- url: "http://127.0.0.1:7880"
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
hr
|
||||||
|
}
|
||||||
|
|
||||||
|
print_tunnel_snippet() {
|
||||||
|
snip "Cloudflare Tunnel — ingress rule (cloudflared config.yml):"
|
||||||
|
hr
|
||||||
|
cat << EOF
|
||||||
|
tunnel: <YOUR-TUNNEL-ID>
|
||||||
|
credentials-file: /root/.cloudflared/<YOUR-TUNNEL-ID>.json
|
||||||
|
|
||||||
|
ingress:
|
||||||
|
- hostname: ${DOMAIN}
|
||||||
|
service: http://127.0.0.1:${APP_PORT_FINAL}
|
||||||
|
- service: http_status:404
|
||||||
|
EOF
|
||||||
|
hr
|
||||||
|
echo " Then map the hostname to the tunnel (once):"
|
||||||
|
echo -e " ${BOLD}cloudflared tunnel route dns <YOUR-TUNNEL-ID> ${DOMAIN}${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
# ── Phase 8: Summary ───────────────────────────────────────
|
# ── Phase 8: Summary ───────────────────────────────────────
|
||||||
|
|
||||||
@@ -469,13 +922,15 @@ step "Backspace is running"
|
|||||||
|
|
||||||
echo -e " ${BOLD}URL:${NC} https://${DOMAIN}"
|
echo -e " ${BOLD}URL:${NC} https://${DOMAIN}"
|
||||||
echo -e " ${BOLD}Instance:${NC} ${INSTANCE_NAME}"
|
echo -e " ${BOLD}Instance:${NC} ${INSTANCE_NAME}"
|
||||||
|
echo -e " ${BOLD}Mode:${NC} ${DEPLOY_MODE}"
|
||||||
echo -e " ${BOLD}Voice:${NC} $(if [[ "$ENABLE_VOICE" == true ]]; then echo 'Enabled'; else echo 'Disabled'; fi)"
|
echo -e " ${BOLD}Voice:${NC} $(if [[ "$ENABLE_VOICE" == true ]]; then echo 'Enabled'; else echo 'Disabled'; fi)"
|
||||||
|
|
||||||
|
if [[ "$DEPLOY_MODE" == "allinone" ]]; then
|
||||||
case "$https_status" in
|
case "$https_status" in
|
||||||
live) echo -e " ${BOLD}HTTPS:${NC} ${GREEN}Live${NC}" ;;
|
live) echo -e " ${BOLD}HTTPS:${NC} ${GREEN}Live${NC}" ;;
|
||||||
pending) echo -e " ${BOLD}HTTPS:${NC} ${YELLOW}Not live yet${NC}" ;;
|
pending) echo -e " ${BOLD}HTTPS:${NC} ${YELLOW}Not live yet${NC}" ;;
|
||||||
esac
|
esac
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
if [[ "$https_status" == "pending" ]]; then
|
if [[ "$https_status" == "pending" ]]; then
|
||||||
echo -e " ${YELLOW}The app is up, but HTTPS isn't live yet — Caddy is still trying to get a certificate.${NC}"
|
echo -e " ${YELLOW}The app is up, but HTTPS isn't live yet — Caddy is still trying to get a certificate.${NC}"
|
||||||
echo -e " ${YELLOW}This is normal right after install; it comes up automatically once BOTH are true:${NC}"
|
echo -e " ${YELLOW}This is normal right after install; it comes up automatically once BOTH are true:${NC}"
|
||||||
@@ -487,12 +942,6 @@ if [[ "$https_status" == "pending" ]]; then
|
|||||||
else
|
else
|
||||||
echo -e " ${YELLOW}Open https://${DOMAIN} and create the first account — it becomes the instance admin.${NC}"
|
echo -e " ${YELLOW}Open https://${DOMAIN} and create the first account — it becomes the instance admin.${NC}"
|
||||||
fi
|
fi
|
||||||
echo ""
|
|
||||||
echo -e " ${BOLD}Commands:${NC}"
|
|
||||||
echo " docker compose logs -f # Watch logs"
|
|
||||||
echo " docker compose restart # Restart all services"
|
|
||||||
echo " docker compose down # Stop everything"
|
|
||||||
echo " docker compose up -d --build # Rebuild after code changes"
|
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
# Best-effort primary LAN IP (the address a router would port-forward to).
|
# Best-effort primary LAN IP (the address a router would port-forward to).
|
||||||
@@ -515,4 +964,51 @@ if [[ "$ENABLE_VOICE" == true ]]; then
|
|||||||
fi
|
fi
|
||||||
echo -e " LiveKit's own signaling port (7880) stays internal — do ${BOLD}not${NC} forward it."
|
echo -e " LiveKit's own signaling port (7880) stays internal — do ${BOLD}not${NC} forward it."
|
||||||
|
|
||||||
|
elif [[ "$DEPLOY_MODE" == "proxy" ]]; then
|
||||||
|
echo ""
|
||||||
|
echo -e " Backspace listens on ${BOLD}127.0.0.1:${APP_PORT_FINAL}${NC} (loopback only — never expose it directly)."
|
||||||
|
echo -e " Point your reverse proxy at it, then open ${BOLD}https://${DOMAIN}${NC} and register the first account (it becomes admin)."
|
||||||
|
echo ""
|
||||||
|
echo -e " ${BOLD}Paste one of these into your reverse proxy${NC} (WebSocket, X-Forwarded-*, and body cap already set):"
|
||||||
|
echo ""
|
||||||
|
print_nginx_snippet
|
||||||
|
echo ""
|
||||||
|
print_caddy_snippet
|
||||||
|
echo ""
|
||||||
|
print_traefik_snippet
|
||||||
|
echo ""
|
||||||
|
echo -e " ${BOLD}Nginx Proxy Manager / other GUI proxies:${NC} see the field-by-field guide"
|
||||||
|
echo -e " in the README → 'Deployment modes' (forward to 127.0.0.1:${APP_PORT_FINAL}, enable"
|
||||||
|
echo -e " 'Websockets Support', and set client_max_body_size ${max_mb}m in the Advanced tab)."
|
||||||
|
if [[ "$ENABLE_VOICE" == true ]]; then
|
||||||
|
echo ""
|
||||||
|
echo -e " ${YELLOW}Voice is enabled — in addition to the /livekit route above, open these UDP/TCP${NC}"
|
||||||
|
echo -e " ${YELLOW}media ports on the firewall and forward them to this host:${NC}"
|
||||||
|
echo " 3478/UDP TURN — WebRTC NAT traversal"
|
||||||
|
echo " 7881/TCP WebRTC TCP fallback"
|
||||||
|
echo " 50000-60000/UDP WebRTC media — voice / video / screen-share"
|
||||||
|
echo -e " ${YELLOW}Media flows host→client directly, NOT through your reverse proxy.${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
elif [[ "$DEPLOY_MODE" == "tunnel" ]]; then
|
||||||
|
echo ""
|
||||||
|
echo -e " Backspace listens on ${BOLD}127.0.0.1:${APP_PORT_FINAL}${NC} (loopback only). Your tunnel fronts it."
|
||||||
|
echo -e " Once the ingress rule is live, open ${BOLD}https://${DOMAIN}${NC} and register the first account (it becomes admin)."
|
||||||
|
echo ""
|
||||||
|
print_tunnel_snippet
|
||||||
|
echo ""
|
||||||
|
echo -e " ${YELLOW}Upload cap:${NC} MAX_UPLOAD_SIZE is set to ${max_mb}MB to stay under Cloudflare's 100MB"
|
||||||
|
echo -e " request-body limit. Raising it above ~100MB will make large uploads fail at the edge."
|
||||||
|
echo ""
|
||||||
|
echo -e " ${YELLOW}Voice/video is not available over a tunnel${NC} — WebRTC/UDP media can't traverse it."
|
||||||
|
echo -e " If you need voice, run Backspace behind a reverse proxy (proxy mode) with the"
|
||||||
|
echo -e " media ports opened, or use All-in-One with ports 80/443."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e " ${BOLD}Commands${NC} (run from this directory):"
|
||||||
|
echo " docker compose logs -f # Watch logs"
|
||||||
|
echo " docker compose restart # Restart all services"
|
||||||
|
echo " docker compose down # Stop everything"
|
||||||
|
echo " docker compose pull && docker compose up -d # Update to the latest prebuilt image"
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
@@ -13,11 +13,13 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev:server": "pnpm --filter @backspace/server dev",
|
"dev:server": "pnpm --filter @backspace/server dev",
|
||||||
"dev:web": "pnpm --filter @backspace/web dev",
|
"dev:web": "pnpm --filter @backspace/web dev",
|
||||||
"dev": "pnpm --filter @backspace/server dev & pnpm --filter @backspace/web dev",
|
"dev": "pnpm --parallel --filter @backspace/server --filter @backspace/web dev",
|
||||||
"build:shared": "pnpm --filter @backspace/shared build",
|
"build:shared": "pnpm --filter @backspace/shared build",
|
||||||
"build:server": "pnpm --filter @backspace/server build",
|
"build:server": "pnpm --filter @backspace/server build",
|
||||||
"build:web": "pnpm --filter @backspace/web 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",
|
||||||
|
"typecheck": "pnpm --filter @backspace/shared build && pnpm -r typecheck",
|
||||||
|
"test": "pnpm -r test",
|
||||||
"dev:desktop": "pnpm --filter @backspace/desktop dev",
|
"dev:desktop": "pnpm --filter @backspace/desktop dev",
|
||||||
"build:desktop": "pnpm --filter @backspace/desktop build",
|
"build:desktop": "pnpm --filter @backspace/desktop build",
|
||||||
"gen-icons": "node scripts/gen-icons.mjs"
|
"gen-icons": "node scripts/gen-icons.mjs"
|
||||||
@@ -38,8 +40,9 @@
|
|||||||
"uiohook-napi@1.5.5": "patches/uiohook-napi@1.5.5.patch"
|
"uiohook-napi@1.5.5": "patches/uiohook-napi@1.5.5.patch"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"packageManager": "pnpm@10.34.3",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0",
|
"node": ">=20.0.0",
|
||||||
"pnpm": ">=8.0.0"
|
"pnpm": ">=10.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
"clean": "rm -rf dist dist-electron",
|
"clean": "rm -rf dist dist-electron",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"postinstall": "electron-rebuild -f -w uiohook-napi"
|
"postinstall": "electron-rebuild -f -w uiohook-napi || node -e \"console.warn('[desktop] uiohook-napi native rebuild skipped - needs build tools (make, g++, python3). Only required to RUN the desktop app; the server, web client, and Docker image are unaffected.')\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"electron-updater": "^6.3.0",
|
"electron-updater": "^6.3.0",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"author": "Jannis Braun",
|
"author": "Jannis Braun",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "PORT=3005 tsx watch src/index.ts",
|
"dev": "cross-env PORT=3005 tsx watch src/index.ts",
|
||||||
"start": "node --import tsx/esm src/index.ts",
|
"start": "node --import tsx/esm src/index.ts",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
@@ -41,6 +41,7 @@
|
|||||||
"@types/jsonwebtoken": "^9.0.6",
|
"@types/jsonwebtoken": "^9.0.6",
|
||||||
"@types/node": "^20.14.0",
|
"@types/node": "^20.14.0",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
|
"cross-env": "^7.0.3",
|
||||||
"drizzle-kit": "^0.24.0",
|
"drizzle-kit": "^0.24.0",
|
||||||
"typescript": "^5.4.0",
|
"typescript": "^5.4.0",
|
||||||
"vitest": "^4.0.18",
|
"vitest": "^4.0.18",
|
||||||
|
|||||||
@@ -200,6 +200,57 @@ describe('POST /api/dm/space-invite', () => {
|
|||||||
expect(messageCount).toBe(0);
|
expect(messageCount).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects a space invite for a local request-only space (approval required)', async () => {
|
||||||
|
// Real local space with request visibility; the snapshot is mocked to match.
|
||||||
|
testDb.insert(schema.spaces).values({
|
||||||
|
id: 'S-REQ', name: 'Req', ownerId: 'alice', inviteCode: 'reqcode',
|
||||||
|
visibility: 'request', createdAt: 1,
|
||||||
|
}).run();
|
||||||
|
(getLocalInviteSnapshot as unknown as ReturnType<typeof vi.fn>).mockReturnValueOnce({
|
||||||
|
spaceId: 'S-REQ', spaceName: 'Req', description: null, icon: null,
|
||||||
|
avatarColor: null, memberCount: 1, instanceName: 'Backspace',
|
||||||
|
});
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/dm/space-invite',
|
||||||
|
payload: {
|
||||||
|
target: { userId: 'bob' },
|
||||||
|
spaceId: 'S-REQ',
|
||||||
|
spaceInstanceOrigin: '',
|
||||||
|
inviteCode: 'reqcode',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
// No DM card should be inserted for a request-only space.
|
||||||
|
expect(testDb.select().from(schema.dmMessages).all().length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a local request-only space even when spaceInstanceOrigin is spoofed to look remote', async () => {
|
||||||
|
// A caller can send an origin variant (trailing slash / different case) so
|
||||||
|
// `isLocal` is false and the fetch path is taken, but the space is genuinely
|
||||||
|
// local + request. The guard must not depend on the claimed origin.
|
||||||
|
testDb.insert(schema.spaces).values({
|
||||||
|
id: 'S-REQ2', name: 'Req2', ownerId: 'alice', inviteCode: 'reqcode2',
|
||||||
|
visibility: 'request', createdAt: 1,
|
||||||
|
}).run();
|
||||||
|
(fetchSpaceInviteSnapshot as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
spaceId: 'S-REQ2', spaceName: 'Req2', description: null, icon: null,
|
||||||
|
avatarColor: null, memberCount: 1, instanceName: 'Backspace',
|
||||||
|
});
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/dm/space-invite',
|
||||||
|
payload: {
|
||||||
|
target: { userId: 'bob' },
|
||||||
|
spaceId: 'S-REQ2',
|
||||||
|
spaceInstanceOrigin: 'https://local.test/', // trailing slash defeats strict isLocal compare
|
||||||
|
inviteCode: 'reqcode2',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
expect(testDb.select().from(schema.dmMessages).all().length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
it('inserts a type=system message with parseable space_invite content on success', async () => {
|
it('inserts a type=system message with parseable space_invite content on success', async () => {
|
||||||
(getLocalInviteSnapshot as unknown as ReturnType<typeof vi.fn>).mockReturnValueOnce({
|
(getLocalInviteSnapshot as unknown as ReturnType<typeof vi.fn>).mockReturnValueOnce({
|
||||||
spaceId: 'S1',
|
spaceId: 'S1',
|
||||||
|
|||||||
@@ -2393,6 +2393,19 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return reply.code(400).send({ error: 'invite_invalid', statusCode: 400 });
|
return reply.code(400).send({ error: 'invite_invalid', statusCode: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Request-only spaces are approval-gated and have no usable invite links, so
|
||||||
|
// refuse to send an invite card that would dead-end at the recipient's join
|
||||||
|
// guard. This is checked against our LOCAL spaces table by id, independent of
|
||||||
|
// the caller-supplied spaceInstanceOrigin: if the space is genuinely local and
|
||||||
|
// request-only we reject even when the origin is spoofed to look remote. A
|
||||||
|
// truly remote space is absent from this table (undefined → allowed); its own
|
||||||
|
// home instance enforces the same rule when the recipient tries to join.
|
||||||
|
const localSpace = db.select({ visibility: schema.spaces.visibility })
|
||||||
|
.from(schema.spaces).where(eq(schema.spaces.id, body.spaceId)).get();
|
||||||
|
if (localSpace?.visibility === 'request') {
|
||||||
|
return reply.code(403).send({ error: 'space_requires_approval', statusCode: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
// 4. Resolve / create the 1-on-1 DM (delegate to dedup helper).
|
// 4. Resolve / create the 1-on-1 DM (delegate to dedup helper).
|
||||||
const dmChannelId = ensureOneOnOneDmChannel(callerId, targetUser, db);
|
const dmChannelId = ensureOneOnOneDmChannel(callerId, targetUser, db);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import { getDb, schema } from '../../db/index.js';
|
||||||
|
import { getOurOrigin } from '../../utils/federationAuth.js';
|
||||||
|
import { sanitizeUser } from '../../utils/sanitize.js';
|
||||||
|
import { generateSnowflake } from '../../utils/snowflake.js';
|
||||||
|
import { connectionManager } from '../../ws/handler.js';
|
||||||
|
import { getDmMessageWithUser } from '../dm.js';
|
||||||
|
import { and, desc, eq, inArray, isNull, or } from 'drizzle-orm';
|
||||||
|
import type { FederatedCallEntry } from '../../ws/handler.js';
|
||||||
|
import type { DmChannel, DmMessageWithUser } from '@backspace/shared';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the full DM channel payload used by `dm_channel_created` events.
|
||||||
|
* Hydrates members, fetches the last message, and returns a `DmChannel`-shaped
|
||||||
|
* object — or `null` when the channel row doesn't exist / is deleted.
|
||||||
|
*
|
||||||
|
* An optional `lastMessageOverride` lets callers supply the message object
|
||||||
|
* directly (e.g. the just-relayed message) instead of querying the DB.
|
||||||
|
*/
|
||||||
|
export function buildDmChannelPayload(
|
||||||
|
channelId: string,
|
||||||
|
db: ReturnType<typeof getDb>,
|
||||||
|
lastMessageOverride?: DmMessageWithUser | null,
|
||||||
|
): DmChannel | null {
|
||||||
|
const dmChannel = db.select()
|
||||||
|
.from(schema.dmChannels)
|
||||||
|
.where(and(eq(schema.dmChannels.id, channelId), isNull(schema.dmChannels.deletedAt)))
|
||||||
|
.get();
|
||||||
|
if (!dmChannel) return null;
|
||||||
|
|
||||||
|
const allMemberRows = db.select()
|
||||||
|
.from(schema.dmMembers)
|
||||||
|
.where(eq(schema.dmMembers.dmChannelId, channelId))
|
||||||
|
.all();
|
||||||
|
const memberUserIds = allMemberRows.map(m => m.userId);
|
||||||
|
const users = memberUserIds.length > 0
|
||||||
|
? db.select().from(schema.users).where(inArray(schema.users.id, memberUserIds)).all()
|
||||||
|
: [];
|
||||||
|
|
||||||
|
let lastMessage: DmMessageWithUser | null = lastMessageOverride ?? null;
|
||||||
|
if (!lastMessageOverride) {
|
||||||
|
const lastMsgRow = db.select({ id: schema.dmMessages.id })
|
||||||
|
.from(schema.dmMessages)
|
||||||
|
.where(eq(schema.dmMessages.dmChannelId, channelId))
|
||||||
|
.orderBy(desc(schema.dmMessages.createdAt))
|
||||||
|
.limit(1)
|
||||||
|
.get();
|
||||||
|
if (lastMsgRow) {
|
||||||
|
lastMessage = getDmMessageWithUser(lastMsgRow.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: dmChannel.id,
|
||||||
|
ownerId: dmChannel.ownerId ?? null,
|
||||||
|
federatedId: dmChannel.federatedId ?? null,
|
||||||
|
createdAt: dmChannel.createdAt,
|
||||||
|
members: users.map(u => sanitizeUser(u)),
|
||||||
|
lastMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Relay Event Processors ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find or create a local DM channel for a federated DM.
|
||||||
|
* Uses federated_id for deterministic cross-instance lookup.
|
||||||
|
*/
|
||||||
|
export function findOrCreateDmChannel(
|
||||||
|
federatedId: string,
|
||||||
|
localUserIds: string[],
|
||||||
|
db: ReturnType<typeof getDb>,
|
||||||
|
): string {
|
||||||
|
// Try to find existing channel by federated ID
|
||||||
|
const existing = db
|
||||||
|
.select()
|
||||||
|
.from(schema.dmChannels)
|
||||||
|
.where(eq(schema.dmChannels.federatedId, federatedId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// Ensure all users are members (they might have been removed)
|
||||||
|
for (const userId of localUserIds) {
|
||||||
|
const member = db
|
||||||
|
.select()
|
||||||
|
.from(schema.dmMembers)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.dmMembers.dmChannelId, existing.id),
|
||||||
|
eq(schema.dmMembers.userId, userId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!member) {
|
||||||
|
db.insert(schema.dmMembers)
|
||||||
|
.values({
|
||||||
|
dmChannelId: existing.id,
|
||||||
|
userId,
|
||||||
|
closed: 0,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Late-bind: if a FederatedCallEntry exists for this federatedId with null dmChannelId,
|
||||||
|
// update it now that we have a local channel
|
||||||
|
connectionManager.lateBindFederatedCall(federatedId, existing.id);
|
||||||
|
return existing.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new DM channel with federated ID
|
||||||
|
const channelId = generateSnowflake();
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
db.insert(schema.dmChannels)
|
||||||
|
.values({
|
||||||
|
id: channelId,
|
||||||
|
federatedId,
|
||||||
|
createdAt: now,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
for (const userId of localUserIds) {
|
||||||
|
db.insert(schema.dmMembers)
|
||||||
|
.values({
|
||||||
|
dmChannelId: channelId,
|
||||||
|
userId,
|
||||||
|
closed: 0,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Late-bind: if a FederatedCallEntry exists for this federatedId with null dmChannelId,
|
||||||
|
// update it now that we have a local channel
|
||||||
|
connectionManager.lateBindFederatedCall(federatedId, channelId);
|
||||||
|
|
||||||
|
return channelId;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a DmMessageWithUser payload for WebSocket broadcasting.
|
||||||
|
*/
|
||||||
|
export function buildDmMessagePayload(
|
||||||
|
messageRow: {
|
||||||
|
id: string;
|
||||||
|
dmChannelId: string;
|
||||||
|
userId: string;
|
||||||
|
content: string | null;
|
||||||
|
replyToId: string | null;
|
||||||
|
editedAt: number | null;
|
||||||
|
createdAt: number;
|
||||||
|
},
|
||||||
|
userRow: typeof schema.users.$inferSelect,
|
||||||
|
): DmMessageWithUser {
|
||||||
|
return {
|
||||||
|
id: messageRow.id,
|
||||||
|
dmChannelId: messageRow.dmChannelId,
|
||||||
|
channelId: messageRow.dmChannelId,
|
||||||
|
userId: messageRow.userId,
|
||||||
|
content: messageRow.content,
|
||||||
|
replyToId: messageRow.replyToId,
|
||||||
|
editedAt: messageRow.editedAt,
|
||||||
|
createdAt: messageRow.createdAt,
|
||||||
|
user: sanitizeUser(userRow),
|
||||||
|
attachments: [],
|
||||||
|
embeds: [],
|
||||||
|
reactions: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that a URL's hostname matches the peer origin's hostname (SSRF protection).
|
||||||
|
*/
|
||||||
|
export function isUrlFromPeer(sourceUrl: string, peerOrigin: string): boolean {
|
||||||
|
try {
|
||||||
|
const sourceHost = new URL(sourceUrl).hostname;
|
||||||
|
const peerHost = new URL(peerOrigin).hostname;
|
||||||
|
return sourceHost === peerHost;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a local DM message from a federation relay event's canonical identity.
|
||||||
|
* Uses messageHomeInstance to branch the lookup:
|
||||||
|
* - If the message originated on THIS instance → find by local ID
|
||||||
|
* - Otherwise → find by sourceInstance + sourceMessageId tracking
|
||||||
|
* Falls back to relay sender origin when messageHomeInstance is absent (backward compat).
|
||||||
|
*/
|
||||||
|
export function resolveLocalDmMessage(
|
||||||
|
canonicalMessageId: string,
|
||||||
|
messageHomeInstance: string | undefined,
|
||||||
|
sourceInstance: string,
|
||||||
|
db: ReturnType<typeof getDb>,
|
||||||
|
): typeof schema.dmMessages.$inferSelect | undefined {
|
||||||
|
if (messageHomeInstance && messageHomeInstance === getOurOrigin()) {
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(schema.dmMessages)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.dmMessages.id, canonicalMessageId),
|
||||||
|
isNull(schema.dmMessages.sourceInstance),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
}
|
||||||
|
const originInstance = messageHomeInstance || sourceInstance;
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(schema.dmMessages)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.dmMessages.sourceInstance, originInstance),
|
||||||
|
eq(schema.dmMessages.sourceMessageId, canonicalMessageId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
}
|
||||||
@@ -0,0 +1,606 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
import { getDb, schema } from '../../../db/index.js';
|
||||||
|
import { getOurOrigin } from '../../../utils/federationAuth.js';
|
||||||
|
import { mapCallReasonToEventReason, sendCallRelay } from '../../../utils/federationOutbox.js';
|
||||||
|
import { generateSnowflake } from '../../../utils/snowflake.js';
|
||||||
|
import { connectionManager } from '../../../ws/handler.js';
|
||||||
|
import { and, eq, isNull, or, sql } from 'drizzle-orm';
|
||||||
|
import type { CallFanoutFailure } from '../../../utils/federationOutbox.js';
|
||||||
|
import type { DmRoomMeta, FederatedCallEntry } from '../../../ws/handler.js';
|
||||||
|
import type { DmCallUndeliverableFailure, FederationRelayEvent, ServerEvent } from '@backspace/shared';
|
||||||
|
import { extractDomain, resolveLocalUser, resolveOrCreateReplicatedUser, verifyAttribution } from '../identity.js';
|
||||||
|
|
||||||
|
export function processDmCallStartEvent(
|
||||||
|
event: FederationRelayEvent,
|
||||||
|
sourceInstance: string,
|
||||||
|
db: ReturnType<typeof getDb>,
|
||||||
|
accepted: string[],
|
||||||
|
rejected: Array<{ messageId: string; reason: string }>,
|
||||||
|
undeliverable: Array<{ messageId: string; reason: string }>,
|
||||||
|
): void {
|
||||||
|
if (!event.call?.caller || !event.call.livekitUrl || !event.call.tokens || !event.federatedId) {
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'missing_call_payload' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attribution: caller must belong to source instance
|
||||||
|
if (!verifyAttribution(event.call.caller.homeInstance, sourceInstance)) {
|
||||||
|
console.warn(`[federation] Attribution mismatch in dm_call_start: caller=${extractDomain(event.call.caller.homeInstance)} source=${extractDomain(sourceInstance)}`);
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'attribution_mismatch' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find local DM channel by federatedId
|
||||||
|
const channel = db.select({ id: schema.dmChannels.id })
|
||||||
|
.from(schema.dmChannels)
|
||||||
|
.where(eq(schema.dmChannels.federatedId, event.federatedId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
// Resolve caller to local stub
|
||||||
|
const callerStub = resolveOrCreateReplicatedUser(
|
||||||
|
event.call.caller.homeUserId,
|
||||||
|
event.call.caller.homeInstance,
|
||||||
|
db,
|
||||||
|
{ username: event.call.caller.displayName },
|
||||||
|
);
|
||||||
|
if (!callerStub) {
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'participant_not_found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ringedUserIds: string[] = [];
|
||||||
|
|
||||||
|
if (channel) {
|
||||||
|
// ── Path A: DM exists locally ──
|
||||||
|
const localDmChannelId = channel.id;
|
||||||
|
|
||||||
|
const localMembers = db.select({
|
||||||
|
userId: schema.dmMembers.userId,
|
||||||
|
homeUserId: schema.users.homeUserId,
|
||||||
|
homeInstance: schema.users.homeInstance,
|
||||||
|
})
|
||||||
|
.from(schema.dmMembers)
|
||||||
|
.innerJoin(schema.users, eq(schema.dmMembers.userId, schema.users.id))
|
||||||
|
.where(eq(schema.dmMembers.dmChannelId, localDmChannelId))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
for (const member of localMembers) {
|
||||||
|
const homeUserId = member.homeUserId || member.userId;
|
||||||
|
// Bug 1 fix: don't ring the caller on this instance
|
||||||
|
if (homeUserId === event.call.caller.homeUserId) continue;
|
||||||
|
|
||||||
|
// #18: skip offline members. Entry-vs-no-entry decision uses the same
|
||||||
|
// connection-count signal Path B has always used — keeps the two paths
|
||||||
|
// symmetric in what counts as "ringed."
|
||||||
|
if (connectionManager.getUserConnections(member.userId).size === 0) continue;
|
||||||
|
|
||||||
|
const token = event.call!.tokens![homeUserId];
|
||||||
|
connectionManager.sendToUser(member.userId, {
|
||||||
|
type: 'dm_call_incoming',
|
||||||
|
dmChannelId: localDmChannelId,
|
||||||
|
federatedCallId: event.federatedId,
|
||||||
|
callerId: callerStub.id,
|
||||||
|
callerName: callerStub.displayName ?? callerStub.username,
|
||||||
|
livekitUrl: event.call!.livekitUrl,
|
||||||
|
livekitToken: token,
|
||||||
|
callOrigin: event.call!.caller.homeInstance,
|
||||||
|
});
|
||||||
|
ringedUserIds.push(member.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ringedUserIds.length === 0) {
|
||||||
|
// #18: no local member was reachable. Do not create a FederatedCallEntry
|
||||||
|
// (it would strand with no accept/reject path); surface to the caller
|
||||||
|
// via undeliverable so it can tear down its ring room instead of hanging.
|
||||||
|
undeliverable.push({ messageId: event.messageId, reason: 'no_recipient' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry: FederatedCallEntry = {
|
||||||
|
dmChannelId: localDmChannelId,
|
||||||
|
federatedId: event.federatedId,
|
||||||
|
callerId: callerStub.id,
|
||||||
|
callerHomeUserId: event.call.caller.homeUserId,
|
||||||
|
federatedCallHost: sourceInstance.startsWith('http') ? sourceInstance : `https://${sourceInstance}`,
|
||||||
|
livekitUrl: event.call.livekitUrl,
|
||||||
|
tokens: new Map(Object.entries(event.call.tokens)),
|
||||||
|
ringedUserIds,
|
||||||
|
state: 'ringing',
|
||||||
|
startedAt: Date.now(),
|
||||||
|
};
|
||||||
|
connectionManager.createFederatedCall(entry);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// ── Path B: DM doesn't exist locally — match by participant identity ──
|
||||||
|
if (!event.call.participants || !Array.isArray(event.call.participants)) {
|
||||||
|
// Old-format relay without participants — backwards-compatible rejection
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'channel_not_found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ourDomain = extractDomain(getOurOrigin());
|
||||||
|
|
||||||
|
for (const p of event.call.participants) {
|
||||||
|
const participantDomain = extractDomain(p.homeInstance);
|
||||||
|
// Skip the caller — strict match on BOTH homeUserId AND homeInstance
|
||||||
|
if (p.homeUserId === event.call.caller.homeUserId
|
||||||
|
&& participantDomain === extractDomain(event.call.caller.homeInstance)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strict identity resolution: homeUserId is only unique within its homeInstance
|
||||||
|
const localUser = db.select({ id: schema.users.id, homeUserId: schema.users.homeUserId })
|
||||||
|
.from(schema.users)
|
||||||
|
.where(
|
||||||
|
or(
|
||||||
|
// Replicated stub or federated account from the participant's home instance
|
||||||
|
and(
|
||||||
|
eq(schema.users.homeUserId, p.homeUserId),
|
||||||
|
sql`replace(replace(coalesce(${schema.users.homeInstance}, ''), 'https://', ''), 'http://', '') = ${participantDomain}`,
|
||||||
|
),
|
||||||
|
// Native user whose ID matches and participant's home matches our domain
|
||||||
|
and(
|
||||||
|
eq(schema.users.id, p.homeUserId),
|
||||||
|
isNull(schema.users.homeInstance),
|
||||||
|
sql`${participantDomain} = ${ourDomain}`,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!localUser) continue;
|
||||||
|
|
||||||
|
// Check if user has an active WS connection
|
||||||
|
const connections = connectionManager.getUserConnections(localUser.id);
|
||||||
|
if (connections.size === 0) continue;
|
||||||
|
|
||||||
|
const homeUserId = localUser.homeUserId || localUser.id;
|
||||||
|
const token = event.call!.tokens![homeUserId];
|
||||||
|
if (!token) continue;
|
||||||
|
|
||||||
|
connectionManager.sendToUser(localUser.id, {
|
||||||
|
type: 'dm_call_incoming',
|
||||||
|
dmChannelId: null,
|
||||||
|
federatedCallId: event.federatedId,
|
||||||
|
callerId: callerStub.id,
|
||||||
|
callerName: callerStub.displayName ?? callerStub.username,
|
||||||
|
livekitUrl: event.call!.livekitUrl,
|
||||||
|
livekitToken: token,
|
||||||
|
callOrigin: event.call!.caller.homeInstance,
|
||||||
|
});
|
||||||
|
ringedUserIds.push(localUser.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ringedUserIds.length === 0) {
|
||||||
|
// No recipient reachable — signal to caller via third ack bucket (#18).
|
||||||
|
// The remote processed the event cleanly; this is not a data error, but
|
||||||
|
// the caller must learn that nobody was rung so it can tear down its
|
||||||
|
// local ring room instead of hanging 60s waiting for an accept.
|
||||||
|
undeliverable.push({ messageId: event.messageId, reason: 'no_recipient' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry: FederatedCallEntry = {
|
||||||
|
dmChannelId: null,
|
||||||
|
federatedId: event.federatedId,
|
||||||
|
callerId: callerStub.id,
|
||||||
|
callerHomeUserId: event.call.caller.homeUserId,
|
||||||
|
federatedCallHost: sourceInstance.startsWith('http') ? sourceInstance : `https://${sourceInstance}`,
|
||||||
|
livekitUrl: event.call.livekitUrl,
|
||||||
|
tokens: new Map(Object.entries(event.call.tokens)),
|
||||||
|
ringedUserIds,
|
||||||
|
state: 'ringing',
|
||||||
|
startedAt: Date.now(),
|
||||||
|
};
|
||||||
|
connectionManager.createFederatedCall(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function processDmCallAcceptEvent(
|
||||||
|
event: FederationRelayEvent,
|
||||||
|
sourceInstance: string,
|
||||||
|
db: ReturnType<typeof getDb>,
|
||||||
|
accepted: string[],
|
||||||
|
rejected: Array<{ messageId: string; reason: string }>,
|
||||||
|
): void {
|
||||||
|
if (!event.call?.acceptor || !event.federatedId) {
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'missing_call_payload' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!verifyAttribution(event.call.acceptor.homeInstance, sourceInstance)) {
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'attribution_mismatch' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = db.select({ id: schema.dmChannels.id })
|
||||||
|
.from(schema.dmChannels)
|
||||||
|
.where(eq(schema.dmChannels.federatedId, event.federatedId))
|
||||||
|
.get();
|
||||||
|
const dmChannelId = channel?.id;
|
||||||
|
|
||||||
|
// Check if we're the HOST (have a VoiceRoom)
|
||||||
|
const room = dmChannelId ? connectionManager.getRoom(dmChannelId) : undefined;
|
||||||
|
if (room && room.roomType === 'dm') {
|
||||||
|
const meta = room.metadata as DmRoomMeta;
|
||||||
|
|
||||||
|
if (meta.state === 'ringing') {
|
||||||
|
connectionManager.activateDmRoom(dmChannelId!);
|
||||||
|
|
||||||
|
// Join caller to room
|
||||||
|
connectionManager.leaveCurrentRoom(meta.callerId);
|
||||||
|
connectionManager.joinRoom(dmChannelId!, meta.callerId);
|
||||||
|
|
||||||
|
connectionManager.sendToDmMembers(dmChannelId!, {
|
||||||
|
type: 'voice_state_update',
|
||||||
|
channelId: dmChannelId!,
|
||||||
|
userId: meta.callerId,
|
||||||
|
action: 'join',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast accepted locally — include federatedCallId so all clients can match
|
||||||
|
connectionManager.sendToDmMembers(dmChannelId!, {
|
||||||
|
type: 'dm_call_accepted',
|
||||||
|
dmChannelId: dmChannelId!,
|
||||||
|
federatedCallId: event.federatedId,
|
||||||
|
} as ServerEvent);
|
||||||
|
|
||||||
|
// Fan out to ALL other remote instances (exclude the one that sent the accept)
|
||||||
|
const normalizedSource = sourceInstance.startsWith('http') ? sourceInstance : `https://${sourceInstance}`;
|
||||||
|
const hostCallerId = (room.metadata as DmRoomMeta).callerId;
|
||||||
|
const localDmId = dmChannelId!;
|
||||||
|
void fanOutCallEvent(localDmId, event.federatedId, 'dm_call_accept', {
|
||||||
|
call: { acceptor: event.call.acceptor },
|
||||||
|
}, normalizedSource, db).then(failures => {
|
||||||
|
emitHostFanoutUndeliverable(hostCallerId, localDmId, event.federatedId!, 'accept', failures);
|
||||||
|
}).catch(err =>
|
||||||
|
console.error('[federation] Fan-out dm_call_accept threw:', err),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// We're a REMOTE instance receiving fan-out — transition local state
|
||||||
|
const fedCall = connectionManager.getFederatedCall(event.federatedId);
|
||||||
|
if (fedCall) {
|
||||||
|
// Only broadcast if transitioning from ringing → active.
|
||||||
|
// If already active (e.g., we initiated the accept and the host is fanning out back),
|
||||||
|
// skip the duplicate broadcast to avoid state conflicts on the client.
|
||||||
|
const wasRinging = fedCall.state === 'ringing';
|
||||||
|
connectionManager.activateFederatedCall(event.federatedId);
|
||||||
|
if (wasRinging) {
|
||||||
|
connectionManager.sendToFederatedCallUsers(event.federatedId, {
|
||||||
|
type: 'dm_call_accepted',
|
||||||
|
dmChannelId: fedCall.dmChannelId,
|
||||||
|
federatedCallId: event.federatedId,
|
||||||
|
} as ServerEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function processDmCallRejectEvent(
|
||||||
|
event: FederationRelayEvent,
|
||||||
|
sourceInstance: string,
|
||||||
|
db: ReturnType<typeof getDb>,
|
||||||
|
accepted: string[],
|
||||||
|
rejected: Array<{ messageId: string; reason: string }>,
|
||||||
|
): void {
|
||||||
|
if (!event.call?.rejector || !event.federatedId) {
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'missing_call_payload' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!verifyAttribution(event.call.rejector.homeInstance, sourceInstance)) {
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'attribution_mismatch' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = db.select({ id: schema.dmChannels.id })
|
||||||
|
.from(schema.dmChannels)
|
||||||
|
.where(eq(schema.dmChannels.federatedId, event.federatedId))
|
||||||
|
.get();
|
||||||
|
const dmChannelId = channel?.id;
|
||||||
|
|
||||||
|
const room = dmChannelId ? connectionManager.getRoom(dmChannelId) : undefined;
|
||||||
|
if (room && room.roomType === 'dm') {
|
||||||
|
const meta = room.metadata as DmRoomMeta;
|
||||||
|
const hostCallerId = meta.callerId;
|
||||||
|
const localDmId = dmChannelId!;
|
||||||
|
connectionManager.clearVoiceWs(meta.callerId);
|
||||||
|
connectionManager.destroyRoom(localDmId);
|
||||||
|
|
||||||
|
connectionManager.sendToDmMembers(localDmId, {
|
||||||
|
type: 'dm_call_rejected',
|
||||||
|
dmChannelId: localDmId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const normalizedSource = sourceInstance.startsWith('http') ? sourceInstance : `https://${sourceInstance}`;
|
||||||
|
void fanOutCallEvent(localDmId, event.federatedId, 'dm_call_end', {
|
||||||
|
call: { endedBy: event.call.rejector },
|
||||||
|
}, normalizedSource, db).then(failures => {
|
||||||
|
emitHostFanoutUndeliverable(hostCallerId, localDmId, event.federatedId!, 'reject', failures);
|
||||||
|
}).catch(err =>
|
||||||
|
console.error('[federation] Fan-out dm_call_end (reject) threw:', err),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const fedCall = connectionManager.getFederatedCall(event.federatedId);
|
||||||
|
if (fedCall) {
|
||||||
|
connectionManager.sendToFederatedCallUsers(event.federatedId, {
|
||||||
|
type: 'dm_call_rejected',
|
||||||
|
dmChannelId: fedCall.dmChannelId,
|
||||||
|
federatedCallId: event.federatedId,
|
||||||
|
} as ServerEvent);
|
||||||
|
connectionManager.clearFederatedCall(event.federatedId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function processDmCallEndEvent(
|
||||||
|
event: FederationRelayEvent,
|
||||||
|
sourceInstance: string,
|
||||||
|
db: ReturnType<typeof getDb>,
|
||||||
|
accepted: string[],
|
||||||
|
rejected: Array<{ messageId: string; reason: string }>,
|
||||||
|
): void {
|
||||||
|
if (!event.call?.endedBy || !event.federatedId) {
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'missing_call_payload' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!verifyAttribution(event.call.endedBy.homeInstance, sourceInstance)) {
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'attribution_mismatch' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = db.select({ id: schema.dmChannels.id })
|
||||||
|
.from(schema.dmChannels)
|
||||||
|
.where(eq(schema.dmChannels.federatedId, event.federatedId))
|
||||||
|
.get();
|
||||||
|
const dmChannelId = channel?.id;
|
||||||
|
|
||||||
|
const room = dmChannelId ? connectionManager.getRoom(dmChannelId) : undefined;
|
||||||
|
if (room && room.roomType === 'dm') {
|
||||||
|
const meta = room.metadata as DmRoomMeta;
|
||||||
|
const hostCallerId = meta.callerId;
|
||||||
|
const localDmId = dmChannelId!;
|
||||||
|
connectionManager.clearVoiceWs(meta.callerId);
|
||||||
|
for (const pid of room.participants) {
|
||||||
|
connectionManager.clearVoiceUserStatus(pid);
|
||||||
|
connectionManager.clearVoiceWs(pid);
|
||||||
|
}
|
||||||
|
connectionManager.destroyRoom(localDmId);
|
||||||
|
|
||||||
|
connectionManager.sendToDmMembers(localDmId, {
|
||||||
|
type: 'dm_call_ended',
|
||||||
|
dmChannelId: localDmId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const normalizedSource = sourceInstance.startsWith('http') ? sourceInstance : `https://${sourceInstance}`;
|
||||||
|
void fanOutCallEvent(localDmId, event.federatedId, 'dm_call_end', {
|
||||||
|
call: { endedBy: event.call.endedBy },
|
||||||
|
}, normalizedSource, db).then(failures => {
|
||||||
|
emitHostFanoutUndeliverable(hostCallerId, localDmId, event.federatedId!, 'end', failures);
|
||||||
|
}).catch(err =>
|
||||||
|
console.error('[federation] Fan-out dm_call_end threw:', err),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const fedCall = connectionManager.getFederatedCall(event.federatedId);
|
||||||
|
if (fedCall) {
|
||||||
|
connectionManager.sendToFederatedCallUsers(event.federatedId, {
|
||||||
|
type: 'dm_call_ended',
|
||||||
|
dmChannelId: fedCall.dmChannelId,
|
||||||
|
federatedCallId: event.federatedId,
|
||||||
|
} as ServerEvent);
|
||||||
|
connectionManager.clearFederatedCall(event.federatedId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function processDmTypingStartEvent(
|
||||||
|
event: FederationRelayEvent,
|
||||||
|
sourceInstance: string,
|
||||||
|
db: ReturnType<typeof getDb>,
|
||||||
|
accepted: string[],
|
||||||
|
rejected: Array<{ messageId: string; reason: string }>,
|
||||||
|
): void {
|
||||||
|
if (!event.typing || !event.federatedId) {
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'missing_typing_payload' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up local channel by federatedId
|
||||||
|
const channel = db.select()
|
||||||
|
.from(schema.dmChannels)
|
||||||
|
.where(and(
|
||||||
|
eq(schema.dmChannels.federatedId, event.federatedId),
|
||||||
|
isNull(schema.dmChannels.deletedAt),
|
||||||
|
))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!channel) {
|
||||||
|
// Channel not bootstrapped yet — discard silently
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the typing user (read-only — don't create stubs for ephemeral events)
|
||||||
|
const typingUser = resolveLocalUser(event.typing.homeUserId, db);
|
||||||
|
if (!typingUser) {
|
||||||
|
// User stub doesn't exist — discard silently
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast dm_typing to local DM members (excluding the typer)
|
||||||
|
const dmMembers = db.select()
|
||||||
|
.from(schema.dmMembers)
|
||||||
|
.where(eq(schema.dmMembers.dmChannelId, channel.id))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
for (const member of dmMembers) {
|
||||||
|
if (member.userId !== typingUser.id) {
|
||||||
|
connectionManager.sendToUser(member.userId, {
|
||||||
|
type: 'dm_typing',
|
||||||
|
dmChannelId: channel.id,
|
||||||
|
userId: typingUser.id,
|
||||||
|
username: typingUser.username ?? event.typing.username,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function processDmTypingStopEvent(
|
||||||
|
event: FederationRelayEvent,
|
||||||
|
sourceInstance: string,
|
||||||
|
db: ReturnType<typeof getDb>,
|
||||||
|
accepted: string[],
|
||||||
|
rejected: Array<{ messageId: string; reason: string }>,
|
||||||
|
): void {
|
||||||
|
if (!event.typing || !event.federatedId) {
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'missing_typing_payload' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up local channel by federatedId
|
||||||
|
const channel = db.select()
|
||||||
|
.from(schema.dmChannels)
|
||||||
|
.where(and(
|
||||||
|
eq(schema.dmChannels.federatedId, event.federatedId),
|
||||||
|
isNull(schema.dmChannels.deletedAt),
|
||||||
|
))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!channel) {
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the typing user (read-only)
|
||||||
|
const typingUser = resolveLocalUser(event.typing.homeUserId, db);
|
||||||
|
if (!typingUser) {
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast dm_typing_stop to local DM members
|
||||||
|
const dmMembers = db.select()
|
||||||
|
.from(schema.dmMembers)
|
||||||
|
.where(eq(schema.dmMembers.dmChannelId, channel.id))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
for (const member of dmMembers) {
|
||||||
|
if (member.userId !== typingUser.id) {
|
||||||
|
connectionManager.sendToUser(member.userId, {
|
||||||
|
type: 'dm_typing_stop',
|
||||||
|
dmChannelId: channel.id,
|
||||||
|
userId: typingUser.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fan out a call event to all remote instances with DM members,
|
||||||
|
* optionally excluding the instance that triggered the event.
|
||||||
|
*/
|
||||||
|
export async function fanOutCallEvent(
|
||||||
|
dmChannelId: string,
|
||||||
|
federatedId: string,
|
||||||
|
eventType: 'dm_call_accept' | 'dm_call_reject' | 'dm_call_end',
|
||||||
|
extraFields: Partial<FederationRelayEvent>,
|
||||||
|
excludeOrigin: string | undefined,
|
||||||
|
db: ReturnType<typeof getDb>,
|
||||||
|
): Promise<CallFanoutFailure[]> {
|
||||||
|
const members = db.select({ homeInstance: schema.users.homeInstance })
|
||||||
|
.from(schema.dmMembers)
|
||||||
|
.innerJoin(schema.users, eq(schema.dmMembers.userId, schema.users.id))
|
||||||
|
.where(eq(schema.dmMembers.dmChannelId, dmChannelId))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const ourOrigin = getOurOrigin();
|
||||||
|
const targets = new Set<string>();
|
||||||
|
for (const m of members) {
|
||||||
|
if (m.homeInstance) {
|
||||||
|
const normalized = m.homeInstance.startsWith('http') ? m.homeInstance : `https://${m.homeInstance}`;
|
||||||
|
if (normalized !== ourOrigin && normalized !== excludeOrigin) {
|
||||||
|
targets.add(normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (targets.size === 0) return [];
|
||||||
|
|
||||||
|
const relayEvent: FederationRelayEvent = {
|
||||||
|
eventType,
|
||||||
|
messageId: generateSnowflake(),
|
||||||
|
encryptionVersion: 0,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
federatedId,
|
||||||
|
...extraFields,
|
||||||
|
} as FederationRelayEvent;
|
||||||
|
|
||||||
|
const labelByOrigin = new Map<string, string | null>();
|
||||||
|
for (const r of db.select({ origin: schema.federationPeers.origin, instanceName: schema.federationPeers.instanceName })
|
||||||
|
.from(schema.federationPeers)
|
||||||
|
.all()) {
|
||||||
|
labelByOrigin.set(r.origin, r.instanceName ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await Promise.all(
|
||||||
|
Array.from(targets).map(async origin => ({ origin, result: await sendCallRelay(origin, [relayEvent]) })),
|
||||||
|
);
|
||||||
|
|
||||||
|
const failures: CallFanoutFailure[] = [];
|
||||||
|
for (const { origin, result } of results) {
|
||||||
|
if (!result.ok) {
|
||||||
|
console.error(`[federation] Fan-out ${eventType} to ${origin} failed (${result.reason}): ${result.error}`);
|
||||||
|
failures.push({
|
||||||
|
origin,
|
||||||
|
peerLabel: labelByOrigin.get(origin) ?? undefined,
|
||||||
|
reason: mapCallReasonToEventReason(result.reason),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return failures;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** Emit a non-terminal dm_call_undeliverable for a host-side fan-out failure. */
|
||||||
|
export function emitHostFanoutUndeliverable(
|
||||||
|
userId: string,
|
||||||
|
dmChannelId: string,
|
||||||
|
federatedId: string,
|
||||||
|
phase: 'accept' | 'reject' | 'end',
|
||||||
|
fanoutFailures: CallFanoutFailure[],
|
||||||
|
): void {
|
||||||
|
if (fanoutFailures.length === 0) return;
|
||||||
|
const failures: DmCallUndeliverableFailure[] = fanoutFailures.map(f => ({
|
||||||
|
reason: f.reason,
|
||||||
|
peerOrigin: f.origin,
|
||||||
|
peerLabel: f.peerLabel,
|
||||||
|
}));
|
||||||
|
connectionManager.sendToUser(userId, {
|
||||||
|
type: 'dm_call_undeliverable',
|
||||||
|
dmChannelId,
|
||||||
|
federatedCallId: federatedId,
|
||||||
|
terminal: false,
|
||||||
|
phase,
|
||||||
|
failures,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { getDb } from '../../../db/index.js';
|
||||||
|
import { and } from 'drizzle-orm';
|
||||||
|
import type { FederationRelayEvent } from '@backspace/shared';
|
||||||
|
import { processDmCallAcceptEvent, processDmCallEndEvent, processDmCallRejectEvent, processDmCallStartEvent, processDmTypingStartEvent, processDmTypingStopEvent } from './calls.js';
|
||||||
|
import { processCreateEvent, processDeleteEvent, processReactionAddEvent, processReactionRemoveEvent, processUpdateEvent } from './dmMessages.js';
|
||||||
|
import { processDmCloseEvent, processDmReopenEvent, processFileRejectedEvent, processPresenceUpdateEvent, processReadStateUpdateEvent } from './dmState.js';
|
||||||
|
import { processFriendAddEvent, processFriendRemoveEvent, processFriendRequestCancelEvent, processFriendRequestCreateEvent, processFriendRequestUpdateEvent } from './friends.js';
|
||||||
|
import { processGroupMetadataUpdateEvent, processMemberAddEvent, processMemberRemoveEvent, processOwnershipTransferEvent } from './membership.js';
|
||||||
|
import { processProfileUpdateEvent } from '../profile.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process an array of federation relay events. Used by the HTTP relay endpoint
|
||||||
|
* and directly by the initial-sync worker (which skips the HTTP round-trip).
|
||||||
|
*/
|
||||||
|
export async function processRelayEvents(
|
||||||
|
events: FederationRelayEvent[],
|
||||||
|
sourceInstance: string,
|
||||||
|
peerOrigin: string,
|
||||||
|
db: ReturnType<typeof getDb>,
|
||||||
|
): Promise<{
|
||||||
|
accepted: string[];
|
||||||
|
rejected: Array<{ messageId: string; reason: string }>;
|
||||||
|
undeliverable: Array<{ messageId: string; reason: string }>;
|
||||||
|
}> {
|
||||||
|
const accepted: string[] = [];
|
||||||
|
const rejected: Array<{ messageId: string; reason: string }> = [];
|
||||||
|
const undeliverable: Array<{ messageId: string; reason: string }> = [];
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
try {
|
||||||
|
switch (event.eventType) {
|
||||||
|
case 'create':
|
||||||
|
await processCreateEvent(event, sourceInstance, peerOrigin, db, accepted, rejected);
|
||||||
|
break;
|
||||||
|
case 'update':
|
||||||
|
processUpdateEvent(event, sourceInstance, db, accepted, rejected);
|
||||||
|
break;
|
||||||
|
case 'delete':
|
||||||
|
processDeleteEvent(event, sourceInstance, db, accepted, rejected);
|
||||||
|
break;
|
||||||
|
case 'reaction_add':
|
||||||
|
processReactionAddEvent(event, sourceInstance, db, accepted, rejected);
|
||||||
|
break;
|
||||||
|
case 'reaction_remove':
|
||||||
|
processReactionRemoveEvent(event, sourceInstance, db, accepted, rejected);
|
||||||
|
break;
|
||||||
|
case 'member_add':
|
||||||
|
await processMemberAddEvent(event, sourceInstance, db, accepted, rejected);
|
||||||
|
break;
|
||||||
|
case 'member_remove':
|
||||||
|
processMemberRemoveEvent(event, sourceInstance, db, accepted, rejected);
|
||||||
|
break;
|
||||||
|
case 'ownership_transfer':
|
||||||
|
processOwnershipTransferEvent(event, sourceInstance, db, accepted, rejected);
|
||||||
|
break;
|
||||||
|
case 'friend_request_create':
|
||||||
|
await processFriendRequestCreateEvent(event, sourceInstance, db, accepted, rejected);
|
||||||
|
break;
|
||||||
|
case 'friend_request_update':
|
||||||
|
processFriendRequestUpdateEvent(event, sourceInstance, db, accepted, rejected);
|
||||||
|
break;
|
||||||
|
case 'friend_request_cancel':
|
||||||
|
processFriendRequestCancelEvent(event, sourceInstance, db, accepted, rejected);
|
||||||
|
break;
|
||||||
|
case 'friend_add':
|
||||||
|
await processFriendAddEvent(event, sourceInstance, db, accepted, rejected);
|
||||||
|
break;
|
||||||
|
case 'friend_remove':
|
||||||
|
processFriendRemoveEvent(event, sourceInstance, db, accepted, rejected);
|
||||||
|
break;
|
||||||
|
case 'file_rejected':
|
||||||
|
processFileRejectedEvent(event, sourceInstance, db, accepted, rejected);
|
||||||
|
break;
|
||||||
|
case 'dm_call_start':
|
||||||
|
processDmCallStartEvent(event, sourceInstance, db, accepted, rejected, undeliverable);
|
||||||
|
break;
|
||||||
|
case 'dm_call_accept':
|
||||||
|
processDmCallAcceptEvent(event, sourceInstance, db, accepted, rejected);
|
||||||
|
break;
|
||||||
|
case 'dm_call_reject':
|
||||||
|
processDmCallRejectEvent(event, sourceInstance, db, accepted, rejected);
|
||||||
|
break;
|
||||||
|
case 'dm_call_end':
|
||||||
|
processDmCallEndEvent(event, sourceInstance, db, accepted, rejected);
|
||||||
|
break;
|
||||||
|
case 'dm_typing_start':
|
||||||
|
processDmTypingStartEvent(event, sourceInstance, db, accepted, rejected);
|
||||||
|
break;
|
||||||
|
case 'dm_typing_stop':
|
||||||
|
processDmTypingStopEvent(event, sourceInstance, db, accepted, rejected);
|
||||||
|
break;
|
||||||
|
case 'profile_update':
|
||||||
|
await processProfileUpdateEvent(event, sourceInstance, db, accepted, rejected);
|
||||||
|
break;
|
||||||
|
case 'group_metadata_update':
|
||||||
|
await processGroupMetadataUpdateEvent(event, sourceInstance, db, accepted, rejected);
|
||||||
|
break;
|
||||||
|
case 'presence_update':
|
||||||
|
processPresenceUpdateEvent(event, sourceInstance, db, accepted, rejected);
|
||||||
|
break;
|
||||||
|
case 'read_state_update':
|
||||||
|
processReadStateUpdateEvent(event, sourceInstance, db, accepted, rejected);
|
||||||
|
break;
|
||||||
|
case 'dm_close':
|
||||||
|
processDmCloseEvent(event, sourceInstance, db, accepted, rejected);
|
||||||
|
break;
|
||||||
|
case 'dm_reopen':
|
||||||
|
processDmReopenEvent(event, sourceInstance, db, accepted, rejected);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'unknown_event_type' });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const errMsg = err instanceof Error ? err.message : 'unknown_error';
|
||||||
|
console.error(`[federation-relay] Error processing event ${event.messageId}:`, errMsg);
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'processing_error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { accepted, rejected, undeliverable };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||||
@@ -0,0 +1,573 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
import { getDb, schema } from '../../../db/index.js';
|
||||||
|
import { computeFederatedId } from '../../../utils/federationOutbox.js';
|
||||||
|
import { deleteAttachmentFiles } from '../../../utils/fileCleanup.js';
|
||||||
|
import { sanitizeUser } from '../../../utils/sanitize.js';
|
||||||
|
import { generateSnowflake } from '../../../utils/snowflake.js';
|
||||||
|
import { connectionManager } from '../../../ws/handler.js';
|
||||||
|
import { getDmMessageWithUser } from '../../dm.js';
|
||||||
|
import { and, eq, isNull, or } from 'drizzle-orm';
|
||||||
|
import type { FederationRelayEvent } from '@backspace/shared';
|
||||||
|
import { buildDmChannelPayload, buildDmMessagePayload, findOrCreateDmChannel, isUrlFromPeer, resolveLocalDmMessage } from '../dmChannels.js';
|
||||||
|
import { extractDomain, resolveLocalUser, resolveOrCreateReplicatedUser, verifyAttribution } from '../identity.js';
|
||||||
|
import { hydrateReplicatedUserProfile } from '../profile.js';
|
||||||
|
|
||||||
|
export async function processCreateEvent(
|
||||||
|
event: FederationRelayEvent,
|
||||||
|
sourceInstance: string,
|
||||||
|
peerOrigin: string,
|
||||||
|
db: ReturnType<typeof getDb>,
|
||||||
|
accepted: string[],
|
||||||
|
rejected: Array<{ messageId: string; reason: string }>,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!event.message) {
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'missing_message_payload' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!event.participants || event.participants.length < 2) {
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'missing_participants' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attribution: message author must belong to source instance (FED-010)
|
||||||
|
if (!verifyAttribution(event.message.homeInstance, sourceInstance)) {
|
||||||
|
console.warn(`[federation] Attribution mismatch in create: message homeInstance=${extractDomain(event.message.homeInstance)} source=${extractDomain(sourceInstance)}`);
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'attribution_mismatch' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dedup: check for existing message with same source
|
||||||
|
const existingMsg = db
|
||||||
|
.select()
|
||||||
|
.from(schema.dmMessages)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.dmMessages.sourceInstance, sourceInstance),
|
||||||
|
eq(schema.dmMessages.sourceMessageId, event.messageId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (existingMsg) {
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'duplicate' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve ALL participants to local users, auto-creating replicated stubs
|
||||||
|
// for remote users that don't have a local record yet. This ensures 1-on-1
|
||||||
|
// federated DMs work even when the remote user hasn't connected or friended.
|
||||||
|
const resolvedParticipants: Array<{
|
||||||
|
localUser: typeof schema.users.$inferSelect;
|
||||||
|
homeUserId: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (const p of event.participants) {
|
||||||
|
let localUser = resolveOrCreateReplicatedUser(p.homeUserId, p.homeInstance, db, { username: p.profile?.username, status: p.profile?.status, deleted: p.profile?.deleted });
|
||||||
|
// Skip deleted identities — don't include tombstoned users in the DM
|
||||||
|
if (!localUser) continue;
|
||||||
|
// Hydrate with profile data from the relay event (displayName, avatar, etc.)
|
||||||
|
if (p.profile) {
|
||||||
|
localUser = await hydrateReplicatedUserProfile(localUser, p.profile, db);
|
||||||
|
}
|
||||||
|
resolvedParticipants.push({ localUser, homeUserId: p.homeUserId });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolvedParticipants.length < 2) {
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'participant_not_found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the author among the resolved participants
|
||||||
|
const authorEntry = resolvedParticipants.find(
|
||||||
|
p => p.homeUserId === event.message!.homeUserId,
|
||||||
|
);
|
||||||
|
if (!authorEntry) {
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'author_not_found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const authorUser = authorEntry.localUser;
|
||||||
|
|
||||||
|
// Resolve local DM channel: group DMs carry a federatedId and the channel
|
||||||
|
// must already exist (bootstrapped by a prior member_add event); 1-on-1 DMs
|
||||||
|
// are computed from the pair of home user IDs and created on demand.
|
||||||
|
let localDmChannelId: string;
|
||||||
|
|
||||||
|
if (event.federatedId) {
|
||||||
|
// Group DM: look up by federated_id (channel must already exist from member_add bootstrap)
|
||||||
|
const channel = db
|
||||||
|
.select()
|
||||||
|
.from(schema.dmChannels)
|
||||||
|
.where(and(
|
||||||
|
eq(schema.dmChannels.federatedId, event.federatedId),
|
||||||
|
isNull(schema.dmChannels.deletedAt),
|
||||||
|
))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!channel) {
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'channel_not_found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
localDmChannelId = channel.id;
|
||||||
|
} else {
|
||||||
|
// 1-on-1 DM: compute federated_id from pair and find/create channel
|
||||||
|
const federatedId = computeFederatedId(
|
||||||
|
resolvedParticipants[0]!.homeUserId,
|
||||||
|
resolvedParticipants[1]!.homeUserId,
|
||||||
|
);
|
||||||
|
localDmChannelId = findOrCreateDmChannel(
|
||||||
|
federatedId,
|
||||||
|
[resolvedParticipants[0]!.localUser.id, resolvedParticipants[1]!.localUser.id],
|
||||||
|
db,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert the message
|
||||||
|
const localMessageId = generateSnowflake();
|
||||||
|
db.insert(schema.dmMessages)
|
||||||
|
.values({
|
||||||
|
id: localMessageId,
|
||||||
|
dmChannelId: localDmChannelId,
|
||||||
|
userId: authorUser.id,
|
||||||
|
content: event.message.content,
|
||||||
|
type: event.message.type === 'system' ? 'system' : 'user',
|
||||||
|
replyToId: null,
|
||||||
|
createdAt: event.message.createdAt,
|
||||||
|
editedAt: null,
|
||||||
|
sourceInstance,
|
||||||
|
sourceMessageId: event.messageId,
|
||||||
|
encryptionVersion: 0,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// Create attachment rows and queue file downloads (SSRF-validated).
|
||||||
|
// Attachment rows are created immediately with filename = sourceUrl so the
|
||||||
|
// initial WebSocket broadcast includes working remote URLs. The background
|
||||||
|
// file worker will UPDATE the filename to the local path after download.
|
||||||
|
if (event.message.attachments && event.message.attachments.length > 0) {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const attachment of event.message.attachments) {
|
||||||
|
if (!isUrlFromPeer(attachment.sourceUrl, peerOrigin)) {
|
||||||
|
console.warn(
|
||||||
|
`[federation-relay] Rejecting attachment URL ${attachment.sourceUrl} — hostname does not match peer ${peerOrigin}`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the attachment row with sourceUrl as the interim filename.
|
||||||
|
// AttachmentRenderer already handles filenames starting with 'http' —
|
||||||
|
// it uses them as direct URLs. When the file worker downloads the file,
|
||||||
|
// it updates this row's filename to the local path.
|
||||||
|
const attachmentId = generateSnowflake();
|
||||||
|
db.insert(schema.attachments)
|
||||||
|
.values({
|
||||||
|
id: attachmentId,
|
||||||
|
dmMessageId: localMessageId,
|
||||||
|
uploaderId: null,
|
||||||
|
filename: attachment.sourceUrl,
|
||||||
|
originalName: attachment.originalName,
|
||||||
|
mimetype: attachment.mimetype,
|
||||||
|
size: attachment.size,
|
||||||
|
width: attachment.width ?? null,
|
||||||
|
height: attachment.height ?? null,
|
||||||
|
duration: attachment.duration ?? null,
|
||||||
|
playable: attachment.playable ?? null,
|
||||||
|
thumbnailFilename: null, // Don't copy source thumbnail — it doesn't exist locally
|
||||||
|
sourceUrl: attachment.sourceUrl,
|
||||||
|
createdAt: now,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// Queue the background file download
|
||||||
|
db.insert(schema.federationFileQueue)
|
||||||
|
.values({
|
||||||
|
id: generateSnowflake(),
|
||||||
|
peerOrigin,
|
||||||
|
dmMessageId: localMessageId,
|
||||||
|
sourceUrl: attachment.sourceUrl,
|
||||||
|
originalName: attachment.originalName,
|
||||||
|
mimetype: attachment.mimetype,
|
||||||
|
size: attachment.size,
|
||||||
|
status: 'pending',
|
||||||
|
nextRetryAt: now,
|
||||||
|
expiresAt: now + 30 * 86_400_000,
|
||||||
|
createdAt: now,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast to local WebSocket clients, but skip members whose home instance
|
||||||
|
// is the source instance — they already have the original message via their
|
||||||
|
// home instance's WebSocket connection.
|
||||||
|
const fullMessage = getDmMessageWithUser(localMessageId);
|
||||||
|
if (fullMessage) {
|
||||||
|
const dmMembers = db.select()
|
||||||
|
.from(schema.dmMembers)
|
||||||
|
.where(eq(schema.dmMembers.dmChannelId, localDmChannelId))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
for (const member of dmMembers) {
|
||||||
|
// If the member closed this DM, reopen it and send dm_channel_created
|
||||||
|
// so the sidebar resurfaces before the message arrives.
|
||||||
|
if (member.closed === 1) {
|
||||||
|
db.update(schema.dmMembers)
|
||||||
|
.set({ closed: 0 })
|
||||||
|
.where(and(
|
||||||
|
eq(schema.dmMembers.dmChannelId, localDmChannelId),
|
||||||
|
eq(schema.dmMembers.userId, member.userId),
|
||||||
|
))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
const payload = buildDmChannelPayload(localDmChannelId, db, fullMessage);
|
||||||
|
if (payload) {
|
||||||
|
connectionManager.sendToUser(member.userId, {
|
||||||
|
type: 'dm_channel_created',
|
||||||
|
dmChannel: payload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
connectionManager.sendToUser(member.userId, {
|
||||||
|
type: 'dm_message_created',
|
||||||
|
message: fullMessage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Belt-and-suspenders: clear typing indicator for the author on inbound relay.
|
||||||
|
// This catches the case where the explicit dm_typing_stop relay was lost.
|
||||||
|
const relayDmMembers = db.select()
|
||||||
|
.from(schema.dmMembers)
|
||||||
|
.where(eq(schema.dmMembers.dmChannelId, localDmChannelId))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
for (const member of relayDmMembers) {
|
||||||
|
if (member.userId !== authorUser.id) {
|
||||||
|
connectionManager.sendToUser(member.userId, {
|
||||||
|
type: 'dm_typing_stop',
|
||||||
|
dmChannelId: localDmChannelId,
|
||||||
|
userId: authorUser.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function processUpdateEvent(
|
||||||
|
event: FederationRelayEvent,
|
||||||
|
sourceInstance: string,
|
||||||
|
db: ReturnType<typeof getDb>,
|
||||||
|
accepted: string[],
|
||||||
|
rejected: Array<{ messageId: string; reason: string }>,
|
||||||
|
): void {
|
||||||
|
// Attribution: if homeInstance present, verify it matches source (FED-010)
|
||||||
|
if (event.message?.homeInstance && !verifyAttribution(event.message.homeInstance, sourceInstance)) {
|
||||||
|
console.warn(`[federation] Attribution mismatch in update: message homeInstance=${extractDomain(event.message.homeInstance)} source=${extractDomain(sourceInstance)}`);
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'attribution_mismatch' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const localMsg = db
|
||||||
|
.select()
|
||||||
|
.from(schema.dmMessages)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.dmMessages.sourceInstance, sourceInstance),
|
||||||
|
eq(schema.dmMessages.sourceMessageId, event.messageId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!localMsg) {
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'unknown_message' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = event.message?.content ?? null;
|
||||||
|
const editedAt = event.message?.editedAt ?? Date.now();
|
||||||
|
|
||||||
|
db.update(schema.dmMessages)
|
||||||
|
.set({ content, editedAt })
|
||||||
|
.where(eq(schema.dmMessages.id, localMsg.id))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// Broadcast update to local clients
|
||||||
|
const authorUser = db
|
||||||
|
.select()
|
||||||
|
.from(schema.users)
|
||||||
|
.where(eq(schema.users.id, localMsg.userId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (authorUser) {
|
||||||
|
const updatedPayload = buildDmMessagePayload(
|
||||||
|
{
|
||||||
|
id: localMsg.id,
|
||||||
|
dmChannelId: localMsg.dmChannelId,
|
||||||
|
userId: localMsg.userId,
|
||||||
|
content,
|
||||||
|
replyToId: localMsg.replyToId,
|
||||||
|
editedAt,
|
||||||
|
createdAt: localMsg.createdAt,
|
||||||
|
},
|
||||||
|
authorUser,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Re-fetch reactions and attachments for the complete payload
|
||||||
|
const reactions = db
|
||||||
|
.select()
|
||||||
|
.from(schema.dmReactions)
|
||||||
|
.where(eq(schema.dmReactions.dmMessageId, localMsg.id))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const attachments = db
|
||||||
|
.select()
|
||||||
|
.from(schema.attachments)
|
||||||
|
.where(eq(schema.attachments.dmMessageId, localMsg.id))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
updatedPayload.reactions = reactions.map(r => ({
|
||||||
|
id: r.id,
|
||||||
|
messageId: r.dmMessageId,
|
||||||
|
userId: r.userId,
|
||||||
|
emoji: r.emoji,
|
||||||
|
createdAt: r.createdAt,
|
||||||
|
}));
|
||||||
|
|
||||||
|
updatedPayload.attachments = attachments.map(a => ({
|
||||||
|
id: a.id,
|
||||||
|
messageId: a.dmMessageId ?? a.messageId ?? '',
|
||||||
|
filename: a.filename,
|
||||||
|
originalName: a.originalName,
|
||||||
|
mimetype: a.mimetype,
|
||||||
|
size: a.size,
|
||||||
|
thumbnailFilename: a.thumbnailFilename,
|
||||||
|
width: a.width,
|
||||||
|
height: a.height,
|
||||||
|
duration: a.duration,
|
||||||
|
playable: a.playable ?? null,
|
||||||
|
createdAt: a.createdAt,
|
||||||
|
}));
|
||||||
|
|
||||||
|
connectionManager.sendToDmMembers(localMsg.dmChannelId, {
|
||||||
|
type: 'dm_message_updated',
|
||||||
|
message: updatedPayload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function processDeleteEvent(
|
||||||
|
event: FederationRelayEvent,
|
||||||
|
sourceInstance: string,
|
||||||
|
db: ReturnType<typeof getDb>,
|
||||||
|
accepted: string[],
|
||||||
|
rejected: Array<{ messageId: string; reason: string }>,
|
||||||
|
): void {
|
||||||
|
// FED-010: delete is safe by design — lookup scoped to sourceInstance+sourceMessageId
|
||||||
|
const localMsg = db
|
||||||
|
.select()
|
||||||
|
.from(schema.dmMessages)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.dmMessages.sourceInstance, sourceInstance),
|
||||||
|
eq(schema.dmMessages.sourceMessageId, event.messageId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!localMsg) {
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'unknown_message' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect attachment filenames before deletion for disk cleanup
|
||||||
|
const attachmentRows = db
|
||||||
|
.select({ filename: schema.attachments.filename })
|
||||||
|
.from(schema.attachments)
|
||||||
|
.where(eq(schema.attachments.dmMessageId, localMsg.id))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
// Delete attachments, reactions, and message atomically
|
||||||
|
db.transaction((tx) => {
|
||||||
|
tx.delete(schema.attachments)
|
||||||
|
.where(eq(schema.attachments.dmMessageId, localMsg.id))
|
||||||
|
.run();
|
||||||
|
tx.delete(schema.dmReactions)
|
||||||
|
.where(eq(schema.dmReactions.dmMessageId, localMsg.id))
|
||||||
|
.run();
|
||||||
|
tx.delete(schema.dmMessages)
|
||||||
|
.where(eq(schema.dmMessages.id, localMsg.id))
|
||||||
|
.run();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean up files from disk
|
||||||
|
deleteAttachmentFiles(attachmentRows);
|
||||||
|
|
||||||
|
// Broadcast deletion to local clients
|
||||||
|
connectionManager.sendToDmMembers(localMsg.dmChannelId, {
|
||||||
|
type: 'dm_message_deleted',
|
||||||
|
messageId: localMsg.id,
|
||||||
|
dmChannelId: localMsg.dmChannelId,
|
||||||
|
});
|
||||||
|
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function processReactionAddEvent(
|
||||||
|
event: FederationRelayEvent,
|
||||||
|
sourceInstance: string,
|
||||||
|
db: ReturnType<typeof getDb>,
|
||||||
|
accepted: string[],
|
||||||
|
rejected: Array<{ messageId: string; reason: string }>,
|
||||||
|
): void {
|
||||||
|
if (!event.reaction) {
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'missing_reaction_payload' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attribution: reacting user must belong to source instance (FED-010)
|
||||||
|
if (!event.reaction.homeInstance || !verifyAttribution(event.reaction.homeInstance, sourceInstance)) {
|
||||||
|
console.warn(`[federation] Attribution mismatch in reaction_add: reaction homeInstance=${event.reaction.homeInstance ? extractDomain(event.reaction.homeInstance) : 'missing'} source=${extractDomain(sourceInstance)}`);
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'attribution_mismatch' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canonicalMessageId = event.reaction.messageId ?? event.messageId;
|
||||||
|
const localMsg = resolveLocalDmMessage(
|
||||||
|
canonicalMessageId,
|
||||||
|
event.reaction.messageHomeInstance,
|
||||||
|
sourceInstance,
|
||||||
|
db,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!localMsg) {
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'unknown_message' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the reacting user
|
||||||
|
const reactingUser = resolveLocalUser(event.reaction.homeUserId, db);
|
||||||
|
if (!reactingUser) {
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'user_not_found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dedup: check if this user already reacted with this emoji
|
||||||
|
const existingReaction = db
|
||||||
|
.select()
|
||||||
|
.from(schema.dmReactions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.dmReactions.dmMessageId, localMsg.id),
|
||||||
|
eq(schema.dmReactions.userId, reactingUser.id),
|
||||||
|
eq(schema.dmReactions.emoji, event.reaction.emoji),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (existingReaction) {
|
||||||
|
// Already exists — treat as accepted (idempotent)
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reactionId = generateSnowflake();
|
||||||
|
const now = event.reaction.createdAt || Date.now();
|
||||||
|
|
||||||
|
db.insert(schema.dmReactions)
|
||||||
|
.values({
|
||||||
|
id: reactionId,
|
||||||
|
dmMessageId: localMsg.id,
|
||||||
|
userId: reactingUser.id,
|
||||||
|
emoji: event.reaction.emoji,
|
||||||
|
createdAt: now,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// Broadcast to local clients
|
||||||
|
connectionManager.sendToDmMembers(localMsg.dmChannelId, {
|
||||||
|
type: 'reaction_added',
|
||||||
|
messageId: localMsg.id,
|
||||||
|
reaction: {
|
||||||
|
id: reactionId,
|
||||||
|
messageId: localMsg.id,
|
||||||
|
userId: reactingUser.id,
|
||||||
|
emoji: event.reaction.emoji,
|
||||||
|
createdAt: now,
|
||||||
|
user: sanitizeUser(reactingUser),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function processReactionRemoveEvent(
|
||||||
|
event: FederationRelayEvent,
|
||||||
|
sourceInstance: string,
|
||||||
|
db: ReturnType<typeof getDb>,
|
||||||
|
accepted: string[],
|
||||||
|
rejected: Array<{ messageId: string; reason: string }>,
|
||||||
|
): void {
|
||||||
|
if (!event.reaction) {
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'missing_reaction_payload' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attribution: reacting user must belong to source instance (FED-010)
|
||||||
|
if (!event.reaction.homeInstance || !verifyAttribution(event.reaction.homeInstance, sourceInstance)) {
|
||||||
|
console.warn(`[federation] Attribution mismatch in reaction_remove: reaction homeInstance=${event.reaction.homeInstance ? extractDomain(event.reaction.homeInstance) : 'missing'} source=${extractDomain(sourceInstance)}`);
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'attribution_mismatch' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canonicalMessageId = event.reaction.messageId ?? event.messageId;
|
||||||
|
const localMsg = resolveLocalDmMessage(
|
||||||
|
canonicalMessageId,
|
||||||
|
event.reaction.messageHomeInstance,
|
||||||
|
sourceInstance,
|
||||||
|
db,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!localMsg) {
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'unknown_message' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the reacting user
|
||||||
|
const reactingUser = resolveLocalUser(event.reaction.homeUserId, db);
|
||||||
|
if (!reactingUser) {
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'user_not_found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = db
|
||||||
|
.delete(schema.dmReactions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.dmReactions.dmMessageId, localMsg.id),
|
||||||
|
eq(schema.dmReactions.userId, reactingUser.id),
|
||||||
|
eq(schema.dmReactions.emoji, event.reaction.emoji),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
|
||||||
|
if (result.changes > 0) {
|
||||||
|
connectionManager.sendToDmMembers(localMsg.dmChannelId, {
|
||||||
|
type: 'reaction_removed',
|
||||||
|
messageId: localMsg.id,
|
||||||
|
userId: reactingUser.id,
|
||||||
|
emoji: event.reaction.emoji,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Membership mutation processors ──────────────────────────────────────────
|
||||||
@@ -0,0 +1,464 @@
|
|||||||
|
import { getDb, schema } from '../../../db/index.js';
|
||||||
|
import { getOurOrigin } from '../../../utils/federationAuth.js';
|
||||||
|
import { collectProfileBroadcastTargetIds } from '../../../utils/userDeletion.js';
|
||||||
|
import { connectionManager } from '../../../ws/handler.js';
|
||||||
|
import { getDmMessageWithUser } from '../../dm.js';
|
||||||
|
import { and, eq, isNull } from 'drizzle-orm';
|
||||||
|
import type { FederationRelayEvent } from '@backspace/shared';
|
||||||
|
import { buildDmChannelPayload } from '../dmChannels.js';
|
||||||
|
import { extractDomain, resolveLocalUser } from '../identity.js';
|
||||||
|
|
||||||
|
export function processFileRejectedEvent(
|
||||||
|
event: FederationRelayEvent,
|
||||||
|
sourceInstance: string,
|
||||||
|
db: ReturnType<typeof getDb>,
|
||||||
|
accepted: string[],
|
||||||
|
rejected: Array<{ messageId: string; reason: string }>,
|
||||||
|
): void {
|
||||||
|
// FED-010: file_rejected is a system event from the rejecting peer — no user attribution to verify
|
||||||
|
if (!event.attachmentId || !event.rejectionReason) {
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'missing_file_rejected_payload' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// event.messageId is the original local message ID on THIS (sender) instance
|
||||||
|
const localMsg = db.select()
|
||||||
|
.from(schema.dmMessages)
|
||||||
|
.where(eq(schema.dmMessages.id, event.messageId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!localMsg) {
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'message_not_found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the attachment — try by sourceUrl matching, then by checking all attachments on the message
|
||||||
|
const messageAttachments = db.select()
|
||||||
|
.from(schema.attachments)
|
||||||
|
.where(eq(schema.attachments.dmMessageId, localMsg.id))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
// Match by filename from the sourceUrl — the remote sends the filename portion
|
||||||
|
// (e.g., "12345.png") which matches our local attachment's filename.
|
||||||
|
let matchedAttachment = event.sourceFilename
|
||||||
|
? messageAttachments.find(a => a.filename === event.sourceFilename)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// Fallback: if only one attachment, use it directly
|
||||||
|
if (!matchedAttachment && messageAttachments.length === 1) {
|
||||||
|
matchedAttachment = messageAttachments[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!matchedAttachment) {
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'attachment_not_found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve affected user IDs to local usernames
|
||||||
|
const affectedUsers: Array<{ userId: string; username: string; limit: number }> = [];
|
||||||
|
for (const remoteUserId of (event.affectedUserIds ?? [])) {
|
||||||
|
// These are homeUserIds — find the replicated user stub
|
||||||
|
const user = db.select()
|
||||||
|
.from(schema.users)
|
||||||
|
.where(eq(schema.users.homeUserId, remoteUserId))
|
||||||
|
.get();
|
||||||
|
if (user) {
|
||||||
|
affectedUsers.push({
|
||||||
|
userId: user.id,
|
||||||
|
username: user.displayName || user.username,
|
||||||
|
limit: event.rejectionLimit ?? 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (affectedUsers.length === 0) {
|
||||||
|
// Fallback: if we can't resolve usernames, still accept the event
|
||||||
|
// but skip the UI update since we can't show meaningful info
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge into federation_meta — accumulate rejections from multiple peers
|
||||||
|
let existingMeta: Array<{ userId: string; username: string; limit: number }> = [];
|
||||||
|
if (matchedAttachment.federationMeta) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(matchedAttachment.federationMeta);
|
||||||
|
existingMeta = Array.isArray(parsed) ? parsed : [];
|
||||||
|
} catch { /* ignore parse errors */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new affected users, avoiding duplicates by userId
|
||||||
|
const existingUserIds = new Set(existingMeta.map(u => u.userId));
|
||||||
|
for (const user of affectedUsers) {
|
||||||
|
if (!existingUserIds.has(user.userId)) {
|
||||||
|
existingMeta.push(user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update attachment
|
||||||
|
db.update(schema.attachments)
|
||||||
|
.set({
|
||||||
|
federationStatus: 'remote_partial',
|
||||||
|
federationMeta: JSON.stringify(existingMeta),
|
||||||
|
})
|
||||||
|
.where(eq(schema.attachments.id, matchedAttachment.id))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// Broadcast dm_message_updated to all DM members (persistent indicator)
|
||||||
|
const updatedMsg = getDmMessageWithUser(localMsg.id);
|
||||||
|
if (updatedMsg) {
|
||||||
|
connectionManager.sendToDmMembers(updatedMsg.dmChannelId, {
|
||||||
|
type: 'dm_message_updated',
|
||||||
|
message: updatedMsg,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send targeted toast event to the message author only
|
||||||
|
connectionManager.sendToUser(localMsg.userId, {
|
||||||
|
type: 'federation_file_rejected',
|
||||||
|
messageId: localMsg.id,
|
||||||
|
dmChannelId: localMsg.dmChannelId,
|
||||||
|
attachmentId: matchedAttachment.id,
|
||||||
|
affectedUsers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── DM Call Relay Processors ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inbound presence_update relay handler.
|
||||||
|
*
|
||||||
|
* Authority: home instance is exclusive. payload.homeInstance domain MUST equal
|
||||||
|
* the source peer's domain (attribution check, mirrors profile_update).
|
||||||
|
*
|
||||||
|
* Effect on success:
|
||||||
|
* 1. Update the local stub's status column.
|
||||||
|
* 2. Broadcast a WS presence_update to local users via collectProfileBroadcastTargetIds
|
||||||
|
* (friends + DM members + space co-members), so the green dot updates without a
|
||||||
|
* page refresh on every connected client that knows this user.
|
||||||
|
*
|
||||||
|
* Edge cases:
|
||||||
|
* - No local replica → silently accept (peer broadcasts presence to all peers,
|
||||||
|
* not all peers have a stub).
|
||||||
|
* - homeInstance domain mismatch on the existing stub → ignore (collision against
|
||||||
|
* a stub of a different identity).
|
||||||
|
* - Invalid status string → reject; sender is buggy, surface for diagnosis.
|
||||||
|
*/
|
||||||
|
export function processPresenceUpdateEvent(
|
||||||
|
event: FederationRelayEvent,
|
||||||
|
sourceInstance: string,
|
||||||
|
db: ReturnType<typeof getDb>,
|
||||||
|
accepted: string[],
|
||||||
|
rejected: Array<{ messageId: string; reason: string }>,
|
||||||
|
): void {
|
||||||
|
const payload = event.presenceUpdate;
|
||||||
|
if (!payload) {
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'missing_presence_update_payload' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadDomain = extractDomain(payload.homeInstance);
|
||||||
|
const sourceDomain = extractDomain(sourceInstance);
|
||||||
|
if (payloadDomain !== sourceDomain) {
|
||||||
|
console.warn(`[federation] Attribution mismatch in presence_update: homeInstance=${payloadDomain} source=${sourceDomain}`);
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'attribution_mismatch' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!payload.status || !['online', 'idle', 'dnd', 'offline'].includes(payload.status)) {
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'invalid_status' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const localUser = db
|
||||||
|
.select()
|
||||||
|
.from(schema.users)
|
||||||
|
.where(and(
|
||||||
|
eq(schema.users.homeUserId, payload.homeUserId),
|
||||||
|
eq(schema.users.isDeleted, 0),
|
||||||
|
))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!localUser) {
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (localUser.homeInstance && extractDomain(localUser.homeInstance) !== payloadDomain) {
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detached accounts are sovereign: the domain now belongs to a different
|
||||||
|
// incarnation, which must never flip the established account's presence by
|
||||||
|
// replaying its old homeUserId. Ack (not reject) — the sender considers this
|
||||||
|
// identity theirs to update; from our side the update simply no-ops.
|
||||||
|
if (localUser.federationHomeOrphaned === 1) {
|
||||||
|
console.log(`[federation] Skipping presence_update for detached account ${localUser.id} (home-orphaned)`);
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.update(schema.users)
|
||||||
|
.set({ status: payload.status })
|
||||||
|
.where(eq(schema.users.id, localUser.id))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// Broadcast presence_update WS event to local users who care.
|
||||||
|
const targetUserIds = collectProfileBroadcastTargetIds(localUser.id);
|
||||||
|
const wsPayload = {
|
||||||
|
type: 'presence_update' as const,
|
||||||
|
userId: localUser.id,
|
||||||
|
status: payload.status,
|
||||||
|
...(payload.activities && payload.activities.length > 0 ? { activities: payload.activities } : {}),
|
||||||
|
};
|
||||||
|
for (const uid of targetUserIds) {
|
||||||
|
connectionManager.sendToUser(uid, wsPayload);
|
||||||
|
}
|
||||||
|
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Dead-Incarnation Startup Sweep ─────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
export function processReadStateUpdateEvent(
|
||||||
|
event: FederationRelayEvent,
|
||||||
|
sourceInstance: string,
|
||||||
|
db: ReturnType<typeof getDb>,
|
||||||
|
accepted: string[],
|
||||||
|
rejected: Array<{ messageId: string; reason: string }>,
|
||||||
|
): void {
|
||||||
|
if (!event.federatedId || !event.readState) {
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'missing_read_state_payload' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the local DM channel by federatedId
|
||||||
|
const channel = db.select({ id: schema.dmChannels.id })
|
||||||
|
.from(schema.dmChannels)
|
||||||
|
.where(and(
|
||||||
|
eq(schema.dmChannels.federatedId, event.federatedId),
|
||||||
|
isNull(schema.dmChannels.deletedAt),
|
||||||
|
))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!channel) {
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'channel_not_found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the user locally
|
||||||
|
const localUser = resolveLocalUser(event.readState.user.homeUserId, db);
|
||||||
|
if (!localUser) {
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'user_not_found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Translate messageRef to a local message ID
|
||||||
|
const { sourceInstance: refSource, sourceMessageId: refId } = event.readState.messageRef;
|
||||||
|
let localMessageId: string;
|
||||||
|
|
||||||
|
const ourOrigin = getOurOrigin();
|
||||||
|
if (extractDomain(refSource) === extractDomain(ourOrigin)) {
|
||||||
|
// The message originated on this instance — refId IS our local ID
|
||||||
|
localMessageId = refId;
|
||||||
|
} else {
|
||||||
|
// Look up the relayed copy by source coordinates
|
||||||
|
const localMsg = db.select({ id: schema.dmMessages.id })
|
||||||
|
.from(schema.dmMessages)
|
||||||
|
.where(and(
|
||||||
|
eq(schema.dmMessages.sourceInstance, refSource),
|
||||||
|
eq(schema.dmMessages.sourceMessageId, refId),
|
||||||
|
))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!localMsg) {
|
||||||
|
// Message relay hasn't arrived yet — silently discard
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
localMessageId = localMsg.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write/update read state using timestamp-only LWW
|
||||||
|
const existing = db.select()
|
||||||
|
.from(schema.readStates)
|
||||||
|
.where(and(
|
||||||
|
eq(schema.readStates.userId, localUser.id),
|
||||||
|
eq(schema.readStates.channelId, channel.id),
|
||||||
|
))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
if (event.timestamp > existing.updatedAt) {
|
||||||
|
db.update(schema.readStates)
|
||||||
|
.set({ lastReadMessageId: localMessageId, updatedAt: event.timestamp })
|
||||||
|
.where(and(
|
||||||
|
eq(schema.readStates.userId, localUser.id),
|
||||||
|
eq(schema.readStates.channelId, channel.id),
|
||||||
|
))
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
db.insert(schema.readStates).values({
|
||||||
|
userId: localUser.id,
|
||||||
|
channelId: channel.id,
|
||||||
|
lastReadMessageId: localMessageId,
|
||||||
|
updatedAt: event.timestamp,
|
||||||
|
}).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Echo channel_ack to the user's local WebSocket connections (multi-tab sync)
|
||||||
|
connectionManager.sendToUser(localUser.id, {
|
||||||
|
type: 'channel_ack',
|
||||||
|
channelId: channel.id,
|
||||||
|
messageId: localMessageId,
|
||||||
|
});
|
||||||
|
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function processDmCloseEvent(
|
||||||
|
event: FederationRelayEvent,
|
||||||
|
sourceInstance: string,
|
||||||
|
db: ReturnType<typeof getDb>,
|
||||||
|
accepted: string[],
|
||||||
|
rejected: Array<{ messageId: string; reason: string }>,
|
||||||
|
): void {
|
||||||
|
if (!event.federatedId || !event.dmCloseReopen) {
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'missing_dm_close_payload' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the local DM channel by federatedId
|
||||||
|
const channel = db.select({ id: schema.dmChannels.id })
|
||||||
|
.from(schema.dmChannels)
|
||||||
|
.where(and(
|
||||||
|
eq(schema.dmChannels.federatedId, event.federatedId),
|
||||||
|
isNull(schema.dmChannels.deletedAt),
|
||||||
|
))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!channel) {
|
||||||
|
// Channel doesn't exist locally — silently accept
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the user locally
|
||||||
|
const localUser = resolveLocalUser(event.dmCloseReopen.homeUserId, db);
|
||||||
|
if (!localUser) {
|
||||||
|
// User not found locally — silently accept
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify user is a DM member
|
||||||
|
const membership = db.select()
|
||||||
|
.from(schema.dmMembers)
|
||||||
|
.where(and(
|
||||||
|
eq(schema.dmMembers.dmChannelId, channel.id),
|
||||||
|
eq(schema.dmMembers.userId, localUser.id),
|
||||||
|
))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!membership) {
|
||||||
|
// Not a member — silently accept
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set closed = 1
|
||||||
|
db.update(schema.dmMembers)
|
||||||
|
.set({ closed: 1 })
|
||||||
|
.where(and(
|
||||||
|
eq(schema.dmMembers.dmChannelId, channel.id),
|
||||||
|
eq(schema.dmMembers.userId, localUser.id),
|
||||||
|
))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// Broadcast dm_channel_closed to local connections of this user
|
||||||
|
connectionManager.sendToUser(localUser.id, {
|
||||||
|
type: 'dm_channel_closed',
|
||||||
|
dmChannelId: channel.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function processDmReopenEvent(
|
||||||
|
event: FederationRelayEvent,
|
||||||
|
sourceInstance: string,
|
||||||
|
db: ReturnType<typeof getDb>,
|
||||||
|
accepted: string[],
|
||||||
|
rejected: Array<{ messageId: string; reason: string }>,
|
||||||
|
): void {
|
||||||
|
if (!event.federatedId || !event.dmCloseReopen) {
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'missing_dm_reopen_payload' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the local DM channel by federatedId
|
||||||
|
const channel = db.select({ id: schema.dmChannels.id })
|
||||||
|
.from(schema.dmChannels)
|
||||||
|
.where(and(
|
||||||
|
eq(schema.dmChannels.federatedId, event.federatedId),
|
||||||
|
isNull(schema.dmChannels.deletedAt),
|
||||||
|
))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!channel) {
|
||||||
|
// Channel doesn't exist locally — silently accept
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the user locally
|
||||||
|
const localUser = resolveLocalUser(event.dmCloseReopen.homeUserId, db);
|
||||||
|
if (!localUser) {
|
||||||
|
// User not found locally — silently accept
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify user is a DM member
|
||||||
|
const membership = db.select()
|
||||||
|
.from(schema.dmMembers)
|
||||||
|
.where(and(
|
||||||
|
eq(schema.dmMembers.dmChannelId, channel.id),
|
||||||
|
eq(schema.dmMembers.userId, localUser.id),
|
||||||
|
))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!membership) {
|
||||||
|
// Not a member — silently accept
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set closed = 0
|
||||||
|
db.update(schema.dmMembers)
|
||||||
|
.set({ closed: 0 })
|
||||||
|
.where(and(
|
||||||
|
eq(schema.dmMembers.dmChannelId, channel.id),
|
||||||
|
eq(schema.dmMembers.userId, localUser.id),
|
||||||
|
))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// Build full DM channel payload and broadcast dm_channel_created
|
||||||
|
const payload = buildDmChannelPayload(channel.id, db);
|
||||||
|
if (payload) {
|
||||||
|
connectionManager.sendToUser(localUser.id, {
|
||||||
|
type: 'dm_channel_created',
|
||||||
|
dmChannel: payload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,433 @@
|
|||||||
|
import { getDb, schema } from '../../../db/index.js';
|
||||||
|
import { getOurOrigin, normalizeOriginForCompare } from '../../../utils/federationAuth.js';
|
||||||
|
import { sanitizeUser } from '../../../utils/sanitize.js';
|
||||||
|
import { generateSnowflake } from '../../../utils/snowflake.js';
|
||||||
|
import { connectionManager } from '../../../ws/handler.js';
|
||||||
|
import { and, eq, or } from 'drizzle-orm';
|
||||||
|
import type { FederationRelayEvent } from '@backspace/shared';
|
||||||
|
import { extractDomain, resolveLocalUser, resolveOrCreateReplicatedUser, verifyAttribution } from '../identity.js';
|
||||||
|
import { hydrateReplicatedUserProfile } from '../profile.js';
|
||||||
|
|
||||||
|
export async function processFriendRequestCreateEvent(
|
||||||
|
event: FederationRelayEvent,
|
||||||
|
sourceInstance: string,
|
||||||
|
db: ReturnType<typeof getDb>,
|
||||||
|
accepted: string[],
|
||||||
|
rejected: Array<{ messageId: string; reason: string }>,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!event.friendship) {
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'missing_friendship_payload' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { from, to } = event.friendship;
|
||||||
|
|
||||||
|
// Attribution: sender must belong to source instance (FED-010)
|
||||||
|
if (!verifyAttribution(from.homeInstance, sourceInstance)) {
|
||||||
|
console.warn(`[federation] Attribution mismatch in friend_request_create: from homeInstance=${extractDomain(from.homeInstance)} source=${extractDomain(sourceInstance)}`);
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'attribution_mismatch' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Self-target guard (defense-in-depth): from-identity must not equal to-identity.
|
||||||
|
// Sender's local cannot_friend_self check should catch this, but the receiver must not trust it.
|
||||||
|
if (
|
||||||
|
from.homeUserId === to.homeUserId &&
|
||||||
|
normalizeOriginForCompare(from.homeInstance) === normalizeOriginForCompare(to.homeInstance)
|
||||||
|
) {
|
||||||
|
console.warn(`[federation] Self-target friend_request_create rejected: homeUserId=${from.homeUserId} homeInstance=${extractDomain(from.homeInstance)} source=${extractDomain(sourceInstance)}`);
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'self_target_invalid' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the sender (create stub if needed — they're on a remote instance)
|
||||||
|
const fromUserResolved = resolveOrCreateReplicatedUser(from.homeUserId, from.homeInstance, db, { username: event.friendship.fromProfile?.username, status: event.friendship.fromProfile?.status, deleted: event.friendship.fromProfile?.deleted });
|
||||||
|
if (!fromUserResolved) {
|
||||||
|
// Sender's identity has been deleted — silently accept to drop the event
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let fromUser = await hydrateReplicatedUserProfile(fromUserResolved, event.friendship.fromProfile, db);
|
||||||
|
|
||||||
|
// Resolve the recipient — must be a local user on this instance
|
||||||
|
const toUser = resolveLocalUser(to.homeUserId, db);
|
||||||
|
if (!toUser) {
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'recipient_not_found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idempotency: if already friends, accept as no-op
|
||||||
|
const existingFriend = db
|
||||||
|
.select()
|
||||||
|
.from(schema.friends)
|
||||||
|
.where(
|
||||||
|
or(
|
||||||
|
and(eq(schema.friends.userId, fromUser.id), eq(schema.friends.friendId, toUser.id)),
|
||||||
|
and(eq(schema.friends.userId, toUser.id), eq(schema.friends.friendId, fromUser.id)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (existingFriend) {
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idempotency: a pending request in EITHER direction makes this event a no-op.
|
||||||
|
// Forward (from→to): re-delivery of an event we've already processed.
|
||||||
|
// Reverse (to→from): the local user has already sent a request TO this remote sender.
|
||||||
|
// Race window: both sides click "add friend" near-simultaneously. Each sender's both-direction
|
||||||
|
// check passes locally (no rows yet anywhere). When the events cross, each receiver must
|
||||||
|
// treat the reverse-direction collision as idempotent — otherwise both instances end up
|
||||||
|
// with two opposite-direction pending rows for the same logical pair. Mirror the
|
||||||
|
// sender-side both-direction check (`incoming_request_exists` in social.ts).
|
||||||
|
const existingRequest = db
|
||||||
|
.select()
|
||||||
|
.from(schema.friendRequests)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
or(
|
||||||
|
and(eq(schema.friendRequests.fromId, fromUser.id), eq(schema.friendRequests.toId, toUser.id)),
|
||||||
|
and(eq(schema.friendRequests.fromId, toUser.id), eq(schema.friendRequests.toId, fromUser.id)),
|
||||||
|
),
|
||||||
|
eq(schema.friendRequests.status, 'pending'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (existingRequest) {
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the friend request
|
||||||
|
const id = generateSnowflake();
|
||||||
|
const now = event.friendship.createdAt || Date.now();
|
||||||
|
|
||||||
|
db.insert(schema.friendRequests)
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
fromId: fromUser.id,
|
||||||
|
toId: toUser.id,
|
||||||
|
status: 'pending',
|
||||||
|
createdAt: now,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// Broadcast to the recipient
|
||||||
|
connectionManager.sendToUser(toUser.id, {
|
||||||
|
type: 'friend_request_received',
|
||||||
|
request: {
|
||||||
|
id,
|
||||||
|
fromId: fromUser.id,
|
||||||
|
toId: toUser.id,
|
||||||
|
status: 'pending' as const,
|
||||||
|
createdAt: now,
|
||||||
|
user: sanitizeUser(fromUser),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function processFriendRequestUpdateEvent(
|
||||||
|
event: FederationRelayEvent,
|
||||||
|
sourceInstance: string,
|
||||||
|
db: ReturnType<typeof getDb>,
|
||||||
|
accepted: string[],
|
||||||
|
rejected: Array<{ messageId: string; reason: string }>,
|
||||||
|
): void {
|
||||||
|
if (!event.friendship || !event.friendship.status) {
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'missing_friendship_payload' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { from, to, status } = event.friendship;
|
||||||
|
|
||||||
|
// Attribution: recipient (acceptor/decliner) must belong to source instance (FED-010)
|
||||||
|
if (!verifyAttribution(to.homeInstance, sourceInstance)) {
|
||||||
|
console.warn(`[federation] Attribution mismatch in friend_request_update: to homeInstance=${extractDomain(to.homeInstance)} source=${extractDomain(sourceInstance)}`);
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'attribution_mismatch' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the sender — must be a local user (the one who sent the original request)
|
||||||
|
const fromUser = resolveLocalUser(from.homeUserId, db);
|
||||||
|
if (!fromUser) {
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'sender_not_found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the recipient (create stub if needed — they're on the remote instance)
|
||||||
|
const toUser = resolveOrCreateReplicatedUser(to.homeUserId, to.homeInstance, db, { username: event.friendship.toProfile?.username, status: event.friendship.toProfile?.status, deleted: event.friendship.toProfile?.deleted });
|
||||||
|
if (!toUser) {
|
||||||
|
// Recipient's identity has been deleted — accept idempotently to drop the event
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the pending request
|
||||||
|
const pendingRequest = db
|
||||||
|
.select()
|
||||||
|
.from(schema.friendRequests)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.friendRequests.fromId, fromUser.id),
|
||||||
|
eq(schema.friendRequests.toId, toUser.id),
|
||||||
|
eq(schema.friendRequests.status, 'pending'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!pendingRequest) {
|
||||||
|
// Accept idempotently — friend_add may have arrived first
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update request status
|
||||||
|
db.update(schema.friendRequests)
|
||||||
|
.set({ status: status as string })
|
||||||
|
.where(eq(schema.friendRequests.id, pendingRequest.id))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
if (status === 'accepted') {
|
||||||
|
const now = event.friendship.createdAt || Date.now();
|
||||||
|
connectionManager.sendToUser(fromUser.id, {
|
||||||
|
type: 'friend_request_accepted',
|
||||||
|
friend: {
|
||||||
|
...sanitizeUser(toUser),
|
||||||
|
addedAt: now,
|
||||||
|
},
|
||||||
|
requestId: pendingRequest.id,
|
||||||
|
});
|
||||||
|
} else if (status === 'declined') {
|
||||||
|
connectionManager.sendToUser(fromUser.id, {
|
||||||
|
type: 'friend_request_declined',
|
||||||
|
requestId: pendingRequest.id,
|
||||||
|
userId: toUser.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function processFriendRequestCancelEvent(
|
||||||
|
event: FederationRelayEvent,
|
||||||
|
sourceInstance: string,
|
||||||
|
db: ReturnType<typeof getDb>,
|
||||||
|
accepted: string[],
|
||||||
|
rejected: Array<{ messageId: string; reason: string }>,
|
||||||
|
): void {
|
||||||
|
if (!event.friendship) {
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'missing_friendship_payload' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { from, to } = event.friendship;
|
||||||
|
|
||||||
|
// Attribution: sender must belong to source instance (FED-010)
|
||||||
|
if (!verifyAttribution(from.homeInstance, sourceInstance)) {
|
||||||
|
console.warn(`[federation] Attribution mismatch in friend_request_cancel: from homeInstance=${extractDomain(from.homeInstance)} source=${extractDomain(sourceInstance)}`);
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'attribution_mismatch' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve both users — must both exist locally for there to be a pending request
|
||||||
|
const fromUser = resolveLocalUser(from.homeUserId, db);
|
||||||
|
const toUser = resolveLocalUser(to.homeUserId, db);
|
||||||
|
|
||||||
|
if (!fromUser || !toUser) {
|
||||||
|
// Accept idempotently — if either user doesn't exist, there's nothing to cancel
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the pending request
|
||||||
|
const pendingRequest = db
|
||||||
|
.select()
|
||||||
|
.from(schema.friendRequests)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.friendRequests.fromId, fromUser.id),
|
||||||
|
eq(schema.friendRequests.toId, toUser.id),
|
||||||
|
eq(schema.friendRequests.status, 'pending'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!pendingRequest) {
|
||||||
|
// Accept idempotently — already cancelled or never existed
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the request
|
||||||
|
db.delete(schema.friendRequests)
|
||||||
|
.where(eq(schema.friendRequests.id, pendingRequest.id))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// Broadcast to the recipient
|
||||||
|
connectionManager.sendToUser(toUser.id, {
|
||||||
|
type: 'friend_request_cancelled',
|
||||||
|
requestId: pendingRequest.id,
|
||||||
|
userId: fromUser.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export async function processFriendAddEvent(
|
||||||
|
event: FederationRelayEvent,
|
||||||
|
sourceInstance: string,
|
||||||
|
db: ReturnType<typeof getDb>,
|
||||||
|
accepted: string[],
|
||||||
|
rejected: Array<{ messageId: string; reason: string }>,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!event.friendship) {
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'missing_friendship_payload' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { from, to } = event.friendship;
|
||||||
|
|
||||||
|
// Attribution: acceptor must belong to source instance (FED-010)
|
||||||
|
if (!verifyAttribution(to.homeInstance, sourceInstance)) {
|
||||||
|
console.warn(`[federation] Attribution mismatch in friend_add: to homeInstance=${extractDomain(to.homeInstance)} source=${extractDomain(sourceInstance)}`);
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'attribution_mismatch' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve both users (create stubs if needed) and hydrate with profile data
|
||||||
|
const fromUserResolved = resolveOrCreateReplicatedUser(from.homeUserId, from.homeInstance, db, { username: event.friendship.fromProfile?.username, status: event.friendship.fromProfile?.status, deleted: event.friendship.fromProfile?.deleted });
|
||||||
|
if (!fromUserResolved) {
|
||||||
|
// One party's identity is deleted — accept idempotently to drop the event
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let fromUser = await hydrateReplicatedUserProfile(fromUserResolved, event.friendship.fromProfile, db);
|
||||||
|
const toUserResolved = resolveOrCreateReplicatedUser(to.homeUserId, to.homeInstance, db, { username: event.friendship.toProfile?.username, status: event.friendship.toProfile?.status, deleted: event.friendship.toProfile?.deleted });
|
||||||
|
if (!toUserResolved) {
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let toUser = await hydrateReplicatedUserProfile(toUserResolved, event.friendship.toProfile, db);
|
||||||
|
|
||||||
|
// Idempotency: if friendship already exists, accept as no-op
|
||||||
|
const existingFriend = db
|
||||||
|
.select()
|
||||||
|
.from(schema.friends)
|
||||||
|
.where(
|
||||||
|
or(
|
||||||
|
and(eq(schema.friends.userId, fromUser.id), eq(schema.friends.friendId, toUser.id)),
|
||||||
|
and(eq(schema.friends.userId, toUser.id), eq(schema.friends.friendId, fromUser.id)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (existingFriend) {
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert friendship row
|
||||||
|
const now = event.friendship.createdAt || Date.now();
|
||||||
|
db.insert(schema.friends)
|
||||||
|
.values({
|
||||||
|
userId: fromUser.id,
|
||||||
|
friendId: toUser.id,
|
||||||
|
createdAt: now,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// Auto-resolve any pending friend request between these users to 'accepted'
|
||||||
|
// (handles friend_add arriving before friend_request_update)
|
||||||
|
db.update(schema.friendRequests)
|
||||||
|
.set({ status: 'accepted' })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
or(
|
||||||
|
and(eq(schema.friendRequests.fromId, fromUser.id), eq(schema.friendRequests.toId, toUser.id)),
|
||||||
|
and(eq(schema.friendRequests.fromId, toUser.id), eq(schema.friendRequests.toId, fromUser.id)),
|
||||||
|
),
|
||||||
|
eq(schema.friendRequests.status, 'pending'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// Determine which user is local and broadcast to them
|
||||||
|
const ourOrigin = getOurOrigin();
|
||||||
|
const localUser = from.homeInstance === ourOrigin ? fromUser : toUser;
|
||||||
|
const remoteUser = from.homeInstance === ourOrigin ? toUser : fromUser;
|
||||||
|
|
||||||
|
connectionManager.sendToUser(localUser.id, {
|
||||||
|
type: 'friend_request_accepted',
|
||||||
|
friend: {
|
||||||
|
...sanitizeUser(remoteUser),
|
||||||
|
addedAt: now,
|
||||||
|
},
|
||||||
|
// Use empty string for requestId since the request may not exist locally yet
|
||||||
|
requestId: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function processFriendRemoveEvent(
|
||||||
|
event: FederationRelayEvent,
|
||||||
|
sourceInstance: string,
|
||||||
|
db: ReturnType<typeof getDb>,
|
||||||
|
accepted: string[],
|
||||||
|
rejected: Array<{ messageId: string; reason: string }>,
|
||||||
|
): void {
|
||||||
|
if (!event.friendship) {
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'missing_friendship_payload' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { from, to } = event.friendship;
|
||||||
|
|
||||||
|
// Attribution: at least one side must belong to source instance (FED-010)
|
||||||
|
if (!verifyAttribution(from.homeInstance, sourceInstance) && !verifyAttribution(to.homeInstance, sourceInstance)) {
|
||||||
|
console.warn(`[federation] Attribution mismatch in friend_remove: from homeInstance=${extractDomain(from.homeInstance)} to homeInstance=${extractDomain(to.homeInstance)} source=${extractDomain(sourceInstance)}`);
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'attribution_mismatch' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve both users — must both exist locally for there to be a friendship
|
||||||
|
const fromUser = resolveLocalUser(from.homeUserId, db);
|
||||||
|
const toUser = resolveLocalUser(to.homeUserId, db);
|
||||||
|
|
||||||
|
if (!fromUser || !toUser) {
|
||||||
|
// Accept idempotently — if either user doesn't exist locally, nothing to remove
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete friendship in both directions
|
||||||
|
db.delete(schema.friends)
|
||||||
|
.where(
|
||||||
|
or(
|
||||||
|
and(eq(schema.friends.userId, fromUser.id), eq(schema.friends.friendId, toUser.id)),
|
||||||
|
and(eq(schema.friends.userId, toUser.id), eq(schema.friends.friendId, fromUser.id)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// Determine which user is local (the one whose home instance is NOT the source)
|
||||||
|
// The removing user is on the source instance; broadcast to the other user
|
||||||
|
const ourOrigin = getOurOrigin();
|
||||||
|
const localUser = from.homeInstance === ourOrigin ? fromUser : toUser;
|
||||||
|
const removingUser = from.homeInstance === ourOrigin ? toUser : fromUser;
|
||||||
|
|
||||||
|
connectionManager.sendToUser(localUser.id, {
|
||||||
|
type: 'friend_removed',
|
||||||
|
userId: removingUser.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,851 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
import { getDb, schema } from '../../../db/index.js';
|
||||||
|
import { canonicalizeHomeInstance, getOurOrigin, normalizeOriginForCompare } from '../../../utils/federationAuth.js';
|
||||||
|
import { deleteUploadFile } from '../../../utils/fileCleanup.js';
|
||||||
|
import { sanitizeUser } from '../../../utils/sanitize.js';
|
||||||
|
import { generateSnowflake } from '../../../utils/snowflake.js';
|
||||||
|
import { connectionManager } from '../../../ws/handler.js';
|
||||||
|
import { GROUP_DM_NAME_MAX_LENGTH, GROUP_DM_NAME_MIN_LENGTH } from '@backspace/shared/src/constants.js';
|
||||||
|
import { and, eq, inArray, or } from 'drizzle-orm';
|
||||||
|
import type { DmChannel, DmMessageWithUser, FederationRelayEvent } from '@backspace/shared';
|
||||||
|
import { extractDomain, resolveLocalUser, resolveOrCreateReplicatedUser, verifyAttribution } from '../identity.js';
|
||||||
|
import { downloadProfileAsset, processProfileUpdateEvent } from '../profile.js';
|
||||||
|
|
||||||
|
export async function processMemberAddEvent(
|
||||||
|
event: FederationRelayEvent,
|
||||||
|
sourceInstance: string,
|
||||||
|
db: ReturnType<typeof getDb>,
|
||||||
|
accepted: string[],
|
||||||
|
rejected: Array<{ messageId: string; reason: string }>,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!event.federatedId || !event.membership?.user) {
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'missing_membership_payload' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idempotency: a prior delivery of this exact event has already been processed.
|
||||||
|
// The system message we persist below carries `(source_instance, source_message_id)`
|
||||||
|
// and is guarded by `idx_dm_messages_source_unique`, so presence of a row here is
|
||||||
|
// proof the event's side-effects are already in place. Accept silently to prevent
|
||||||
|
// outbox retries and initial-sync replay from creating duplicate system messages.
|
||||||
|
const existingSysMsg = db
|
||||||
|
.select({ id: schema.dmMessages.id })
|
||||||
|
.from(schema.dmMessages)
|
||||||
|
.where(and(
|
||||||
|
eq(schema.dmMessages.sourceInstance, sourceInstance),
|
||||||
|
eq(schema.dmMessages.sourceMessageId, event.messageId),
|
||||||
|
))
|
||||||
|
.get();
|
||||||
|
if (existingSysMsg) {
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up local channel by federated_id
|
||||||
|
let channel = db
|
||||||
|
.select()
|
||||||
|
.from(schema.dmChannels)
|
||||||
|
.where(eq(schema.dmChannels.federatedId, event.federatedId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
let bootstrapped = false;
|
||||||
|
|
||||||
|
// Bootstrap: channel doesn't exist yet — create from group metadata
|
||||||
|
if (!channel && event.group) {
|
||||||
|
// Attribution: only the owner's instance can bootstrap a group (FED-010)
|
||||||
|
if (event.group.owner && !verifyAttribution(event.group.owner.homeInstance, sourceInstance)) {
|
||||||
|
console.warn(`[federation] Attribution mismatch in member_add bootstrap: owner homeInstance=${extractDomain(event.group.owner.homeInstance)} source=${extractDomain(sourceInstance)}`);
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'attribution_mismatch' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channelId = generateSnowflake();
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Resolve owner — create a replicated stub if unknown
|
||||||
|
let ownerId: string | null = null;
|
||||||
|
if (event.group.owner) {
|
||||||
|
const ownerLocal = resolveOrCreateReplicatedUser(event.group.owner.homeUserId, event.group.owner.homeInstance, db, { username: event.group.owner.profile?.username, status: event.group.owner.profile?.status, deleted: event.group.owner.profile?.deleted });
|
||||||
|
ownerId = ownerLocal?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group metadata snapshot. Older peers omit these fields — fall back
|
||||||
|
// to safe defaults (null name/icon, metadataUpdatedAt=0). When an icon
|
||||||
|
// URL is present, mirror processGroupMetadataUpdateEvent and try to
|
||||||
|
// download a local copy; on failure, persist the absolute URL.
|
||||||
|
const bootstrapName = event.group.name ?? null;
|
||||||
|
const bootstrapIconUrl = event.group.icon ?? null;
|
||||||
|
const bootstrapMetadataUpdatedAt = event.group.metadataUpdatedAt ?? 0;
|
||||||
|
let bootstrapResolvedIcon: string | null = bootstrapIconUrl;
|
||||||
|
if (bootstrapIconUrl !== null) {
|
||||||
|
const localFile = await downloadProfileAsset(bootstrapIconUrl, sourceInstance);
|
||||||
|
bootstrapResolvedIcon = localFile ?? bootstrapIconUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.insert(schema.dmChannels)
|
||||||
|
.values({
|
||||||
|
id: channelId,
|
||||||
|
federatedId: event.federatedId,
|
||||||
|
ownerId,
|
||||||
|
ownerHomeUserId: event.group.owner?.homeUserId ?? null,
|
||||||
|
// Canonicalize on storage so future authority comparisons against
|
||||||
|
// `sourceInstance` (always a full URL) match cleanly. Defensive: older
|
||||||
|
// peers may have sent a bare host on the wire.
|
||||||
|
ownerHomeInstance: canonicalizeHomeInstance(event.group.owner?.homeInstance) ?? null,
|
||||||
|
createdAt: now,
|
||||||
|
name: bootstrapName,
|
||||||
|
icon: bootstrapResolvedIcon,
|
||||||
|
metadataUpdatedAt: bootstrapMetadataUpdatedAt,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// Add all roster members — create replicated user stubs for any
|
||||||
|
// participants from remote instances that haven't been seen before.
|
||||||
|
for (const member of event.group.members) {
|
||||||
|
const rosterUser = resolveOrCreateReplicatedUser(member.homeUserId, member.homeInstance, db, { username: member.profile?.username, status: member.profile?.status, deleted: member.profile?.deleted });
|
||||||
|
// Skip deleted identities — tombstoned users can't be added to a DM
|
||||||
|
if (!rosterUser) continue;
|
||||||
|
const existing = db.select().from(schema.dmMembers)
|
||||||
|
.where(and(
|
||||||
|
eq(schema.dmMembers.dmChannelId, channelId),
|
||||||
|
eq(schema.dmMembers.userId, rosterUser.id),
|
||||||
|
)).get();
|
||||||
|
if (!existing) {
|
||||||
|
db.insert(schema.dmMembers).values({
|
||||||
|
dmChannelId: channelId,
|
||||||
|
userId: rosterUser.id,
|
||||||
|
closed: 0,
|
||||||
|
}).run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
channel = db.select().from(schema.dmChannels)
|
||||||
|
.where(eq(schema.dmChannels.id, channelId)).get();
|
||||||
|
|
||||||
|
console.log(`[federation] Bootstrapped group DM channel ${channelId} (federated_id: ${event.federatedId})`);
|
||||||
|
|
||||||
|
bootstrapped = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!channel) {
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'channel_not_found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authority note: any HMAC-verified peer can relay member_add events.
|
||||||
|
// The HMAC signature proves the event came from a trusted peer.
|
||||||
|
// The attribution check below still validates that addedBy belongs to the source instance.
|
||||||
|
|
||||||
|
// Attribution: adder must belong to source instance (FED-010)
|
||||||
|
if (event.membership.addedBy && !verifyAttribution(event.membership.addedBy.homeInstance, sourceInstance)) {
|
||||||
|
console.warn(`[federation] Attribution mismatch in member_add: addedBy homeInstance=${extractDomain(event.membership.addedBy.homeInstance)} source=${extractDomain(sourceInstance)}`);
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'attribution_mismatch' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel soft-delete if channel was pending GC
|
||||||
|
if (channel.deletedAt) {
|
||||||
|
db.update(schema.dmChannels)
|
||||||
|
.set({ deletedAt: null })
|
||||||
|
.where(eq(schema.dmChannels.id, channel.id))
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the added user — create a replicated stub if unknown
|
||||||
|
const localUser = resolveOrCreateReplicatedUser(
|
||||||
|
event.membership.user.homeUserId,
|
||||||
|
event.membership.user.homeInstance,
|
||||||
|
db,
|
||||||
|
{ username: event.membership.user.profile?.username, status: event.membership.user.profile?.status, deleted: event.membership.user.profile?.deleted },
|
||||||
|
);
|
||||||
|
if (!localUser) {
|
||||||
|
// The user's identity has been deleted — don't add a tombstoned user to the DM
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'participant_not_found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enforce max 10 members
|
||||||
|
const memberCount = db.select()
|
||||||
|
.from(schema.dmMembers)
|
||||||
|
.where(eq(schema.dmMembers.dmChannelId, channel.id))
|
||||||
|
.all().length;
|
||||||
|
if (memberCount >= 10) {
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'max_members_exceeded' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add member (idempotent)
|
||||||
|
const existingMember = db.select().from(schema.dmMembers)
|
||||||
|
.where(and(
|
||||||
|
eq(schema.dmMembers.dmChannelId, channel.id),
|
||||||
|
eq(schema.dmMembers.userId, localUser.id),
|
||||||
|
)).get();
|
||||||
|
|
||||||
|
if (!existingMember) {
|
||||||
|
db.insert(schema.dmMembers).values({
|
||||||
|
dmChannelId: channel.id,
|
||||||
|
userId: localUser.id,
|
||||||
|
closed: 0,
|
||||||
|
}).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert system message for member addition — tagged with (sourceInstance, sourceMessageId)
|
||||||
|
// so subsequent deliveries of the same event are deduplicated at the top of this function.
|
||||||
|
// The tag is applied in both the bootstrap and incremental paths, because bootstrap replays
|
||||||
|
// would otherwise find the channel already present and fall through to the incremental path,
|
||||||
|
// creating spurious system messages (the exact bug this fixes).
|
||||||
|
const actorUser = event.membership.addedBy
|
||||||
|
? resolveOrCreateReplicatedUser(event.membership.addedBy.homeUserId, event.membership.addedBy.homeInstance, db, { username: event.membership.addedBy.profile?.username, status: event.membership.addedBy.profile?.status, deleted: event.membership.addedBy.profile?.deleted })
|
||||||
|
: null;
|
||||||
|
const actorId = actorUser?.id ?? localUser.id;
|
||||||
|
const addBaseName = localUser.username?.includes('@') ? localUser.username.split('@')[0] : (localUser.username ?? 'Unknown');
|
||||||
|
const addSysMsgId = generateSnowflake();
|
||||||
|
const addSysCreatedAt = Date.now();
|
||||||
|
const addSysContent = JSON.stringify({
|
||||||
|
event: 'member_added',
|
||||||
|
targetUserId: localUser.id,
|
||||||
|
targetDisplayName: localUser.displayName ?? addBaseName,
|
||||||
|
});
|
||||||
|
|
||||||
|
db.insert(schema.dmMessages).values({
|
||||||
|
id: addSysMsgId,
|
||||||
|
dmChannelId: channel.id,
|
||||||
|
userId: actorId,
|
||||||
|
content: addSysContent,
|
||||||
|
type: 'system',
|
||||||
|
createdAt: addSysCreatedAt,
|
||||||
|
sourceInstance,
|
||||||
|
sourceMessageId: event.messageId,
|
||||||
|
}).run();
|
||||||
|
|
||||||
|
const systemMessagePayload = {
|
||||||
|
id: addSysMsgId,
|
||||||
|
dmChannelId: channel.id,
|
||||||
|
userId: actorId,
|
||||||
|
content: addSysContent,
|
||||||
|
type: 'system' as const,
|
||||||
|
createdAt: addSysCreatedAt,
|
||||||
|
sourceInstance,
|
||||||
|
sourceMessageId: event.messageId,
|
||||||
|
editedAt: null,
|
||||||
|
replyToId: null,
|
||||||
|
user: actorUser ? sanitizeUser(actorUser) : sanitizeUser(localUser),
|
||||||
|
attachments: [],
|
||||||
|
embeds: [],
|
||||||
|
reactions: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (bootstrapped) {
|
||||||
|
// Bootstrap: send dm_channel_created to home-local members only (prevents
|
||||||
|
// duplicate sidebar entries for users connected to multiple instances).
|
||||||
|
// Include the system message we just persisted as lastMessage so the sidebar
|
||||||
|
// preview and unread calculation use the same anchor as future messages.
|
||||||
|
const memberRows = db.select()
|
||||||
|
.from(schema.dmMembers)
|
||||||
|
.where(eq(schema.dmMembers.dmChannelId, channel.id))
|
||||||
|
.all();
|
||||||
|
const memberUserIds = memberRows.map(m => m.userId);
|
||||||
|
const memberUsers = memberUserIds.length > 0
|
||||||
|
? db.select().from(schema.users).where(inArray(schema.users.id, memberUserIds)).all()
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const bootstrapResult = {
|
||||||
|
id: channel.id,
|
||||||
|
federatedId: channel.federatedId,
|
||||||
|
ownerId: channel.ownerId,
|
||||||
|
createdAt: channel.createdAt,
|
||||||
|
members: memberUsers.map(u => sanitizeUser(u)),
|
||||||
|
lastMessage: systemMessagePayload,
|
||||||
|
};
|
||||||
|
|
||||||
|
const bootstrapOrigin = getOurOrigin();
|
||||||
|
for (const mu of memberUsers) {
|
||||||
|
const muHome = mu.homeInstance
|
||||||
|
? (mu.homeInstance.startsWith('http') ? mu.homeInstance : `https://${mu.homeInstance}`)
|
||||||
|
: bootstrapOrigin; // null homeInstance = native local user
|
||||||
|
if (muHome !== bootstrapOrigin) continue;
|
||||||
|
connectionManager.sendToUser(mu.id, {
|
||||||
|
type: 'dm_channel_created',
|
||||||
|
dmChannel: bootstrapResult as unknown as DmChannel,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Incremental: channel already exists for local members, so broadcast the
|
||||||
|
// structural change (dm_member_added) and the chat message.
|
||||||
|
connectionManager.sendToDmMembers(channel.id, {
|
||||||
|
type: 'dm_message_created',
|
||||||
|
message: systemMessagePayload as unknown as DmMessageWithUser,
|
||||||
|
});
|
||||||
|
connectionManager.sendToDmMembers(channel.id, {
|
||||||
|
type: 'dm_member_added',
|
||||||
|
dmChannelId: channel.id,
|
||||||
|
user: sanitizeUser(localUser),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function processMemberRemoveEvent(
|
||||||
|
event: FederationRelayEvent,
|
||||||
|
sourceInstance: string,
|
||||||
|
db: ReturnType<typeof getDb>,
|
||||||
|
accepted: string[],
|
||||||
|
rejected: Array<{ messageId: string; reason: string }>,
|
||||||
|
): void {
|
||||||
|
if (!event.federatedId || !event.membership?.user) {
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'missing_membership_payload' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attribution: for self-leave, user must belong to source instance (FED-010)
|
||||||
|
if (event.membership.reason === 'leave' && !verifyAttribution(event.membership.user.homeInstance, sourceInstance)) {
|
||||||
|
console.warn(`[federation] Attribution mismatch in member_remove: user homeInstance=${extractDomain(event.membership.user.homeInstance)} source=${extractDomain(sourceInstance)}`);
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'attribution_mismatch' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idempotency: skip if this exact event has already been processed.
|
||||||
|
// See `processMemberAddEvent` for the rationale — deduplicates retries and
|
||||||
|
// initial-sync replay so we don't insert duplicate leave/kick system messages
|
||||||
|
// or re-trigger broadcast and soft-delete side-effects.
|
||||||
|
const existingSysMsg = db
|
||||||
|
.select({ id: schema.dmMessages.id })
|
||||||
|
.from(schema.dmMessages)
|
||||||
|
.where(and(
|
||||||
|
eq(schema.dmMessages.sourceInstance, sourceInstance),
|
||||||
|
eq(schema.dmMessages.sourceMessageId, event.messageId),
|
||||||
|
))
|
||||||
|
.get();
|
||||||
|
if (existingSysMsg) {
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = db
|
||||||
|
.select()
|
||||||
|
.from(schema.dmChannels)
|
||||||
|
.where(eq(schema.dmChannels.federatedId, event.federatedId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!channel) {
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate authority: owner's instance for kicks, any instance for self-leave.
|
||||||
|
//
|
||||||
|
// `sourceInstance` arrives as a full URL from `federationWorker.ts` (always
|
||||||
|
// `getOurOrigin()` on the sender). `channel.ownerHomeInstance`, however, can be
|
||||||
|
// stored either as a bare host (from `users.homeInstance`, written by
|
||||||
|
// `resolveOrCreateReplicatedUser` and by group DM ownership transfers to a
|
||||||
|
// federated user) OR as a full URL (group DM creation / transfers to a local
|
||||||
|
// user, which fall back to `domainOrigin = getOurOrigin()`). Strict equality
|
||||||
|
// here mis-fires for the bare-vs-full mismatch — see the historical bug entry
|
||||||
|
// in `docs/systems/dm-system.md`. Always compare through
|
||||||
|
// `normalizeOriginForCompare`, matching the established pattern for federation
|
||||||
|
// authority checks.
|
||||||
|
if (event.membership.reason !== 'leave' && channel.ownerHomeInstance &&
|
||||||
|
normalizeOriginForCompare(sourceInstance) !== normalizeOriginForCompare(channel.ownerHomeInstance)) {
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'unauthorized_source' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const localUser = resolveLocalUser(event.membership.user.homeUserId, db);
|
||||||
|
if (!localUser) {
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert system message for member leaving (before deletion so the broadcast
|
||||||
|
// still reaches the departing user's connections). Tagged with source for dedup.
|
||||||
|
const leaveBaseName = localUser.username?.includes('@') ? localUser.username.split('@')[0] : (localUser.username ?? 'Unknown');
|
||||||
|
const leaveSysMsgId = generateSnowflake();
|
||||||
|
const leaveSysCreatedAt = Date.now();
|
||||||
|
const leaveSysContent = JSON.stringify({
|
||||||
|
event: 'member_removed',
|
||||||
|
targetUserId: localUser.id,
|
||||||
|
targetDisplayName: localUser.displayName ?? leaveBaseName,
|
||||||
|
reason: event.membership?.reason ?? 'leave',
|
||||||
|
});
|
||||||
|
db.insert(schema.dmMessages).values({
|
||||||
|
id: leaveSysMsgId,
|
||||||
|
dmChannelId: channel.id,
|
||||||
|
userId: localUser.id,
|
||||||
|
content: leaveSysContent,
|
||||||
|
type: 'system',
|
||||||
|
createdAt: leaveSysCreatedAt,
|
||||||
|
sourceInstance,
|
||||||
|
sourceMessageId: event.messageId,
|
||||||
|
}).run();
|
||||||
|
|
||||||
|
connectionManager.sendToDmMembers(channel.id, {
|
||||||
|
type: 'dm_message_created',
|
||||||
|
message: {
|
||||||
|
id: leaveSysMsgId,
|
||||||
|
dmChannelId: channel.id,
|
||||||
|
userId: localUser.id,
|
||||||
|
content: leaveSysContent,
|
||||||
|
type: 'system',
|
||||||
|
createdAt: leaveSysCreatedAt,
|
||||||
|
sourceInstance,
|
||||||
|
sourceMessageId: event.messageId,
|
||||||
|
editedAt: null,
|
||||||
|
replyToId: null,
|
||||||
|
user: sanitizeUser(localUser),
|
||||||
|
attachments: [],
|
||||||
|
embeds: [],
|
||||||
|
reactions: [],
|
||||||
|
} as unknown as DmMessageWithUser,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove member (idempotent)
|
||||||
|
db.delete(schema.dmMembers)
|
||||||
|
.where(and(
|
||||||
|
eq(schema.dmMembers.dmChannelId, channel.id),
|
||||||
|
eq(schema.dmMembers.userId, localUser.id),
|
||||||
|
))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// Clean up read states
|
||||||
|
db.delete(schema.readStates)
|
||||||
|
.where(and(
|
||||||
|
eq(schema.readStates.userId, localUser.id),
|
||||||
|
eq(schema.readStates.channelId, channel.id),
|
||||||
|
))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// Broadcast to local WebSocket clients
|
||||||
|
connectionManager.sendToDmMembers(channel.id, {
|
||||||
|
type: 'dm_member_removed',
|
||||||
|
dmChannelId: channel.id,
|
||||||
|
userId: localUser.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if zero local members remain — begin soft-delete GC
|
||||||
|
const remaining = db.select()
|
||||||
|
.from(schema.dmMembers)
|
||||||
|
.where(eq(schema.dmMembers.dmChannelId, channel.id))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
if (remaining.length === 0) {
|
||||||
|
db.update(schema.dmChannels)
|
||||||
|
.set({ deletedAt: Date.now() })
|
||||||
|
.where(eq(schema.dmChannels.id, channel.id))
|
||||||
|
.run();
|
||||||
|
console.log(`[federation] Group DM ${channel.id} has no local members, soft-deleted for GC`);
|
||||||
|
}
|
||||||
|
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function processOwnershipTransferEvent(
|
||||||
|
event: FederationRelayEvent,
|
||||||
|
sourceInstance: string,
|
||||||
|
db: ReturnType<typeof getDb>,
|
||||||
|
accepted: string[],
|
||||||
|
rejected: Array<{ messageId: string; reason: string }>,
|
||||||
|
): void {
|
||||||
|
if (!event.federatedId || !event.ownership) {
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'missing_ownership_payload' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attribution: previous owner must belong to source instance (FED-010)
|
||||||
|
if (event.ownership.previousOwner && !verifyAttribution(event.ownership.previousOwner.homeInstance, sourceInstance)) {
|
||||||
|
console.warn(`[federation] Attribution mismatch in ownership_transfer: previousOwner homeInstance=${extractDomain(event.ownership.previousOwner.homeInstance)} source=${extractDomain(sourceInstance)}`);
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'attribution_mismatch' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idempotency: reject replay of a transfer we've already processed. Critical here
|
||||||
|
// because a stale replay could otherwise overwrite a newer owner (e.g. A->B then
|
||||||
|
// B->A, then A->B arrives again and clobbers). See `processMemberAddEvent`.
|
||||||
|
const existingSysMsg = db
|
||||||
|
.select({ id: schema.dmMessages.id })
|
||||||
|
.from(schema.dmMessages)
|
||||||
|
.where(and(
|
||||||
|
eq(schema.dmMessages.sourceInstance, sourceInstance),
|
||||||
|
eq(schema.dmMessages.sourceMessageId, event.messageId),
|
||||||
|
))
|
||||||
|
.get();
|
||||||
|
if (existingSysMsg) {
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = db
|
||||||
|
.select()
|
||||||
|
.from(schema.dmChannels)
|
||||||
|
.where(eq(schema.dmChannels.federatedId, event.federatedId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!channel) {
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate authority: only the current owner's instance can transfer ownership.
|
||||||
|
//
|
||||||
|
// See the matching note in `processMemberRemoveEvent`: `sourceInstance` is
|
||||||
|
// always a full URL but `channel.ownerHomeInstance` can be bare or full.
|
||||||
|
// Normalize both sides through `normalizeOriginForCompare` so we don't reject
|
||||||
|
// legitimate back-and-forth transfers that wrote a bare host into the column.
|
||||||
|
if (channel.ownerHomeInstance &&
|
||||||
|
normalizeOriginForCompare(sourceInstance) !== normalizeOriginForCompare(channel.ownerHomeInstance)) {
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'unauthorized_source' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve new owner to local user. If the new owner's identity has been
|
||||||
|
// deleted, we cannot complete the transfer — reject so the event can be
|
||||||
|
// retried or dropped by the sender.
|
||||||
|
const newOwnerLocal = resolveOrCreateReplicatedUser(
|
||||||
|
event.ownership.newOwner.homeUserId,
|
||||||
|
event.ownership.newOwner.homeInstance,
|
||||||
|
db,
|
||||||
|
{ username: event.ownership.newOwner.profile?.username, status: event.ownership.newOwner.profile?.status, deleted: event.ownership.newOwner.profile?.deleted },
|
||||||
|
);
|
||||||
|
if (!newOwnerLocal) {
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'participant_not_found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Canonicalize to a full origin URL on storage so future authority checks
|
||||||
|
// can compare cleanly against `sourceInstance` (also a full URL). Mirrors
|
||||||
|
// the canonicalization performed in `transferGroupDmOwnership` on the
|
||||||
|
// sender side. Falls back to the wire value if normalization yields null
|
||||||
|
// (shouldn't happen for valid events; defensive).
|
||||||
|
const canonicalOwnerHome =
|
||||||
|
canonicalizeHomeInstance(event.ownership.newOwner.homeInstance) ?? event.ownership.newOwner.homeInstance;
|
||||||
|
|
||||||
|
db.update(schema.dmChannels)
|
||||||
|
.set({
|
||||||
|
ownerId: newOwnerLocal.id,
|
||||||
|
ownerHomeUserId: event.ownership.newOwner.homeUserId,
|
||||||
|
ownerHomeInstance: canonicalOwnerHome,
|
||||||
|
})
|
||||||
|
.where(eq(schema.dmChannels.id, channel.id))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
connectionManager.sendToDmMembers(channel.id, {
|
||||||
|
type: 'dm_owner_updated',
|
||||||
|
dmChannelId: channel.id,
|
||||||
|
newOwnerId: newOwnerLocal.id,
|
||||||
|
newOwnerHomeUserId: event.ownership.newOwner.homeUserId,
|
||||||
|
newOwnerHomeInstance: canonicalOwnerHome,
|
||||||
|
});
|
||||||
|
|
||||||
|
const prevOwnerLocal = event.ownership.previousOwner
|
||||||
|
? resolveLocalUser(event.ownership.previousOwner.homeUserId, db)
|
||||||
|
: null;
|
||||||
|
const ownerSysMsgId = generateSnowflake();
|
||||||
|
const ownerSysCreatedAt = Date.now();
|
||||||
|
const newOwnerBaseName = newOwnerLocal?.username?.includes('@') ? newOwnerLocal.username.split('@')[0] : (newOwnerLocal?.username ?? 'Unknown');
|
||||||
|
const prevOwnerId = prevOwnerLocal?.id ?? channel.ownerId ?? 'system';
|
||||||
|
const ownerSysContent = JSON.stringify({
|
||||||
|
event: 'owner_changed',
|
||||||
|
newOwnerId: newOwnerLocal.id,
|
||||||
|
newOwnerDisplayName: newOwnerLocal.displayName ?? newOwnerBaseName,
|
||||||
|
});
|
||||||
|
|
||||||
|
db.insert(schema.dmMessages).values({
|
||||||
|
id: ownerSysMsgId,
|
||||||
|
dmChannelId: channel.id,
|
||||||
|
userId: prevOwnerId,
|
||||||
|
content: ownerSysContent,
|
||||||
|
type: 'system',
|
||||||
|
createdAt: ownerSysCreatedAt,
|
||||||
|
sourceInstance,
|
||||||
|
sourceMessageId: event.messageId,
|
||||||
|
}).run();
|
||||||
|
|
||||||
|
connectionManager.sendToDmMembers(channel.id, {
|
||||||
|
type: 'dm_message_created',
|
||||||
|
message: {
|
||||||
|
id: ownerSysMsgId,
|
||||||
|
dmChannelId: channel.id,
|
||||||
|
userId: prevOwnerId,
|
||||||
|
content: ownerSysContent,
|
||||||
|
type: 'system',
|
||||||
|
createdAt: ownerSysCreatedAt,
|
||||||
|
sourceInstance,
|
||||||
|
sourceMessageId: event.messageId,
|
||||||
|
editedAt: null,
|
||||||
|
replyToId: null,
|
||||||
|
user: prevOwnerLocal ? sanitizeUser(prevOwnerLocal) : undefined,
|
||||||
|
attachments: [],
|
||||||
|
embeds: [],
|
||||||
|
reactions: [],
|
||||||
|
} as unknown as DmMessageWithUser,
|
||||||
|
});
|
||||||
|
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Friend Event Processors ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inbound group_metadata_update relay handler.
|
||||||
|
*
|
||||||
|
* Authority: only the group's owner instance may mutate name/icon. We compare
|
||||||
|
* `extractDomain(sourceInstance)` with `extractDomain(channel.ownerHomeInstance)`;
|
||||||
|
* any other peer relaying this event is treated as an attribution mismatch
|
||||||
|
* (mirrors the strict check in processProfileUpdateEvent).
|
||||||
|
*
|
||||||
|
* Receiver hardening: never trust the wire payload's bounds — re-validate name
|
||||||
|
* length and icon URL scheme. A malicious or buggy peer cannot push us past
|
||||||
|
* the same constraints we enforce in PATCH /api/dm/:id.
|
||||||
|
*
|
||||||
|
* Side-effects on success:
|
||||||
|
* 1. dm_channels.{name,icon,metadataUpdatedAt} updated in a single tx.
|
||||||
|
* 2. One or two `dm_messages` system rows inserted (name_changed / icon_changed),
|
||||||
|
* each tagged with `(sourceInstance, sourceMessageId)` using the dedup-suffix
|
||||||
|
* scheme `${event.messageId}:name` / `${event.messageId}:icon`. This mirrors
|
||||||
|
* processMemberAddEvent's idempotency contract — a retry of the same wire
|
||||||
|
* event must not insert a second row.
|
||||||
|
* 3. dm_channel_updated WS broadcast to local members.
|
||||||
|
* 4. dm_message_created WS broadcast for each new system message.
|
||||||
|
* 5. Old local icon file is unlinked from disk if it changed away from a local
|
||||||
|
* filename (same precedent as the local PATCH endpoint).
|
||||||
|
*/
|
||||||
|
export async function processGroupMetadataUpdateEvent(
|
||||||
|
event: FederationRelayEvent,
|
||||||
|
sourceInstance: string,
|
||||||
|
db: ReturnType<typeof getDb>,
|
||||||
|
accepted: string[],
|
||||||
|
rejected: Array<{ messageId: string; reason: string }>,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!event.federatedId) {
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'missing_federated_id' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lookup channel by federated_id. Missing → idempotent accept (this peer has
|
||||||
|
// no replica of the channel, nothing to update).
|
||||||
|
const channel = db
|
||||||
|
.select()
|
||||||
|
.from(schema.dmChannels)
|
||||||
|
.where(eq(schema.dmChannels.federatedId, event.federatedId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!channel) {
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authority: only the owner's home instance can mutate group metadata.
|
||||||
|
if (extractDomain(sourceInstance) !== extractDomain(channel.ownerHomeInstance ?? '')) {
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'attribution_mismatch' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Receiver hardening — payload validation. Don't trust remote peers to
|
||||||
|
// respect our bounds; a peer bug or malicious actor must not be able to
|
||||||
|
// push values our local PATCH endpoint would have rejected.
|
||||||
|
const metadata = event.metadata;
|
||||||
|
if (!metadata) {
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'missing_metadata_payload' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metadata.name !== null) {
|
||||||
|
const trimmedLength = metadata.name.trim().length;
|
||||||
|
if (trimmedLength < GROUP_DM_NAME_MIN_LENGTH || trimmedLength > GROUP_DM_NAME_MAX_LENGTH) {
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'invalid_payload' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metadata.icon !== null && !(metadata.icon.startsWith('http://') || metadata.icon.startsWith('https://'))) {
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'invalid_payload' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Version check: stale or duplicate timestamp → silent accept.
|
||||||
|
if (metadata.metadataUpdatedAt <= (channel.metadataUpdatedAt ?? 0)) {
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Diff against stored row — if neither field actually changed, no-op.
|
||||||
|
const nameChanged = metadata.name !== channel.name;
|
||||||
|
const iconChanged = metadata.icon !== channel.icon;
|
||||||
|
if (!nameChanged && !iconChanged) {
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idempotency pre-check: if either system message already exists under
|
||||||
|
// `(sourceInstance, sourceMessageId)`, this event was already processed.
|
||||||
|
// Mirrors processMemberAddEvent's dedup contract — outbox retries and
|
||||||
|
// initial-sync replay must not double-insert.
|
||||||
|
const nameMessageId = `${event.messageId}:name`;
|
||||||
|
const iconMessageId = `${event.messageId}:icon`;
|
||||||
|
const existingNameRow = nameChanged
|
||||||
|
? db
|
||||||
|
.select({ id: schema.dmMessages.id })
|
||||||
|
.from(schema.dmMessages)
|
||||||
|
.where(and(
|
||||||
|
eq(schema.dmMessages.sourceInstance, sourceInstance),
|
||||||
|
eq(schema.dmMessages.sourceMessageId, nameMessageId),
|
||||||
|
))
|
||||||
|
.get()
|
||||||
|
: null;
|
||||||
|
const existingIconRow = iconChanged
|
||||||
|
? db
|
||||||
|
.select({ id: schema.dmMessages.id })
|
||||||
|
.from(schema.dmMessages)
|
||||||
|
.where(and(
|
||||||
|
eq(schema.dmMessages.sourceInstance, sourceInstance),
|
||||||
|
eq(schema.dmMessages.sourceMessageId, iconMessageId),
|
||||||
|
))
|
||||||
|
.get()
|
||||||
|
: null;
|
||||||
|
// If every changed field already has its corresponding system row, the
|
||||||
|
// entire event has been applied — accept silently.
|
||||||
|
if (
|
||||||
|
(!nameChanged || existingNameRow)
|
||||||
|
&& (!iconChanged || existingIconRow)
|
||||||
|
) {
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Resolve icon: download to local upload dir, fall back to absolute URL ──
|
||||||
|
let resolvedIcon: string | null = metadata.icon;
|
||||||
|
if (iconChanged && metadata.icon !== null) {
|
||||||
|
const localFile = await downloadProfileAsset(metadata.icon, sourceInstance);
|
||||||
|
resolvedIcon = localFile ?? metadata.icon;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve actor → local user id for the system-message foreign key.
|
||||||
|
// Falls back to channel.ownerId if the actor stub can't be created (e.g.
|
||||||
|
// tombstoned identity); the system message still has to render somewhere.
|
||||||
|
const actorParticipant = metadata.actor;
|
||||||
|
let actorUserId: string | null = null;
|
||||||
|
if (actorParticipant) {
|
||||||
|
const actorUser = resolveOrCreateReplicatedUser(
|
||||||
|
actorParticipant.homeUserId,
|
||||||
|
actorParticipant.homeInstance,
|
||||||
|
db,
|
||||||
|
{ username: actorParticipant.profile?.username, status: actorParticipant.profile?.status, deleted: actorParticipant.profile?.deleted },
|
||||||
|
);
|
||||||
|
actorUserId = actorUser?.id ?? null;
|
||||||
|
}
|
||||||
|
if (!actorUserId) {
|
||||||
|
actorUserId = channel.ownerId;
|
||||||
|
}
|
||||||
|
if (!actorUserId) {
|
||||||
|
// No owner user row to attach a system message to — extremely unusual,
|
||||||
|
// bail out cleanly without persisting anything.
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'actor_not_found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldName = channel.name;
|
||||||
|
const oldIcon = channel.icon;
|
||||||
|
|
||||||
|
type SystemMessageRow = { id: string; sourceMessageId: string; content: string; createdAt: number };
|
||||||
|
const sysMessageRows: SystemMessageRow[] = [];
|
||||||
|
|
||||||
|
// Single transaction: channel update + system message insert(s).
|
||||||
|
db.transaction((tx) => {
|
||||||
|
tx.update(schema.dmChannels)
|
||||||
|
.set({
|
||||||
|
name: metadata.name,
|
||||||
|
icon: resolvedIcon,
|
||||||
|
metadataUpdatedAt: metadata.metadataUpdatedAt,
|
||||||
|
})
|
||||||
|
.where(eq(schema.dmChannels.id, channel.id))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
if (nameChanged && !existingNameRow) {
|
||||||
|
const sysId = generateSnowflake();
|
||||||
|
const content = JSON.stringify({ event: 'name_changed', oldName, newName: metadata.name });
|
||||||
|
tx.insert(schema.dmMessages).values({
|
||||||
|
id: sysId,
|
||||||
|
dmChannelId: channel.id,
|
||||||
|
userId: actorUserId,
|
||||||
|
content,
|
||||||
|
type: 'system',
|
||||||
|
sourceInstance,
|
||||||
|
sourceMessageId: nameMessageId,
|
||||||
|
createdAt: metadata.metadataUpdatedAt,
|
||||||
|
}).run();
|
||||||
|
sysMessageRows.push({
|
||||||
|
id: sysId,
|
||||||
|
sourceMessageId: nameMessageId,
|
||||||
|
content,
|
||||||
|
createdAt: metadata.metadataUpdatedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (iconChanged && !existingIconRow) {
|
||||||
|
const sysId = generateSnowflake();
|
||||||
|
const content = JSON.stringify({ event: 'icon_changed' });
|
||||||
|
tx.insert(schema.dmMessages).values({
|
||||||
|
id: sysId,
|
||||||
|
dmChannelId: channel.id,
|
||||||
|
userId: actorUserId,
|
||||||
|
content,
|
||||||
|
type: 'system',
|
||||||
|
sourceInstance,
|
||||||
|
sourceMessageId: iconMessageId,
|
||||||
|
createdAt: metadata.metadataUpdatedAt,
|
||||||
|
}).run();
|
||||||
|
sysMessageRows.push({
|
||||||
|
id: sysId,
|
||||||
|
sourceMessageId: iconMessageId,
|
||||||
|
content,
|
||||||
|
createdAt: metadata.metadataUpdatedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Broadcast channel update to local members ──
|
||||||
|
connectionManager.sendToDmMembers(channel.id, {
|
||||||
|
type: 'dm_channel_updated',
|
||||||
|
dmChannelId: channel.id,
|
||||||
|
name: metadata.name,
|
||||||
|
icon: resolvedIcon,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Broadcast each new system message ──
|
||||||
|
const actorRow = db.select().from(schema.users).where(eq(schema.users.id, actorUserId)).get();
|
||||||
|
const sanitizedActor = actorRow ? sanitizeUser(actorRow) : undefined;
|
||||||
|
for (const sys of sysMessageRows) {
|
||||||
|
connectionManager.sendToDmMembers(channel.id, {
|
||||||
|
type: 'dm_message_created',
|
||||||
|
message: {
|
||||||
|
id: sys.id,
|
||||||
|
dmChannelId: channel.id,
|
||||||
|
userId: actorUserId,
|
||||||
|
content: sys.content,
|
||||||
|
type: 'system',
|
||||||
|
createdAt: sys.createdAt,
|
||||||
|
sourceInstance,
|
||||||
|
sourceMessageId: sys.sourceMessageId,
|
||||||
|
editedAt: null,
|
||||||
|
replyToId: null,
|
||||||
|
user: sanitizedActor,
|
||||||
|
attachments: [],
|
||||||
|
embeds: [],
|
||||||
|
reactions: [],
|
||||||
|
} as DmMessageWithUser,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cleanup old local icon file ──
|
||||||
|
// Mirrors the local PATCH precedent (dm.ts:1595): only unlink when the
|
||||||
|
// previous icon was a bare local filename (i.e. we own the file on disk).
|
||||||
|
// Absolute URLs point at remote files we never owned.
|
||||||
|
if (iconChanged && oldIcon && !oldIcon.startsWith('http://') && !oldIcon.startsWith('https://') && oldIcon !== resolvedIcon) {
|
||||||
|
deleteUploadFile(oldIcon);
|
||||||
|
}
|
||||||
|
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,890 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
import { getDb, schema } from '../../../db/index.js';
|
||||||
|
import { authenticate, requireAdmin } from '../../../utils/auth.js';
|
||||||
|
import { buildFederationHeaders, generateHmacSecret, getOurOrigin } from '../../../utils/federationAuth.js';
|
||||||
|
import { getInstanceId } from '../../../utils/federationEpoch.js';
|
||||||
|
import { onPeerActivated } from '../../../utils/federationPeerActivation.js';
|
||||||
|
import { generateSnowflake } from '../../../utils/snowflake.js';
|
||||||
|
import { connectionManager } from '../../../ws/handler.js';
|
||||||
|
import { and, desc, eq, inArray, isNull, or } from 'drizzle-orm';
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import type { ApprovalRequestSubscriberSummary, PeeringTriggerReason } from '@backspace/shared';
|
||||||
|
import type { FastifyInstance, FastifyReply } from 'fastify';
|
||||||
|
import { resolveLocalOrigin, sanitizePeer } from '../origin.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue an inbound peer/accept request for local-admin approval.
|
||||||
|
*
|
||||||
|
* Called from `/peer/accept` when:
|
||||||
|
* (a) `autoAcceptPeering=0` and no `pending`/`awaiting_approval` peer row
|
||||||
|
* exists for the source origin (first-contact request from remote), OR
|
||||||
|
* (b) the receiver is in `awaiting_approval` for this origin but the
|
||||||
|
* inbound `/peer/accept` cannot be cryptographically verified
|
||||||
|
* (token absent or mismatched) — see spec §3.5.
|
||||||
|
*
|
||||||
|
* Generates a fresh single-use approval token, upserts the
|
||||||
|
* `peer_approval_requests` row, notifies admins, and returns 202 with the
|
||||||
|
* token in the body. The initiator stores the token alongside its
|
||||||
|
* `awaiting_approval` row so a future `/peer/accept` from this side's
|
||||||
|
* `/approve` endpoint can verify mutual admin approval.
|
||||||
|
*/
|
||||||
|
export function queueApprovalRequest(
|
||||||
|
db: ReturnType<typeof getDb>,
|
||||||
|
reply: FastifyReply,
|
||||||
|
sourceOrigin: string,
|
||||||
|
hmacSecret: string,
|
||||||
|
reqInstanceName: string | null,
|
||||||
|
): FastifyReply {
|
||||||
|
const now = Date.now();
|
||||||
|
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
||||||
|
const approvalToken = randomBytes(32).toString('hex');
|
||||||
|
|
||||||
|
const existingRequest = db
|
||||||
|
.select({ id: schema.peerApprovalRequests.id })
|
||||||
|
.from(schema.peerApprovalRequests)
|
||||||
|
.where(eq(schema.peerApprovalRequests.origin, sourceOrigin))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (existingRequest) {
|
||||||
|
db.update(schema.peerApprovalRequests)
|
||||||
|
.set({
|
||||||
|
instanceName: reqInstanceName,
|
||||||
|
hmacSecret,
|
||||||
|
requestedAt: now,
|
||||||
|
expiresAt: now + THIRTY_DAYS_MS,
|
||||||
|
approvalToken,
|
||||||
|
})
|
||||||
|
.where(eq(schema.peerApprovalRequests.id, existingRequest.id))
|
||||||
|
.run();
|
||||||
|
} else {
|
||||||
|
db.insert(schema.peerApprovalRequests)
|
||||||
|
.values({
|
||||||
|
id: generateSnowflake(),
|
||||||
|
origin: sourceOrigin,
|
||||||
|
instanceName: reqInstanceName,
|
||||||
|
hmacSecret,
|
||||||
|
requestedAt: now,
|
||||||
|
expiresAt: now + THIRTY_DAYS_MS,
|
||||||
|
approvalToken,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
connectionManager.sendToAdmins({
|
||||||
|
type: 'federation_approval_request_received' as const,
|
||||||
|
origin: sourceOrigin,
|
||||||
|
instanceName: reqInstanceName ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return reply.code(202).send({
|
||||||
|
queued: true,
|
||||||
|
message: 'Request queued for admin approval',
|
||||||
|
approvalToken,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inbound approve — admin accepts a remote instance's peering request.
|
||||||
|
* Generates fresh HMAC, sends `/peer/accept` to the remote, and on success
|
||||||
|
* activates the peer locally. Preserves the historical behavior verbatim;
|
||||||
|
* extracted from the route handler so the dispatcher can branch on direction.
|
||||||
|
*/
|
||||||
|
export async function handleInboundApprove(
|
||||||
|
approvalReq: typeof schema.peerApprovalRequests.$inferSelect,
|
||||||
|
localOrigin: string,
|
||||||
|
reply: FastifyReply,
|
||||||
|
): Promise<FastifyReply> {
|
||||||
|
const db = getDb();
|
||||||
|
const id = approvalReq.id;
|
||||||
|
|
||||||
|
const existingPeer = db
|
||||||
|
.select()
|
||||||
|
.from(schema.federationPeers)
|
||||||
|
.where(eq(schema.federationPeers.origin, approvalReq.origin))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (existingPeer && existingPeer.status === 'active') {
|
||||||
|
db.delete(schema.peerApprovalRequests)
|
||||||
|
.where(eq(schema.peerApprovalRequests.id, id))
|
||||||
|
.run();
|
||||||
|
return reply.code(200).send({ success: true, peer: sanitizePeer(existingPeer) });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingPeer) {
|
||||||
|
db.delete(schema.federationPeers)
|
||||||
|
.where(eq(schema.federationPeers.id, existingPeer.id))
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
const hmacSecret = generateHmacSecret();
|
||||||
|
const peerId = generateSnowflake();
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
db.insert(schema.federationPeers).values({
|
||||||
|
id: peerId,
|
||||||
|
origin: approvalReq.origin,
|
||||||
|
instanceName: approvalReq.instanceName,
|
||||||
|
hmacSecret,
|
||||||
|
status: 'pending',
|
||||||
|
createdAt: now,
|
||||||
|
}).run();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const instanceName = db
|
||||||
|
.select({ name: schema.instanceSettings.instanceName })
|
||||||
|
.from(schema.instanceSettings)
|
||||||
|
.where(eq(schema.instanceSettings.id, 1))
|
||||||
|
.get()?.name ?? undefined;
|
||||||
|
|
||||||
|
const response = await fetch(`${approvalReq.origin}/api/federation/peer/accept`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
sourceOrigin: localOrigin,
|
||||||
|
hmacSecret,
|
||||||
|
instanceName,
|
||||||
|
instanceId: getInstanceId(),
|
||||||
|
// Forward the stored token (issued in our 202 response when the
|
||||||
|
// remote first sent /peer/accept). Lets the remote verify mutual
|
||||||
|
// admin approval. Spec §3.7.
|
||||||
|
...(approvalReq.approvalToken ? { approvalToken: approvalReq.approvalToken } : {}),
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 202) {
|
||||||
|
// Remote instance also has autoAcceptPeering off — they queued our request.
|
||||||
|
// Don't activate our peer. Set to awaiting_approval until their admin also approves.
|
||||||
|
// Capture the approval token they returned so the next inbound
|
||||||
|
// /peer/accept (when their admin approves) can be verified. §3.7.
|
||||||
|
let returnedToken: string | null = null;
|
||||||
|
try {
|
||||||
|
const body = (await response.json()) as { approvalToken?: string };
|
||||||
|
if (typeof body?.approvalToken === 'string' && body.approvalToken.length > 0) {
|
||||||
|
returnedToken = body.approvalToken;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Non-JSON / empty body — legacy peer.
|
||||||
|
}
|
||||||
|
|
||||||
|
db.update(schema.federationPeers)
|
||||||
|
.set({ status: 'awaiting_approval', approvalToken: returnedToken })
|
||||||
|
.where(eq(schema.federationPeers.id, peerId))
|
||||||
|
.run();
|
||||||
|
// Delete the approval request since we already acted on it
|
||||||
|
db.delete(schema.peerApprovalRequests)
|
||||||
|
.where(eq(schema.peerApprovalRequests.id, id))
|
||||||
|
.run();
|
||||||
|
return reply.code(200).send({
|
||||||
|
success: true,
|
||||||
|
awaitingRemoteApproval: true,
|
||||||
|
message: 'Remote instance also requires admin approval. Your request has been queued on their side.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let errorMessage = `Remote instance rejected handshake (HTTP ${response.status})`;
|
||||||
|
try {
|
||||||
|
const body = await response.json() as { error?: string };
|
||||||
|
if (body.error) errorMessage = body.error;
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
|
db.delete(schema.federationPeers)
|
||||||
|
.where(eq(schema.federationPeers.id, peerId))
|
||||||
|
.run();
|
||||||
|
return reply.code(502).send({ error: errorMessage, statusCode: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the remote's instanceName and instanceId (epoch) from the response
|
||||||
|
// body so the federation panel renders a friendly label and we record the
|
||||||
|
// peer's authenticated epoch baseline. Tolerate omission and non-JSON
|
||||||
|
// bodies — same pattern as performHandshake and /peer/initiate.
|
||||||
|
let remoteInstanceName: string | null = null;
|
||||||
|
let remoteInstanceId: string | null = null;
|
||||||
|
try {
|
||||||
|
const body = (await response.json()) as { instanceName?: string | null; instanceId?: string | null };
|
||||||
|
if (typeof body?.instanceName === 'string' && body.instanceName.length > 0) {
|
||||||
|
remoteInstanceName = body.instanceName;
|
||||||
|
}
|
||||||
|
if (typeof body?.instanceId === 'string' && body.instanceId.length > 0) {
|
||||||
|
remoteInstanceId = body.instanceId;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Non-JSON body — leave null.
|
||||||
|
}
|
||||||
|
|
||||||
|
db.update(schema.federationPeers)
|
||||||
|
.set({ status: 'active', lastSeenAt: now, instanceName: remoteInstanceName, peerInstanceId: remoteInstanceId, approvalToken: null })
|
||||||
|
.where(eq(schema.federationPeers.id, peerId))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
db.delete(schema.peerApprovalRequests)
|
||||||
|
.where(eq(schema.peerApprovalRequests.id, id))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
|
||||||
|
onPeerActivated(peerId, 'approval_handshake').catch(err =>
|
||||||
|
console.error('[federation] onPeerActivated from /approval-requests/:id/approve failed:', err)
|
||||||
|
);
|
||||||
|
|
||||||
|
const peer = db
|
||||||
|
.select()
|
||||||
|
.from(schema.federationPeers)
|
||||||
|
.where(eq(schema.federationPeers.id, peerId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
return reply.code(200).send({ success: true, peer: peer ? sanitizePeer(peer) : undefined });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
db.delete(schema.federationPeers)
|
||||||
|
.where(eq(schema.federationPeers.id, peerId))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
if (err instanceof DOMException && err.name === 'TimeoutError') {
|
||||||
|
return reply.code(504).send({
|
||||||
|
error: 'Remote instance did not respond within 10 seconds',
|
||||||
|
statusCode: 504,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return reply.code(502).send({
|
||||||
|
error: `Failed to reach remote instance: ${message}`,
|
||||||
|
statusCode: 502,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outbound approve — admin authorizes the local instance to peer with a
|
||||||
|
* remote that one or more of its users have requested. Generates fresh HMAC,
|
||||||
|
* sends `/peer/accept` to the remote, and:
|
||||||
|
* - 200 → activate peer; `onPeerActivated` runs and (per Task 6) fans out
|
||||||
|
* approved-notifications to outbound subscribers and cascade-deletes the
|
||||||
|
* queue row. The handler MUST NOT duplicate that cleanup.
|
||||||
|
* - 202 → remote also gates; transition to `awaiting_approval`, capture
|
||||||
|
* the returned token, leave the queue row + subscribers untouched (they
|
||||||
|
* wait for the remote admin to approve and the eventual full activation
|
||||||
|
* to fan out via `onPeerActivated`).
|
||||||
|
* - 4xx/5xx/network → clean up the peer row we created; leave the queue
|
||||||
|
* row alone so the admin can retry.
|
||||||
|
*/
|
||||||
|
export async function handleOutboundApprove(
|
||||||
|
approvalReq: typeof schema.peerApprovalRequests.$inferSelect,
|
||||||
|
localOrigin: string,
|
||||||
|
reply: FastifyReply,
|
||||||
|
): Promise<FastifyReply> {
|
||||||
|
const db = getDb();
|
||||||
|
const hmacSecret = generateHmacSecret();
|
||||||
|
const peerId = generateSnowflake();
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Insert the peer row in 'pending' so failure paths roll back cleanly.
|
||||||
|
db.insert(schema.federationPeers).values({
|
||||||
|
id: peerId,
|
||||||
|
origin: approvalReq.origin,
|
||||||
|
instanceName: approvalReq.instanceName,
|
||||||
|
hmacSecret,
|
||||||
|
status: 'pending',
|
||||||
|
createdAt: now,
|
||||||
|
}).run();
|
||||||
|
|
||||||
|
const instanceName = db
|
||||||
|
.select({ name: schema.instanceSettings.instanceName })
|
||||||
|
.from(schema.instanceSettings)
|
||||||
|
.where(eq(schema.instanceSettings.id, 1))
|
||||||
|
.get()?.name ?? undefined;
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(`${approvalReq.origin}/api/federation/peer/accept`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
sourceOrigin: localOrigin,
|
||||||
|
hmacSecret,
|
||||||
|
instanceName,
|
||||||
|
instanceId: getInstanceId(),
|
||||||
|
// No approvalToken — outbound rows are admin-initiated locally; we
|
||||||
|
// hold no prior token from the remote and rely on the remote's own
|
||||||
|
// autoAcceptPeering setting to decide 200 vs 202.
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
|
});
|
||||||
|
} catch (err: unknown) {
|
||||||
|
db.delete(schema.federationPeers)
|
||||||
|
.where(eq(schema.federationPeers.id, peerId))
|
||||||
|
.run();
|
||||||
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
if (err instanceof DOMException && err.name === 'TimeoutError') {
|
||||||
|
return reply.code(504).send({
|
||||||
|
error: 'Remote instance did not respond within 10 seconds',
|
||||||
|
statusCode: 504,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return reply.code(503).send({
|
||||||
|
error: `Remote instance unreachable: ${message}`,
|
||||||
|
statusCode: 503,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 202) {
|
||||||
|
// Remote also gates new peers. Capture the approval token they returned
|
||||||
|
// so the next inbound /peer/accept (when the remote admin approves) can
|
||||||
|
// verify mutual admin approval. The outbound queue row and its
|
||||||
|
// subscribers REMAIN — `onPeerActivated` is NOT called here; subscribers
|
||||||
|
// wait for the eventual activation (fanout happens then).
|
||||||
|
let returnedToken: string | null = null;
|
||||||
|
try {
|
||||||
|
const body = (await response.json()) as { approvalToken?: string };
|
||||||
|
if (typeof body?.approvalToken === 'string' && body.approvalToken.length > 0) {
|
||||||
|
returnedToken = body.approvalToken;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Non-JSON / empty body — legacy peer with no token to capture.
|
||||||
|
}
|
||||||
|
|
||||||
|
db.update(schema.federationPeers)
|
||||||
|
.set({ status: 'awaiting_approval', approvalToken: returnedToken })
|
||||||
|
.where(eq(schema.federationPeers.id, peerId))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
|
||||||
|
|
||||||
|
const peer = db
|
||||||
|
.select()
|
||||||
|
.from(schema.federationPeers)
|
||||||
|
.where(eq(schema.federationPeers.id, peerId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
return reply.code(200).send({
|
||||||
|
success: true,
|
||||||
|
peerStatus: 'awaiting_approval' as const,
|
||||||
|
awaitingRemoteApproval: true,
|
||||||
|
message: 'Remote instance also requires admin approval. Your request has been queued on their side.',
|
||||||
|
peer: peer ? sanitizePeer(peer) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let errorMessage = `Remote instance rejected handshake (HTTP ${response.status})`;
|
||||||
|
try {
|
||||||
|
const body = await response.json() as { error?: string };
|
||||||
|
if (body.error) errorMessage = body.error;
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
|
// Clean up the peer row we created. Leave the outbound queue row alone
|
||||||
|
// so the admin can retry without re-collecting subscribers.
|
||||||
|
db.delete(schema.federationPeers)
|
||||||
|
.where(eq(schema.federationPeers.id, peerId))
|
||||||
|
.run();
|
||||||
|
return reply.code(502).send({
|
||||||
|
error: errorMessage,
|
||||||
|
statusCode: 502,
|
||||||
|
remoteStatus: response.status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 200 — peer activated. Capture remote's instanceName for the friendly label
|
||||||
|
// and instanceId (epoch) for the authenticated baseline.
|
||||||
|
let remoteInstanceName: string | null = approvalReq.instanceName;
|
||||||
|
let remoteInstanceId: string | null = null;
|
||||||
|
try {
|
||||||
|
const body = (await response.json()) as { instanceName?: string | null; instanceId?: string | null };
|
||||||
|
if (typeof body?.instanceName === 'string' && body.instanceName.length > 0) {
|
||||||
|
remoteInstanceName = body.instanceName;
|
||||||
|
}
|
||||||
|
if (typeof body?.instanceId === 'string' && body.instanceId.length > 0) {
|
||||||
|
remoteInstanceId = body.instanceId;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Non-JSON body — keep approvalReq.instanceName (may be null).
|
||||||
|
}
|
||||||
|
|
||||||
|
db.update(schema.federationPeers)
|
||||||
|
.set({
|
||||||
|
status: 'active',
|
||||||
|
lastSeenAt: now,
|
||||||
|
instanceName: remoteInstanceName,
|
||||||
|
peerInstanceId: remoteInstanceId,
|
||||||
|
approvalToken: null,
|
||||||
|
})
|
||||||
|
.where(eq(schema.federationPeers.id, peerId))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
|
||||||
|
|
||||||
|
// onPeerActivated runs fanoutOutboundSubscribers (Task 6) which:
|
||||||
|
// - inserts kind='approved' notifications for each subscriber,
|
||||||
|
// - sends `peering_notification_received` WS to each subscriber,
|
||||||
|
// - cascade-deletes the parent + subscriber rows.
|
||||||
|
// Do NOT duplicate any of that here — it would double-notify and corrupt
|
||||||
|
// the queue.
|
||||||
|
onPeerActivated(peerId, 'approval_handshake').catch(err =>
|
||||||
|
console.error('[federation] onPeerActivated from outbound /approval-requests/:id/approve failed:', err)
|
||||||
|
);
|
||||||
|
|
||||||
|
const peer = db
|
||||||
|
.select()
|
||||||
|
.from(schema.federationPeers)
|
||||||
|
.where(eq(schema.federationPeers.id, peerId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
return reply.code(200).send({
|
||||||
|
success: true,
|
||||||
|
peerStatus: 'active' as const,
|
||||||
|
peer: peer ? sanitizePeer(peer) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inbound deny — admin rejects a remote instance's peering request. Fires
|
||||||
|
* the existing /peer/denied notification to the remote, marks any local
|
||||||
|
* peer row as `rejected`, and clears the queue row. Preserves historical
|
||||||
|
* behavior verbatim.
|
||||||
|
*/
|
||||||
|
export async function handleInboundDeny(
|
||||||
|
approvalReq: typeof schema.peerApprovalRequests.$inferSelect,
|
||||||
|
reply: FastifyReply,
|
||||||
|
): Promise<FastifyReply> {
|
||||||
|
const db = getDb();
|
||||||
|
const id = approvalReq.id;
|
||||||
|
|
||||||
|
// Inbound rows always carry hmacSecret (CHECK constraint enforces this).
|
||||||
|
// If it's somehow null, we cannot sign /peer/denied — surface clearly.
|
||||||
|
if (!approvalReq.hmacSecret) {
|
||||||
|
return reply.code(500).send({
|
||||||
|
error: 'Inbound approval request is missing hmacSecret — cannot deliver /peer/denied notification.',
|
||||||
|
statusCode: 500,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ourOrigin = getOurOrigin();
|
||||||
|
const denialBody = JSON.stringify({
|
||||||
|
origin: ourOrigin,
|
||||||
|
reason: 'denied_by_admin' as const,
|
||||||
|
message: 'Request denied by admin',
|
||||||
|
});
|
||||||
|
|
||||||
|
const headers = buildFederationHeaders(denialBody, approvalReq.hmacSecret, ourOrigin);
|
||||||
|
|
||||||
|
let notificationSent = false;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${approvalReq.origin}/api/federation/peer/denied`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: denialBody,
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
|
});
|
||||||
|
notificationSent = response.ok;
|
||||||
|
} catch {
|
||||||
|
// Network error
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!notificationSent) {
|
||||||
|
return reply.code(502).send({
|
||||||
|
error: 'Denial notification could not be delivered to the remote instance. The request is still pending — you can retry or wait for it to expire.',
|
||||||
|
statusCode: 502,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingPeer = db
|
||||||
|
.select()
|
||||||
|
.from(schema.federationPeers)
|
||||||
|
.where(eq(schema.federationPeers.origin, approvalReq.origin))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!existingPeer) {
|
||||||
|
db.insert(schema.federationPeers).values({
|
||||||
|
id: generateSnowflake(),
|
||||||
|
origin: approvalReq.origin,
|
||||||
|
instanceName: approvalReq.instanceName,
|
||||||
|
hmacSecret: approvalReq.hmacSecret,
|
||||||
|
status: 'rejected',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
}).run();
|
||||||
|
} else if (existingPeer.status !== 'active') {
|
||||||
|
db.update(schema.federationPeers)
|
||||||
|
.set({ status: 'rejected' })
|
||||||
|
.where(eq(schema.federationPeers.id, existingPeer.id))
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
|
||||||
|
|
||||||
|
db.delete(schema.peerApprovalRequests)
|
||||||
|
.where(eq(schema.peerApprovalRequests.id, id))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
return reply.code(200).send({ success: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outbound deny — admin refuses local users' peering request. Fans out
|
||||||
|
* `kind='denied'` notifications to each subscriber and cascade-deletes the
|
||||||
|
* parent (which clears subscribers via FK cascade). No remote network call
|
||||||
|
* — outbound rows have no /peer/denied counterpart on the wire (the remote
|
||||||
|
* never knew we were considering this).
|
||||||
|
*/
|
||||||
|
export async function handleOutboundDeny(
|
||||||
|
approvalReq: typeof schema.peerApprovalRequests.$inferSelect,
|
||||||
|
reply: FastifyReply,
|
||||||
|
): Promise<FastifyReply> {
|
||||||
|
const db = getDb();
|
||||||
|
const subscribers = db
|
||||||
|
.select()
|
||||||
|
.from(schema.peerApprovalSubscribers)
|
||||||
|
.where(eq(schema.peerApprovalSubscribers.requestId, approvalReq.id))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
for (const sub of subscribers) {
|
||||||
|
db.insert(schema.peerApprovalNotifications)
|
||||||
|
.values({
|
||||||
|
id: generateSnowflake(),
|
||||||
|
userId: sub.userId,
|
||||||
|
kind: 'denied',
|
||||||
|
peerOrigin: approvalReq.origin,
|
||||||
|
triggerReason: sub.triggerReason,
|
||||||
|
triggerTarget: sub.triggerTarget,
|
||||||
|
createdAt: now,
|
||||||
|
readAt: null,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
connectionManager.sendToUser(sub.userId, {
|
||||||
|
type: 'peering_notification_received' as const,
|
||||||
|
kind: 'denied',
|
||||||
|
});
|
||||||
|
// Subscriber row is about to cascade-delete; refresh the user's pending list.
|
||||||
|
connectionManager.sendToUser(sub.userId, {
|
||||||
|
type: 'peering_subscription_changed' as const,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cascade-delete clears subscribers via onDelete: 'cascade'.
|
||||||
|
db.delete(schema.peerApprovalRequests)
|
||||||
|
.where(eq(schema.peerApprovalRequests.id, approvalReq.id))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// Tell admins the queue changed.
|
||||||
|
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
|
||||||
|
|
||||||
|
if (subscribers.length > 0) {
|
||||||
|
console.log(
|
||||||
|
`[federation] handleOutboundDeny denied ${subscribers.length} subscriber notification${subscribers.length === 1 ? '' : 's'} for ${approvalReq.origin}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.code(200).send({ success: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function registerApprovalRoutes(app: FastifyInstance): void {
|
||||||
|
// ─── GET /api/federation/approval-requests ─────────────────────────────────
|
||||||
|
app.get(
|
||||||
|
'/api/federation/approval-requests',
|
||||||
|
{ preHandler: [authenticate, requireAdmin] },
|
||||||
|
async (_request, reply) => {
|
||||||
|
const db = getDb();
|
||||||
|
const requests = db
|
||||||
|
.select({
|
||||||
|
id: schema.peerApprovalRequests.id,
|
||||||
|
origin: schema.peerApprovalRequests.origin,
|
||||||
|
direction: schema.peerApprovalRequests.direction,
|
||||||
|
instanceName: schema.peerApprovalRequests.instanceName,
|
||||||
|
requestedAt: schema.peerApprovalRequests.requestedAt,
|
||||||
|
expiresAt: schema.peerApprovalRequests.expiresAt,
|
||||||
|
})
|
||||||
|
.from(schema.peerApprovalRequests)
|
||||||
|
.orderBy(desc(schema.peerApprovalRequests.requestedAt))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
// For outbound rows, fetch subscriber summaries (joined with users for username).
|
||||||
|
// Inbound rows have no subscriber concept; field is omitted in their response.
|
||||||
|
const outboundIds = requests.filter(r => r.direction === 'outbound').map(r => r.id);
|
||||||
|
const subscribersByRequestId = new Map<string, ApprovalRequestSubscriberSummary[]>();
|
||||||
|
if (outboundIds.length > 0) {
|
||||||
|
const rows = db
|
||||||
|
.select({
|
||||||
|
requestId: schema.peerApprovalSubscribers.requestId,
|
||||||
|
userId: schema.peerApprovalSubscribers.userId,
|
||||||
|
username: schema.users.username,
|
||||||
|
triggerReason: schema.peerApprovalSubscribers.triggerReason,
|
||||||
|
triggerTarget: schema.peerApprovalSubscribers.triggerTarget,
|
||||||
|
})
|
||||||
|
.from(schema.peerApprovalSubscribers)
|
||||||
|
.innerJoin(schema.users, eq(schema.users.id, schema.peerApprovalSubscribers.userId))
|
||||||
|
.where(inArray(schema.peerApprovalSubscribers.requestId, outboundIds))
|
||||||
|
.all();
|
||||||
|
for (const row of rows) {
|
||||||
|
const arr = subscribersByRequestId.get(row.requestId) ?? [];
|
||||||
|
arr.push({
|
||||||
|
userId: row.userId,
|
||||||
|
username: row.username,
|
||||||
|
triggerReason: row.triggerReason as PeeringTriggerReason,
|
||||||
|
triggerTarget: row.triggerTarget,
|
||||||
|
});
|
||||||
|
subscribersByRequestId.set(row.requestId, arr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.code(200).send({
|
||||||
|
requests: requests.map(r =>
|
||||||
|
r.direction === 'outbound'
|
||||||
|
? { ...r, subscribers: subscribersByRequestId.get(r.id) ?? [] }
|
||||||
|
: r,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── POST /api/federation/approval-requests/:id/approve ───────────────────
|
||||||
|
// Direction-branched: inbound rows complete the existing accept-handshake
|
||||||
|
// path (preserved verbatim); outbound rows initiate /peer/accept against
|
||||||
|
// the remote, capturing 200/202 outcomes and leaving the queue intact on
|
||||||
|
// failure so the admin can retry.
|
||||||
|
app.post<{ Params: { id: string } }>(
|
||||||
|
'/api/federation/approval-requests/:id/approve',
|
||||||
|
{ preHandler: [authenticate, requireAdmin] },
|
||||||
|
async (request, reply) => {
|
||||||
|
const db = getDb();
|
||||||
|
const { id } = request.params;
|
||||||
|
|
||||||
|
const approvalReq = db
|
||||||
|
.select()
|
||||||
|
.from(schema.peerApprovalRequests)
|
||||||
|
.where(eq(schema.peerApprovalRequests.id, id))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!approvalReq) {
|
||||||
|
return reply.code(404).send({ error: 'Approval request not found', statusCode: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let localOrigin: string;
|
||||||
|
try {
|
||||||
|
localOrigin = resolveLocalOrigin();
|
||||||
|
} catch {
|
||||||
|
return reply.code(500).send({
|
||||||
|
error: 'Cannot determine local instance origin. Set the DOMAIN environment variable.',
|
||||||
|
statusCode: 500,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (approvalReq.direction === 'outbound') {
|
||||||
|
return await handleOutboundApprove(approvalReq, localOrigin, reply);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await handleInboundApprove(approvalReq, localOrigin, reply);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── POST /api/federation/approval-requests/:id/deny ───────────────────────
|
||||||
|
// Direction-branched: inbound rows hit the remote's /peer/denied endpoint
|
||||||
|
// (existing behavior preserved); outbound rows fan out denied notifications
|
||||||
|
// to subscribers and cascade-delete the queue row.
|
||||||
|
app.post<{ Params: { id: string } }>(
|
||||||
|
'/api/federation/approval-requests/:id/deny',
|
||||||
|
{ preHandler: [authenticate, requireAdmin] },
|
||||||
|
async (request, reply) => {
|
||||||
|
const db = getDb();
|
||||||
|
const { id } = request.params;
|
||||||
|
|
||||||
|
const approvalReq = db
|
||||||
|
.select()
|
||||||
|
.from(schema.peerApprovalRequests)
|
||||||
|
.where(eq(schema.peerApprovalRequests.id, id))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!approvalReq) {
|
||||||
|
return reply.code(404).send({ error: 'Approval request not found', statusCode: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (approvalReq.direction === 'outbound') {
|
||||||
|
return await handleOutboundDeny(approvalReq, reply);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await handleInboundDeny(approvalReq, reply);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── GET /api/federation/peering-subscriptions ─────────────────────────────
|
||||||
|
// User-facing: list the requesting user's pending outbound peering
|
||||||
|
// subscriber rows joined to their parent peer_approval_requests. Used by the
|
||||||
|
// pending-peering UI surface to show "you have a peering with X waiting on
|
||||||
|
// your admin's approval" rows.
|
||||||
|
app.get(
|
||||||
|
'/api/federation/peering-subscriptions',
|
||||||
|
{ preHandler: [authenticate] },
|
||||||
|
async (request, reply) => {
|
||||||
|
const db = getDb();
|
||||||
|
const userId = request.userId;
|
||||||
|
const rows = db
|
||||||
|
.select({
|
||||||
|
id: schema.peerApprovalSubscribers.id,
|
||||||
|
requestId: schema.peerApprovalSubscribers.requestId,
|
||||||
|
peerOrigin: schema.peerApprovalRequests.origin,
|
||||||
|
peerInstanceName: schema.peerApprovalRequests.instanceName,
|
||||||
|
triggerReason: schema.peerApprovalSubscribers.triggerReason,
|
||||||
|
triggerTarget: schema.peerApprovalSubscribers.triggerTarget,
|
||||||
|
createdAt: schema.peerApprovalSubscribers.createdAt,
|
||||||
|
})
|
||||||
|
.from(schema.peerApprovalSubscribers)
|
||||||
|
.innerJoin(
|
||||||
|
schema.peerApprovalRequests,
|
||||||
|
eq(schema.peerApprovalRequests.id, schema.peerApprovalSubscribers.requestId),
|
||||||
|
)
|
||||||
|
.where(eq(schema.peerApprovalSubscribers.userId, userId))
|
||||||
|
.orderBy(desc(schema.peerApprovalSubscribers.createdAt))
|
||||||
|
.all();
|
||||||
|
return reply.send({ subscriptions: rows });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── DELETE /api/federation/peering-subscriptions/:id ──────────────────────
|
||||||
|
// User-facing: cancel one of the requesting user's pending peering
|
||||||
|
// subscriptions. Authorization: subscriber.userId must match request.userId.
|
||||||
|
// If this was the last subscriber for its parent request, the parent
|
||||||
|
// cascade-deletes too (avoids zombie outbound rows in the admin queue).
|
||||||
|
// No notification is created for the canceller (per spec §4.3 (iii)).
|
||||||
|
app.delete<{ Params: { id: string } }>(
|
||||||
|
'/api/federation/peering-subscriptions/:id',
|
||||||
|
{ preHandler: [authenticate] },
|
||||||
|
async (request, reply) => {
|
||||||
|
const db = getDb();
|
||||||
|
const { id } = request.params;
|
||||||
|
const userId = request.userId;
|
||||||
|
|
||||||
|
const sub = db
|
||||||
|
.select()
|
||||||
|
.from(schema.peerApprovalSubscribers)
|
||||||
|
.where(eq(schema.peerApprovalSubscribers.id, id))
|
||||||
|
.get();
|
||||||
|
if (!sub) {
|
||||||
|
return reply.code(404).send({ error: 'subscription_not_found', statusCode: 404 });
|
||||||
|
}
|
||||||
|
if (sub.userId !== userId) {
|
||||||
|
return reply.code(403).send({ error: 'forbidden', statusCode: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.delete(schema.peerApprovalSubscribers)
|
||||||
|
.where(eq(schema.peerApprovalSubscribers.id, id))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// If the row we just removed was the last subscriber on its parent
|
||||||
|
// peer_approval_request, cascade-delete the parent. The admin queue
|
||||||
|
// refreshes via federation_peers_changed.
|
||||||
|
const remaining = db
|
||||||
|
.select({ id: schema.peerApprovalSubscribers.id })
|
||||||
|
.from(schema.peerApprovalSubscribers)
|
||||||
|
.where(eq(schema.peerApprovalSubscribers.requestId, sub.requestId))
|
||||||
|
.all();
|
||||||
|
if (remaining.length === 0) {
|
||||||
|
db.delete(schema.peerApprovalRequests)
|
||||||
|
.where(eq(schema.peerApprovalRequests.id, sub.requestId))
|
||||||
|
.run();
|
||||||
|
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
|
||||||
|
}
|
||||||
|
|
||||||
|
connectionManager.sendToUser(userId, { type: 'peering_subscription_changed' as const });
|
||||||
|
return reply.send({ success: true });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── GET /api/federation/peering-notifications ─────────────────────────────
|
||||||
|
// User-facing: list the requesting user's terminal-state peering
|
||||||
|
// notifications (kind='approved'|'denied'|'expired'). Optional ?unread=1
|
||||||
|
// filter narrows to rows where readAt IS NULL. Ordered DESC by createdAt
|
||||||
|
// (newest first).
|
||||||
|
app.get<{ Querystring: { unread?: string } }>(
|
||||||
|
'/api/federation/peering-notifications',
|
||||||
|
{ preHandler: [authenticate] },
|
||||||
|
async (request, reply) => {
|
||||||
|
const db = getDb();
|
||||||
|
const userId = request.userId;
|
||||||
|
const unread = request.query?.unread === '1';
|
||||||
|
|
||||||
|
const whereClause = unread
|
||||||
|
? and(
|
||||||
|
eq(schema.peerApprovalNotifications.userId, userId),
|
||||||
|
isNull(schema.peerApprovalNotifications.readAt),
|
||||||
|
)
|
||||||
|
: eq(schema.peerApprovalNotifications.userId, userId);
|
||||||
|
|
||||||
|
const notifications = db
|
||||||
|
.select({
|
||||||
|
id: schema.peerApprovalNotifications.id,
|
||||||
|
kind: schema.peerApprovalNotifications.kind,
|
||||||
|
peerOrigin: schema.peerApprovalNotifications.peerOrigin,
|
||||||
|
triggerReason: schema.peerApprovalNotifications.triggerReason,
|
||||||
|
triggerTarget: schema.peerApprovalNotifications.triggerTarget,
|
||||||
|
createdAt: schema.peerApprovalNotifications.createdAt,
|
||||||
|
readAt: schema.peerApprovalNotifications.readAt,
|
||||||
|
})
|
||||||
|
.from(schema.peerApprovalNotifications)
|
||||||
|
.where(whereClause)
|
||||||
|
.orderBy(desc(schema.peerApprovalNotifications.createdAt))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
return reply.send({ notifications });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── POST /api/federation/peering-notifications/:id/read ───────────────────
|
||||||
|
// User-facing: mark a single peering notification as read. Authorization:
|
||||||
|
// notification.userId must match request.userId.
|
||||||
|
app.post<{ Params: { id: string } }>(
|
||||||
|
'/api/federation/peering-notifications/:id/read',
|
||||||
|
{ preHandler: [authenticate] },
|
||||||
|
async (request, reply) => {
|
||||||
|
const db = getDb();
|
||||||
|
const { id } = request.params;
|
||||||
|
const userId = request.userId;
|
||||||
|
|
||||||
|
const notif = db
|
||||||
|
.select()
|
||||||
|
.from(schema.peerApprovalNotifications)
|
||||||
|
.where(eq(schema.peerApprovalNotifications.id, id))
|
||||||
|
.get();
|
||||||
|
if (!notif) {
|
||||||
|
return reply.code(404).send({ error: 'notification_not_found', statusCode: 404 });
|
||||||
|
}
|
||||||
|
if (notif.userId !== userId) {
|
||||||
|
return reply.code(403).send({ error: 'forbidden', statusCode: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.update(schema.peerApprovalNotifications)
|
||||||
|
.set({ readAt: Date.now() })
|
||||||
|
.where(eq(schema.peerApprovalNotifications.id, id))
|
||||||
|
.run();
|
||||||
|
return reply.send({ success: true });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── POST /api/federation/peering-notifications/read-all ───────────────────
|
||||||
|
// User-facing: mark all the requesting user's unread peering notifications
|
||||||
|
// as read. Already-read rows are NOT touched (their readAt is preserved).
|
||||||
|
// Returns the count of rows affected for UI feedback.
|
||||||
|
app.post(
|
||||||
|
'/api/federation/peering-notifications/read-all',
|
||||||
|
{ preHandler: [authenticate] },
|
||||||
|
async (request, reply) => {
|
||||||
|
const db = getDb();
|
||||||
|
const userId = request.userId;
|
||||||
|
const result = db
|
||||||
|
.update(schema.peerApprovalNotifications)
|
||||||
|
.set({ readAt: Date.now() })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.peerApprovalNotifications.userId, userId),
|
||||||
|
isNull(schema.peerApprovalNotifications.readAt),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
return reply.send({ success: true, count: result.changes });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
import { config } from '../../../config.js';
|
||||||
|
import { getDb, getRawDb, schema } from '../../../db/index.js';
|
||||||
|
import { authenticate } from '../../../utils/auth.js';
|
||||||
|
import { fetchHomeProfileByHomeId, verifyAttachProofWithPeer } from '../../../utils/federationAttach.js';
|
||||||
|
import { sendSignedJson } from './signedResponse.js';
|
||||||
|
import { sanitizeUser } from '../../../utils/sanitize.js';
|
||||||
|
import { collectProfileBroadcastTargetIds } from '../../../utils/userDeletion.js';
|
||||||
|
import { connectionManager } from '../../../ws/handler.js';
|
||||||
|
import { and, eq, isNull, or } from 'drizzle-orm';
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import type { DmChannel } from '@backspace/shared';
|
||||||
|
import type { FastifyInstance, FastifyReply } from 'fastify';
|
||||||
|
import { buildDmChannelPayload } from '../dmChannels.js';
|
||||||
|
import { extractDomain } from '../identity.js';
|
||||||
|
import { downloadProfileAsset } from '../profile.js';
|
||||||
|
import { isLookupRateLimited } from '../rateLimits.js';
|
||||||
|
import { authenticateS2SPeer } from './s2sAuth.js';
|
||||||
|
import { reconcileDmChannelFederatedId } from '../reconciliation.js';
|
||||||
|
import type { DmReconcileResult } from '../reconciliation.js';
|
||||||
|
|
||||||
|
export function registerAttachRoutes(app: FastifyInstance): void {
|
||||||
|
// ─── POST /api/federation/verify-attach-proof ───────────────────────────────
|
||||||
|
// Server-to-server: verify a one-time attach-proof token minted by
|
||||||
|
// /api/auth/attach-proof (re-attach spec §3.1). The token is single-use (an
|
||||||
|
// atomic claim guarantees only one concurrent verification can win) and is
|
||||||
|
// bound to the CALLING peer's domain — the binding is checked against the
|
||||||
|
// authenticated peer row (extractDomain(peer.origin)), NEVER trusted from the
|
||||||
|
// request body. This is the anti-replay control: a compromised requester
|
||||||
|
// cannot redeem a token minted for a different peer. The response is HMAC-
|
||||||
|
// signed (epoch pattern) so the caller can trust the identity it carries; all
|
||||||
|
// failure modes fail closed to a signed { valid: false }.
|
||||||
|
app.post<{ Body: { token?: unknown } }>(
|
||||||
|
'/api/federation/verify-attach-proof',
|
||||||
|
{ bodyLimit: 4 * 1024 },
|
||||||
|
async (request, reply) => {
|
||||||
|
const db = getDb();
|
||||||
|
const rawDb = getRawDb();
|
||||||
|
|
||||||
|
// Shared inbound S2S-auth preamble: headers → active peer → rate limit →
|
||||||
|
// signature → nonce replay. Shares the lookup rate-limit bucket (60/min) by
|
||||||
|
// design (this is the same friend-request-originator flow as /users/lookup),
|
||||||
|
// running BEFORE signature with `Retry-After: 60`; no missing-nonce warning.
|
||||||
|
const auth = authenticateS2SPeer(request, reply, {
|
||||||
|
rateLimiter: { limited: isLookupRateLimited, retryAfterSeconds: 60 },
|
||||||
|
});
|
||||||
|
if (!auth.ok) return;
|
||||||
|
const { peer } = auth;
|
||||||
|
|
||||||
|
// 2. Sign every downstream response with the peer's shared secret so the
|
||||||
|
// caller can trust the identity (or the fail-closed verdict) it carries.
|
||||||
|
const sendSigned = (payload: { valid: false } | { valid: true; homeUserId: string; username: string }): FastifyReply =>
|
||||||
|
sendSignedJson(reply, payload, peer.hmacSecret);
|
||||||
|
|
||||||
|
// 3. Validate the token shape (64 hex chars, as minted by attach-proof).
|
||||||
|
const rawToken = (request.body as { token?: unknown } | null)?.token;
|
||||||
|
if (typeof rawToken !== 'string' || !/^[0-9a-f]{64}$/i.test(rawToken)) {
|
||||||
|
return sendSigned({ valid: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Atomic single-use claim. The domain binding is server-side: the
|
||||||
|
// token's target_domain must equal the AUTHENTICATED peer's domain, never
|
||||||
|
// a value from the request body. Concurrent verifications cannot both win
|
||||||
|
// because only the first UPDATE that flips used_at from NULL matches.
|
||||||
|
const peerDomain = extractDomain(peer.origin).toLowerCase();
|
||||||
|
const now = Date.now();
|
||||||
|
const claimed = rawDb.prepare(`
|
||||||
|
UPDATE federation_attach_proofs
|
||||||
|
SET used_at = ?
|
||||||
|
WHERE token = ? AND used_at IS NULL AND expires_at > ? AND lower(target_domain) = ?
|
||||||
|
RETURNING home_user_id
|
||||||
|
`).get(now, rawToken, now, peerDomain) as { home_user_id: string } | undefined;
|
||||||
|
|
||||||
|
if (!claimed) {
|
||||||
|
return sendSigned({ valid: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Re-confirm the home user is still native (not tombstoned, not turned
|
||||||
|
// into a replicated stub) since the token was minted.
|
||||||
|
const homeUser = db
|
||||||
|
.select()
|
||||||
|
.from(schema.users)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.users.id, claimed.home_user_id),
|
||||||
|
eq(schema.users.isDeleted, 0),
|
||||||
|
isNull(schema.users.homeInstance),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!homeUser) {
|
||||||
|
return sendSigned({ valid: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
return sendSigned({ valid: true, homeUserId: homeUser.id, username: homeUser.username });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── POST /api/users/@me/reattach ────────────────────────────────────────────
|
||||||
|
// Owner-initiated exception to the detach invariant (re-attach spec §3.2).
|
||||||
|
// Requires BOTH identities: the session proves the detached account (local
|
||||||
|
// password authority), the one-time token — verified with the home peer over
|
||||||
|
// signed S2S — proves the new home account. Registered here rather than in
|
||||||
|
// users.ts because it consumes federation-internal machinery (peer HMAC
|
||||||
|
// channel, profile fetch, asset download). URL path stays /api/users/@me/*.
|
||||||
|
app.post<{ Body: { token?: unknown } }>('/api/users/@me/reattach', {
|
||||||
|
preHandler: authenticate,
|
||||||
|
config: { rateLimit: { max: 5, timeWindow: '15 minutes' } },
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const db = getDb();
|
||||||
|
const rawDb = getRawDb();
|
||||||
|
|
||||||
|
const rawToken = (request.body as { token?: unknown } | null)?.token;
|
||||||
|
if (typeof rawToken !== 'string' || !/^[0-9a-f]{64}$/i.test(rawToken)) {
|
||||||
|
return reply.code(400).send({ error: 'token is required (64-char hex)', statusCode: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guard 1: session user must be a LIVE detached federated account. A missing
|
||||||
|
// or tombstoned row is a 404 (nothing to re-attach); a live non-detached /
|
||||||
|
// native account is a 403 (re-attach is meaningless — it already syncs).
|
||||||
|
const detached = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
|
||||||
|
if (!detached || detached.isDeleted === 1) {
|
||||||
|
return reply.code(404).send({ error: 'User not found', statusCode: 404 });
|
||||||
|
}
|
||||||
|
if (!detached.homeInstance || detached.federationHomeOrphaned !== 1) {
|
||||||
|
return reply.code(403).send({ error: 'Only detached accounts can re-attach', statusCode: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guard 2: the home domain must be an ACTIVE peer — the proof is only as
|
||||||
|
// trustworthy as the S2S channel it is verified over.
|
||||||
|
const homeDomain = extractDomain(detached.homeInstance).toLowerCase();
|
||||||
|
const normPeer = (origin: string) => extractDomain(origin).toLowerCase();
|
||||||
|
const peerRow = db.select().from(schema.federationPeers).all()
|
||||||
|
.find(p => normPeer(p.origin) === homeDomain && p.status === 'active');
|
||||||
|
if (!peerRow) {
|
||||||
|
return reply.code(409).send({ error: 'Home instance is not an active peer', statusCode: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guard 3: verify the one-time proof with the home instance (fails closed).
|
||||||
|
const verified = await verifyAttachProofWithPeer(peerRow, rawToken);
|
||||||
|
if (!verified.valid) {
|
||||||
|
return reply.code(401).send({ error: 'Attach proof could not be verified', statusCode: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guard 4: if the new identity already has a local row for this domain, it
|
||||||
|
// MUST be a replicated stub (the merge source, §3.3). A real account holding
|
||||||
|
// it means state corruption — abort loudly, do not merge.
|
||||||
|
const normHome = `lower(replace(replace(coalesce(home_instance, ''), 'https://', ''), 'http://', ''))`;
|
||||||
|
const existingRow = rawDb.prepare(`
|
||||||
|
SELECT id, password_hash FROM users
|
||||||
|
WHERE home_user_id = ? AND ${normHome} = ? AND is_deleted = 0 AND id != ?
|
||||||
|
`).get(verified.homeUserId, homeDomain, detached.id) as { id: string; password_hash: string } | undefined;
|
||||||
|
if (existingRow && existingRow.password_hash !== '!federation-replicated') {
|
||||||
|
console.error(`[federation] Re-attach conflict: identity ${verified.homeUserId}@${homeDomain} held by non-stub account ${existingRow.id}`);
|
||||||
|
return reply.code(409).send({ error: 'The new identity is already bound to another account on this instance', statusCode: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Username: adopt the new home base when it differs (existing collision-suffix
|
||||||
|
// scheme). Usernames are not identity, so a base match keeps the current handle.
|
||||||
|
const currentBase = detached.username.includes('@')
|
||||||
|
? detached.username.slice(0, detached.username.indexOf('@'))
|
||||||
|
: detached.username;
|
||||||
|
let newUsername = detached.username;
|
||||||
|
const newBase = verified.username.toLowerCase();
|
||||||
|
if (newBase !== currentBase.toLowerCase()) {
|
||||||
|
let candidate = `${newBase}@${homeDomain}`;
|
||||||
|
let attempt = 0;
|
||||||
|
while (rawDb.prepare(`SELECT 1 FROM users WHERE username = ? AND id != ?`).get(candidate, detached.id)) {
|
||||||
|
attempt++;
|
||||||
|
candidate = `${newBase}_${attempt}@${homeDomain}`;
|
||||||
|
if (attempt > 10) {
|
||||||
|
candidate = `${newBase}_${randomBytes(4).toString('hex')}@${homeDomain}`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
newUsername = candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge + re-bind, atomically. All users.id FK repointing lives here; dedupe
|
||||||
|
// rows that would collide on a composite PK / unique index BEFORE repointing
|
||||||
|
// (spec §3.3). The stub row is the only source — a real account holding the
|
||||||
|
// identity was already rejected by guard 4.
|
||||||
|
const dmReconcileResults: DmReconcileResult[] = [];
|
||||||
|
rawDb.transaction(() => {
|
||||||
|
if (existingRow) {
|
||||||
|
const stubId = existingRow.id;
|
||||||
|
const targetId = detached.id;
|
||||||
|
// dm_members (composite PK dm_channel_id+user_id → dedupe): drop the
|
||||||
|
// stub's membership where the detached row is already a member.
|
||||||
|
rawDb.prepare(`DELETE FROM dm_members WHERE user_id = ? AND dm_channel_id IN (SELECT dm_channel_id FROM dm_members WHERE user_id = ?)`).run(stubId, targetId);
|
||||||
|
rawDb.prepare(`UPDATE dm_members SET user_id = ? WHERE user_id = ?`).run(targetId, stubId);
|
||||||
|
// dm_messages / messages (RESTRICT FK, no unique on user_id → straight repoint).
|
||||||
|
rawDb.prepare(`UPDATE dm_messages SET user_id = ? WHERE user_id = ?`).run(targetId, stubId);
|
||||||
|
rawDb.prepare(`UPDATE messages SET user_id = ? WHERE user_id = ?`).run(targetId, stubId);
|
||||||
|
// attachments.uploader_id (plain text column, NO FK, no unique → straight
|
||||||
|
// repoint). A replicated stub that uploaded a DM/channel attachment would
|
||||||
|
// otherwise leave uploader_id dangling at the deleted stub's id — broken
|
||||||
|
// attribution.
|
||||||
|
rawDb.prepare(`UPDATE attachments SET uploader_id = ? WHERE uploader_id = ?`).run(targetId, stubId);
|
||||||
|
// dm_reactions (dedupe on dm_message_id+emoji per user).
|
||||||
|
rawDb.prepare(`DELETE FROM dm_reactions WHERE user_id = ? AND EXISTS (SELECT 1 FROM dm_reactions r2 WHERE r2.user_id = ? AND r2.dm_message_id = dm_reactions.dm_message_id AND r2.emoji = dm_reactions.emoji)`).run(stubId, targetId);
|
||||||
|
rawDb.prepare(`UPDATE dm_reactions SET user_id = ? WHERE user_id = ?`).run(targetId, stubId);
|
||||||
|
// reactions (dedupe on message_id+emoji per user).
|
||||||
|
rawDb.prepare(`DELETE FROM reactions WHERE user_id = ? AND EXISTS (SELECT 1 FROM reactions r2 WHERE r2.user_id = ? AND r2.message_id = reactions.message_id AND r2.emoji = reactions.emoji)`).run(stubId, targetId);
|
||||||
|
rawDb.prepare(`UPDATE reactions SET user_id = ? WHERE user_id = ?`).run(targetId, stubId);
|
||||||
|
// friends (composite PK user_id+friend_id → dedupe both directions, then
|
||||||
|
// repoint, then drop any self-friendship the repoint created).
|
||||||
|
rawDb.prepare(`DELETE FROM friends WHERE user_id = ? AND friend_id IN (SELECT friend_id FROM friends WHERE user_id = ?)`).run(stubId, targetId);
|
||||||
|
rawDb.prepare(`DELETE FROM friends WHERE friend_id = ? AND user_id IN (SELECT user_id FROM friends WHERE friend_id = ?)`).run(stubId, targetId);
|
||||||
|
rawDb.prepare(`UPDATE friends SET user_id = ? WHERE user_id = ?`).run(targetId, stubId);
|
||||||
|
rawDb.prepare(`UPDATE friends SET friend_id = ? WHERE friend_id = ?`).run(targetId, stubId);
|
||||||
|
rawDb.prepare(`DELETE FROM friends WHERE user_id = friend_id`).run();
|
||||||
|
// friend_requests (unique on neither col alone; repoint both, drop self-rows).
|
||||||
|
rawDb.prepare(`UPDATE friend_requests SET from_id = ? WHERE from_id = ?`).run(targetId, stubId);
|
||||||
|
rawDb.prepare(`UPDATE friend_requests SET to_id = ? WHERE to_id = ?`).run(targetId, stubId);
|
||||||
|
rawDb.prepare(`DELETE FROM friend_requests WHERE from_id = to_id`).run();
|
||||||
|
// read_states (composite PK user_id+channel_id → dedupe).
|
||||||
|
rawDb.prepare(`DELETE FROM read_states WHERE user_id = ? AND channel_id IN (SELECT channel_id FROM read_states WHERE user_id = ?)`).run(stubId, targetId);
|
||||||
|
rawDb.prepare(`UPDATE read_states SET user_id = ? WHERE user_id = ?`).run(targetId, stubId);
|
||||||
|
// dm_channels.owner_id (plain text column, NO FK → straight repoint).
|
||||||
|
rawDb.prepare(`UPDATE dm_channels SET owner_id = ? WHERE owner_id = ?`).run(targetId, stubId);
|
||||||
|
rawDb.prepare(`DELETE FROM users WHERE id = ?`).run(stubId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group-DM ownership continuity: channels the OLD identity owned keep
|
||||||
|
// authority under the NEW identity (owner_home_user_id is the S2S
|
||||||
|
// authority key, not a users.id FK).
|
||||||
|
const normOwnerHome = `lower(replace(replace(coalesce(owner_home_instance, ''), 'https://', ''), 'http://', ''))`;
|
||||||
|
rawDb.prepare(`UPDATE dm_channels SET owner_home_user_id = ? WHERE owner_home_user_id = ? AND ${normOwnerHome} = ?`)
|
||||||
|
.run(verified.homeUserId, detached.homeUserId, homeDomain);
|
||||||
|
|
||||||
|
// Re-bind. profile_updated_at is nulled so the home's next profile_update
|
||||||
|
// (any version) tier-1 matches and applies (the accept-and-skip guards
|
||||||
|
// only fire on federation_home_orphaned = 1).
|
||||||
|
rawDb.prepare(`UPDATE users SET home_user_id = ?, federation_home_orphaned = 0, username = ?, profile_updated_at = NULL WHERE id = ?`)
|
||||||
|
.run(verified.homeUserId, newUsername, detached.id);
|
||||||
|
|
||||||
|
// Reconcile the account's 1-on-1 DM channels: the home_user_id just
|
||||||
|
// changed, so every 1-on-1 federatedId derived from it is now stale.
|
||||||
|
// Re-key or merge each into its new-identity channel so history stays a
|
||||||
|
// single conversation (reattach-dm-reconcile spec §3.2). Group DMs (UUID
|
||||||
|
// federatedId / != 2 members) are skipped by the helper.
|
||||||
|
const oneOnOne = rawDb.prepare(`
|
||||||
|
SELECT c.id FROM dm_channels c
|
||||||
|
WHERE c.deleted_at IS NULL
|
||||||
|
AND c.federated_id IS NOT NULL
|
||||||
|
AND EXISTS (SELECT 1 FROM dm_members m WHERE m.dm_channel_id = c.id AND m.user_id = ?)
|
||||||
|
AND (SELECT count(*) FROM dm_members m2 WHERE m2.dm_channel_id = c.id) = 2
|
||||||
|
`).all(detached.id) as Array<{ id: string }>;
|
||||||
|
for (const c of oneOnOne) {
|
||||||
|
// A merge earlier in this loop may have deleted this id — reconcile
|
||||||
|
// returns noop for a missing/mutated channel, so the loop is convergent.
|
||||||
|
const result = reconcileDmChannelFederatedId(rawDb, c.id);
|
||||||
|
if (result.action !== 'noop') dmReconcileResults.push(result);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Best-effort initial profile pull (spec §3.2 step 4). Failure is fine — the
|
||||||
|
// account is re-attached; the next relay fills the profile.
|
||||||
|
const home = await fetchHomeProfileByHomeId(peerRow, verified.homeUserId);
|
||||||
|
if (home) {
|
||||||
|
let avatar: string | null = null;
|
||||||
|
let banner: string | null = null;
|
||||||
|
if (home.profile.avatar) {
|
||||||
|
const url = home.profile.avatar.startsWith('http') ? home.profile.avatar : `${peerRow.origin}/api/uploads/${home.profile.avatar}`;
|
||||||
|
avatar = (await downloadProfileAsset(url, peerRow.origin)) ?? url;
|
||||||
|
}
|
||||||
|
if (home.profile.banner) {
|
||||||
|
const url = home.profile.banner.startsWith('http') ? home.profile.banner : `${peerRow.origin}/api/uploads/${home.profile.banner}`;
|
||||||
|
banner = (await downloadProfileAsset(url, peerRow.origin)) ?? url;
|
||||||
|
}
|
||||||
|
db.update(schema.users).set({
|
||||||
|
displayName: home.profile.displayName ?? home.username,
|
||||||
|
avatar,
|
||||||
|
banner,
|
||||||
|
avatarColor: home.profile.avatarColor ?? detached.avatarColor,
|
||||||
|
bio: home.profile.bio,
|
||||||
|
}).where(eq(schema.users.id, detached.id)).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = db.select().from(schema.users).where(eq(schema.users.id, detached.id)).get()!;
|
||||||
|
console.log(`[federation] Re-attached account ${updated.id} (${updated.username}): ${detached.homeUserId} → ${verified.homeUserId} @ ${homeDomain}`);
|
||||||
|
|
||||||
|
// Broadcast to friends / DM / space co-members + all self connections.
|
||||||
|
const targetIds = collectProfileBroadcastTargetIds(updated.id);
|
||||||
|
targetIds.add(updated.id);
|
||||||
|
for (const uid of targetIds) {
|
||||||
|
connectionManager.sendToUser(uid, { type: 'user_updated' as const, user: sanitizeUser(updated, uid === updated.id) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push DM-list refresh for reconciled channels to affected local members so
|
||||||
|
// the merged/re-keyed conversation replaces the split without a reload
|
||||||
|
// (reattach-dm-reconcile spec §3.4). Reuses existing events, no new type:
|
||||||
|
// - merged: dm_channel_closed removes the stale source entry; dm_channel_created
|
||||||
|
// (full DmChannel payload — the client handler reads dmChannel.members) resurfaces
|
||||||
|
// the surviving target with its merged history.
|
||||||
|
// - rekeyed: dm_channel_created upserts the channel by id (spaceStore.addDmChannel
|
||||||
|
// replaces by id), refreshing the now-stale federatedId in place. dm_channel_updated
|
||||||
|
// would only patch name/icon, not federatedId, so it cannot heal the client here.
|
||||||
|
for (const r of dmReconcileResults) {
|
||||||
|
const targetPayload = buildDmChannelPayload(r.targetChannelId, db);
|
||||||
|
for (const uid of r.affectedUserIds) {
|
||||||
|
if (r.action === 'merged') {
|
||||||
|
connectionManager.sendToUser(uid, { type: 'dm_channel_closed' as const, dmChannelId: r.channelId });
|
||||||
|
}
|
||||||
|
if (targetPayload) {
|
||||||
|
connectionManager.sendToUser(uid, { type: 'dm_channel_created' as const, dmChannel: targetPayload });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.code(200).send({ success: true, user: sanitizeUser(updated, true) });
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { getDb, schema } from '../../../db/index.js';
|
||||||
|
import { and, eq, isNull, or } from 'drizzle-orm';
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { isLookupRateLimited } from '../rateLimits.js';
|
||||||
|
import { authenticateS2SPeer } from './s2sAuth.js';
|
||||||
|
|
||||||
|
export function registerLookupRoutes(app: FastifyInstance): void {
|
||||||
|
// ─── POST /api/federation/users/lookup ─────────────────────────────────────
|
||||||
|
// Server-to-server: resolve a username on this instance to its canonical
|
||||||
|
// (homeUserId, profile snapshot). Used by another instance to construct a
|
||||||
|
// friend_request_create event without requiring a federated user account.
|
||||||
|
//
|
||||||
|
// Returns 200 with profile snapshot for native users (regardless of the
|
||||||
|
// user's `discoverable` setting — exact-handle resolution).
|
||||||
|
// Returns 404 for tombstoned users, replicated stubs, or unknown usernames.
|
||||||
|
app.post<{ Body: { username?: unknown } }>(
|
||||||
|
'/api/federation/users/lookup',
|
||||||
|
{ bodyLimit: 4 * 1024 },
|
||||||
|
async (request, reply) => {
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
// Shared inbound S2S-auth preamble: headers → active peer → rate limit →
|
||||||
|
// signature → nonce replay. The per-peer lookup rate limiter (60/min) runs
|
||||||
|
// BEFORE signature verification and sends `Retry-After: 60`. This endpoint
|
||||||
|
// never logged on a missing nonce (logMissingNonce omitted).
|
||||||
|
const auth = authenticateS2SPeer(request, reply, {
|
||||||
|
rateLimiter: { limited: isLookupRateLimited, retryAfterSeconds: 60 },
|
||||||
|
});
|
||||||
|
if (!auth.ok) return;
|
||||||
|
|
||||||
|
// 2. Validate body
|
||||||
|
const rawUsername = (request.body as { username?: unknown } | null)?.username;
|
||||||
|
if (typeof rawUsername !== 'string') {
|
||||||
|
return reply.code(400).send({ error: 'username is required (string)', statusCode: 400 });
|
||||||
|
}
|
||||||
|
const username = rawUsername.trim().toLowerCase();
|
||||||
|
if (!username) {
|
||||||
|
return reply.code(400).send({ error: 'username is required', statusCode: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Native-only lookup with isDeleted filter; discoverable is NOT consulted.
|
||||||
|
const user = db
|
||||||
|
.select()
|
||||||
|
.from(schema.users)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.users.username, username),
|
||||||
|
eq(schema.users.isDeleted, 0),
|
||||||
|
isNull(schema.users.homeInstance),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return reply.code(404).send({ found: false, code: 'user_not_found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.code(200).send({
|
||||||
|
found: true,
|
||||||
|
user: {
|
||||||
|
homeUserId: user.id,
|
||||||
|
username: user.username,
|
||||||
|
profile: {
|
||||||
|
displayName: user.displayName,
|
||||||
|
avatar: user.avatar,
|
||||||
|
avatarColor: user.avatarColor,
|
||||||
|
banner: user.banner,
|
||||||
|
bio: user.bio,
|
||||||
|
status: user.status as 'online' | 'idle' | 'dnd' | 'offline' | null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── POST /api/federation/users/by-home-id ──────────────────────────────────
|
||||||
|
// Server-to-server: reverse-lookup a homeUserId to its canonical username +
|
||||||
|
// profile snapshot. Used by the stub-username backfill worker on peers that
|
||||||
|
// hold legacy snowflake-named replicas of users now visible by their real
|
||||||
|
// handle. Same auth+rate-limit shape as /users/lookup.
|
||||||
|
app.post<{ Body: { homeUserId?: unknown } }>(
|
||||||
|
'/api/federation/users/by-home-id',
|
||||||
|
{ bodyLimit: 4 * 1024 },
|
||||||
|
async (request, reply) => {
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
// Shared inbound S2S-auth preamble: headers → active peer → rate limit →
|
||||||
|
// signature → nonce replay. Same shape as /users/lookup: per-peer lookup
|
||||||
|
// rate limiter (60/min) BEFORE signature, `Retry-After: 60`, no
|
||||||
|
// missing-nonce warning.
|
||||||
|
const auth = authenticateS2SPeer(request, reply, {
|
||||||
|
rateLimiter: { limited: isLookupRateLimited, retryAfterSeconds: 60 },
|
||||||
|
});
|
||||||
|
if (!auth.ok) return;
|
||||||
|
|
||||||
|
// 2. Validate body
|
||||||
|
const rawId = (request.body as { homeUserId?: unknown } | null)?.homeUserId;
|
||||||
|
if (typeof rawId !== 'string' || rawId.trim().length === 0) {
|
||||||
|
return reply.code(400).send({ error: 'homeUserId is required (string)', statusCode: 400 });
|
||||||
|
}
|
||||||
|
const homeUserId = rawId.trim();
|
||||||
|
|
||||||
|
// 3. Native-only lookup. Match by id (canonical native id) OR home_user_id
|
||||||
|
// (backfilled column natives carry to satisfy tier-1 lookups). Excludes
|
||||||
|
// tombstoned and replicated stubs.
|
||||||
|
const user = db
|
||||||
|
.select()
|
||||||
|
.from(schema.users)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.users.isDeleted, 0),
|
||||||
|
isNull(schema.users.homeInstance),
|
||||||
|
or(eq(schema.users.id, homeUserId), eq(schema.users.homeUserId, homeUserId)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return reply.code(200).send({ found: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.code(200).send({
|
||||||
|
found: true,
|
||||||
|
user: {
|
||||||
|
homeUserId: user.homeUserId ?? user.id,
|
||||||
|
username: user.username,
|
||||||
|
profile: {
|
||||||
|
displayName: user.displayName,
|
||||||
|
avatar: user.avatar,
|
||||||
|
avatarColor: user.avatarColor,
|
||||||
|
status: user.status as 'online' | 'idle' | 'dnd' | 'offline' | null,
|
||||||
|
banner: user.banner,
|
||||||
|
bio: user.bio,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,426 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
import { getDb, schema } from '../../../db/index.js';
|
||||||
|
import { authenticate, requireAdmin } from '../../../utils/auth.js';
|
||||||
|
import { buildFederationHeaders, generateHmacSecret } from '../../../utils/federationAuth.js';
|
||||||
|
import { onPeerDeactivated } from '../../../utils/federationPeerActivation.js';
|
||||||
|
import { probePeerReachable, recoverOrDetectReset } from '../../../utils/federationRecovery.js';
|
||||||
|
import { homeInstanceMatch } from '../../../utils/federationReset.js';
|
||||||
|
import { connectionManager } from '../../../ws/handler.js';
|
||||||
|
import { and, eq, sql } from 'drizzle-orm';
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { resolveLocalOrigin, sanitizePeer } from '../origin.js';
|
||||||
|
|
||||||
|
export function registerPeerAdminRoutes(app: FastifyInstance): void {
|
||||||
|
// ─── GET /api/federation/peers ─────────────────────────────────────────────
|
||||||
|
// Admin-only: list all federation peers (hmacSecret excluded).
|
||||||
|
app.get(
|
||||||
|
'/api/federation/peers',
|
||||||
|
{ preHandler: [authenticate, requireAdmin] },
|
||||||
|
async (_request, reply) => {
|
||||||
|
const db = getDb();
|
||||||
|
const peers = db
|
||||||
|
.select()
|
||||||
|
.from(schema.federationPeers)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
return reply.code(200).send({ peers: peers.map(sanitizePeer) });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── GET /api/federation/reset-events ──────────────────────────────────────
|
||||||
|
// Admin-only: the durable reset journal + per-origin orphaned real accounts,
|
||||||
|
// for the "Reset cleanup" admin surface (instance-epoch self-healing §6.4).
|
||||||
|
// Read-only; disposition actions reuse the existing one-click Re-peer (reset
|
||||||
|
// + initiate) and the existing DELETE /api/admin/users/:id (full-purge Remove).
|
||||||
|
app.get(
|
||||||
|
'/api/federation/reset-events',
|
||||||
|
{ preHandler: [authenticate, requireAdmin] },
|
||||||
|
async (_request, reply) => {
|
||||||
|
const db = getDb();
|
||||||
|
const events = db.select().from(schema.federationResetEvents).all();
|
||||||
|
|
||||||
|
const result = events.map((ev) => {
|
||||||
|
const accounts = db
|
||||||
|
.select({
|
||||||
|
id: schema.users.id,
|
||||||
|
username: schema.users.username,
|
||||||
|
displayName: schema.users.displayName,
|
||||||
|
avatarColor: schema.users.avatarColor,
|
||||||
|
})
|
||||||
|
.from(schema.users)
|
||||||
|
.where(and(
|
||||||
|
eq(schema.users.federationHomeOrphaned, 1),
|
||||||
|
eq(schema.users.isDeleted, 0),
|
||||||
|
homeInstanceMatch(ev.origin),
|
||||||
|
))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const orphanedAccounts = accounts.map((a) => {
|
||||||
|
const ownedSpaces = db
|
||||||
|
.select({ id: schema.spaces.id, name: schema.spaces.name })
|
||||||
|
.from(schema.spaces)
|
||||||
|
.where(eq(schema.spaces.ownerId, a.id))
|
||||||
|
.all();
|
||||||
|
const spaceMemberCount = db
|
||||||
|
.select({ n: sql<number>`count(*)` })
|
||||||
|
.from(schema.spaceMembers)
|
||||||
|
.where(eq(schema.spaceMembers.userId, a.id))
|
||||||
|
.get()?.n ?? 0;
|
||||||
|
const messageCount = db
|
||||||
|
.select({ n: sql<number>`count(*)` })
|
||||||
|
.from(schema.messages)
|
||||||
|
.where(eq(schema.messages.userId, a.id))
|
||||||
|
.get()?.n ?? 0;
|
||||||
|
return { ...a, ownedSpaces, spaceMemberCount, messageCount };
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
origin: ev.origin,
|
||||||
|
deadEpoch: ev.deadEpoch,
|
||||||
|
newEpoch: ev.newEpoch,
|
||||||
|
detectedAt: ev.detectedAt,
|
||||||
|
resolvedAt: ev.resolvedAt,
|
||||||
|
stubCount: ev.stubCount,
|
||||||
|
orphanedAccountCount: ev.orphanedAccountCount,
|
||||||
|
acknowledgedAt: ev.acknowledgedAt,
|
||||||
|
orphanedAccounts,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return reply.code(200).send({ events: result });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── POST /api/federation/reset-events/acknowledge ─────────────────────────
|
||||||
|
// Admin-only: dismiss a reset event from the admin banner. Purely
|
||||||
|
// informational state — detached accounts stay detached and functional;
|
||||||
|
// acknowledging just stops the surface from re-listing them (detach spec §4.6).
|
||||||
|
app.post<{ Body: { origin: string } }>(
|
||||||
|
'/api/federation/reset-events/acknowledge',
|
||||||
|
{ preHandler: [authenticate, requireAdmin] },
|
||||||
|
async (request, reply) => {
|
||||||
|
const { origin } = request.body;
|
||||||
|
if (!origin || typeof origin !== 'string') {
|
||||||
|
return reply.code(400).send({ error: 'origin is required', statusCode: 400 });
|
||||||
|
}
|
||||||
|
const db = getDb();
|
||||||
|
const existing = db
|
||||||
|
.select()
|
||||||
|
.from(schema.federationResetEvents)
|
||||||
|
.where(eq(schema.federationResetEvents.origin, origin))
|
||||||
|
.get();
|
||||||
|
if (!existing) {
|
||||||
|
return reply.code(404).send({ error: 'No reset event for this origin', statusCode: 404 });
|
||||||
|
}
|
||||||
|
if (existing.acknowledgedAt === null) {
|
||||||
|
db.update(schema.federationResetEvents)
|
||||||
|
.set({ acknowledgedAt: Date.now() })
|
||||||
|
.where(eq(schema.federationResetEvents.origin, origin))
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
return reply.code(200).send({ success: true });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── DELETE /api/federation/peers/:id ──────────────────────────────────────
|
||||||
|
// Admin-only: revoke a federation peer and clean up its outbox.
|
||||||
|
app.delete<{ Params: { id: string } }>(
|
||||||
|
'/api/federation/peers/:id',
|
||||||
|
{ preHandler: [authenticate, requireAdmin] },
|
||||||
|
async (request, reply) => {
|
||||||
|
const { id } = request.params;
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
const peer = db
|
||||||
|
.select()
|
||||||
|
.from(schema.federationPeers)
|
||||||
|
.where(eq(schema.federationPeers.id, id))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!peer) {
|
||||||
|
return reply.code(404).send({ error: 'Peer not found', statusCode: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke the peer
|
||||||
|
db.update(schema.federationPeers)
|
||||||
|
.set({ status: 'revoked' })
|
||||||
|
.where(eq(schema.federationPeers.id, id))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
onPeerDeactivated(id, 'admin_revoked').catch(err =>
|
||||||
|
console.error('[federation] onPeerDeactivated from admin revoke failed:', err),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete all outbox entries for this peer
|
||||||
|
db.delete(schema.federationOutbox)
|
||||||
|
.where(eq(schema.federationOutbox.peerId, id))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
return reply.code(200).send({ success: true });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── POST /api/federation/peers/:id/reset ──────────────────────────────────
|
||||||
|
// Admin-only: reset a peer that has transitioned to needs_attention.
|
||||||
|
// Deletes the local peer row (cascade-deletes outbox entries via FK).
|
||||||
|
// Admin must re-initiate peering out of band after reset.
|
||||||
|
app.post<{ Params: { id: string } }>(
|
||||||
|
'/api/federation/peers/:id/reset',
|
||||||
|
{ preHandler: [authenticate, requireAdmin] },
|
||||||
|
async (request, reply) => {
|
||||||
|
const { id } = request.params;
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
const peer = db
|
||||||
|
.select()
|
||||||
|
.from(schema.federationPeers)
|
||||||
|
.where(eq(schema.federationPeers.id, id))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!peer) {
|
||||||
|
return reply.code(404).send({ error: 'Peer not found', statusCode: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (peer.status !== 'needs_attention') {
|
||||||
|
return reply.code(400).send({
|
||||||
|
error: 'Reset is only available for peers in the needs_attention state. Use revoke for active peers.',
|
||||||
|
statusCode: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cascade-delete handles federation_outbox entries (FK onDelete: 'cascade').
|
||||||
|
db.delete(schema.federationPeers)
|
||||||
|
.where(eq(schema.federationPeers.id, id))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
|
||||||
|
|
||||||
|
return reply.code(200).send({ success: true });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── POST /api/federation/peers/:id/recheck ────────────────────────────────
|
||||||
|
// Admin-only: run an immediate reachability probe on an unreachable peer.
|
||||||
|
// On success the peer transitions to active (outbox flushes on the next tick).
|
||||||
|
app.post<{ Params: { id: string } }>(
|
||||||
|
'/api/federation/peers/:id/recheck',
|
||||||
|
{ preHandler: [authenticate, requireAdmin] },
|
||||||
|
async (request, reply) => {
|
||||||
|
const { id } = request.params;
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
const peer = db
|
||||||
|
.select()
|
||||||
|
.from(schema.federationPeers)
|
||||||
|
.where(eq(schema.federationPeers.id, id))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!peer) {
|
||||||
|
return reply.code(404).send({ error: 'Peer not found', statusCode: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (peer.status !== 'unreachable') {
|
||||||
|
return reply.code(400).send({
|
||||||
|
error: 'Recheck is only available for unreachable peers.',
|
||||||
|
statusCode: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const probe = await probePeerReachable(peer.origin);
|
||||||
|
|
||||||
|
if (probe.reachable) {
|
||||||
|
const outcome = await recoverOrDetectReset(peer, probe);
|
||||||
|
if (outcome === 'reset_detected') {
|
||||||
|
// The peer is a new incarnation on the same domain. It was routed to
|
||||||
|
// needs_attention (detection-only, no rekey) and must NOT be recovered
|
||||||
|
// to active until an admin re-peers through the authenticated path.
|
||||||
|
return reply.code(200).send({ recovered: false, status: 'needs_attention' });
|
||||||
|
}
|
||||||
|
return reply.code(200).send({ recovered: true, status: 'active' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Probe failed — advance pacing so a manual attempt stays consistent with
|
||||||
|
// the recovery worker's schedule.
|
||||||
|
db.update(schema.federationPeers)
|
||||||
|
.set({ probeAttempts: peer.probeAttempts + 1, lastProbeAt: Date.now() })
|
||||||
|
.where(eq(schema.federationPeers.id, peer.id))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
return reply.code(200).send({ recovered: false, status: 'unreachable' });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── PATCH /api/federation/peers/:id ────────────────────────────────────────
|
||||||
|
// Admin-only: update peer settings (e.g. auto-rotation interval).
|
||||||
|
app.patch<{ Params: { id: string }; Body: { autoRotateIntervalDays?: number } }>(
|
||||||
|
'/api/federation/peers/:id',
|
||||||
|
{ preHandler: [authenticate, requireAdmin] },
|
||||||
|
async (request, reply) => {
|
||||||
|
const { id } = request.params;
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
const peer = db
|
||||||
|
.select()
|
||||||
|
.from(schema.federationPeers)
|
||||||
|
.where(eq(schema.federationPeers.id, id))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!peer) {
|
||||||
|
return reply.code(404).send({ error: 'Peer not found', statusCode: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData: Record<string, number> = {};
|
||||||
|
|
||||||
|
if (request.body.autoRotateIntervalDays !== undefined) {
|
||||||
|
const interval = Number(request.body.autoRotateIntervalDays);
|
||||||
|
if (isNaN(interval) || !Number.isInteger(interval) || interval < 1 || interval > 365) {
|
||||||
|
return reply.code(400).send({ error: 'autoRotateIntervalDays must be an integer between 1 and 365', statusCode: 400 });
|
||||||
|
}
|
||||||
|
updateData.autoRotateIntervalDays = interval;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(updateData).length === 0) {
|
||||||
|
return reply.code(400).send({ error: 'No valid fields to update', statusCode: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.update(schema.federationPeers)
|
||||||
|
.set(updateData)
|
||||||
|
.where(eq(schema.federationPeers.id, id))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
const updated = db
|
||||||
|
.select()
|
||||||
|
.from(schema.federationPeers)
|
||||||
|
.where(eq(schema.federationPeers.id, id))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
return reply.code(200).send({ peer: sanitizePeer(updated!) });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── DELETE /api/federation/peers/:id/permanent ─────────────────────────────
|
||||||
|
// Admin-only: permanently delete a revoked peer record.
|
||||||
|
app.delete<{ Params: { id: string } }>(
|
||||||
|
'/api/federation/peers/:id/permanent',
|
||||||
|
{ preHandler: [authenticate, requireAdmin] },
|
||||||
|
async (request, reply) => {
|
||||||
|
const { id } = request.params;
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
const peer = db
|
||||||
|
.select()
|
||||||
|
.from(schema.federationPeers)
|
||||||
|
.where(eq(schema.federationPeers.id, id))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!peer) {
|
||||||
|
return reply.code(404).send({ error: 'Peer not found', statusCode: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (peer.status !== 'revoked') {
|
||||||
|
return reply.code(400).send({
|
||||||
|
error: 'Only revoked peers can be permanently deleted. Revoke the peer first.',
|
||||||
|
statusCode: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
db.delete(schema.federationPeers)
|
||||||
|
.where(eq(schema.federationPeers.id, id))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
return reply.code(200).send({ success: true });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── POST /api/federation/peers/:id/rotate ──────────────────────────────────
|
||||||
|
// Admin-only: trigger immediate secret rotation for a peer.
|
||||||
|
app.post<{ Params: { id: string } }>(
|
||||||
|
'/api/federation/peers/:id/rotate',
|
||||||
|
{ preHandler: [authenticate, requireAdmin] },
|
||||||
|
async (request, reply) => {
|
||||||
|
const { id } = request.params;
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
const peer = db
|
||||||
|
.select()
|
||||||
|
.from(schema.federationPeers)
|
||||||
|
.where(eq(schema.federationPeers.id, id))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!peer) {
|
||||||
|
return reply.code(404).send({ error: 'Peer not found', statusCode: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (peer.status !== 'active') {
|
||||||
|
return reply.code(400).send({ error: 'Can only rotate secrets for active peers', statusCode: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (peer.pendingHmacSecret) {
|
||||||
|
return reply.code(409).send({
|
||||||
|
error: 'A secret rotation is already in progress — wait for it to complete',
|
||||||
|
statusCode: 409,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const newSecret = generateHmacSecret();
|
||||||
|
let localOrigin: string;
|
||||||
|
try {
|
||||||
|
localOrigin = resolveLocalOrigin();
|
||||||
|
} catch {
|
||||||
|
return reply.code(500).send({
|
||||||
|
error: 'Cannot determine local instance origin. Set the DOMAIN environment variable.',
|
||||||
|
statusCode: 500,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send rotation request to peer, signed with the CURRENT (old) secret
|
||||||
|
try {
|
||||||
|
const rotateBody = JSON.stringify({ newSecret });
|
||||||
|
const headers = buildFederationHeaders(rotateBody, peer.hmacSecret, localOrigin);
|
||||||
|
|
||||||
|
const response = await fetch(`${peer.origin}/api/federation/peer/rotate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: rotateBody,
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let errorMessage = `Remote instance rejected rotation (HTTP ${response.status})`;
|
||||||
|
try {
|
||||||
|
const body = await response.json() as { error?: string };
|
||||||
|
if (body.error) errorMessage = body.error;
|
||||||
|
} catch { /* ignore parse failures */ }
|
||||||
|
|
||||||
|
return reply.code(502).send({ error: errorMessage, statusCode: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store pending secret locally AFTER remote peer confirms acceptance
|
||||||
|
db.update(schema.federationPeers)
|
||||||
|
.set({
|
||||||
|
pendingHmacSecret: newSecret,
|
||||||
|
secretRotationAt: Date.now(),
|
||||||
|
})
|
||||||
|
.where(eq(schema.federationPeers.id, peer.id))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
console.log(`[federation] Secret rotation initiated with peer ${peer.origin}`);
|
||||||
|
|
||||||
|
return reply.code(200).send({ success: true, gracePeriodMs: 900_000 });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
if (err instanceof DOMException && err.name === 'TimeoutError') {
|
||||||
|
return reply.code(504).send({
|
||||||
|
error: 'Remote instance did not respond within 10 seconds',
|
||||||
|
statusCode: 504,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return reply.code(502).send({
|
||||||
|
error: `Failed to reach remote instance: ${message}`,
|
||||||
|
statusCode: 502,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,784 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
import { getDb, schema } from '../../../db/index.js';
|
||||||
|
import { authenticate, requireAdmin } from '../../../utils/auth.js';
|
||||||
|
import { generateHmacSecret, parseFederationHeaders, verifyPeerSignature } from '../../../utils/federationAuth.js';
|
||||||
|
import { fetchPeerEpoch, getInstanceId } from '../../../utils/federationEpoch.js';
|
||||||
|
import { onPeerActivated } from '../../../utils/federationPeerActivation.js';
|
||||||
|
import { markPeerReset } from '../../../utils/federationReset.js';
|
||||||
|
import { generateSnowflake } from '../../../utils/snowflake.js';
|
||||||
|
import { connectionManager } from '../../../ws/handler.js';
|
||||||
|
import { and, eq, inArray, or } from 'drizzle-orm';
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { queueApprovalRequest } from './approvals.js';
|
||||||
|
import { resolveLocalOrigin, sanitizePeer, validateOrigin } from '../origin.js';
|
||||||
|
import { isAcceptRateLimited, isEnsureRateLimited } from '../rateLimits.js';
|
||||||
|
|
||||||
|
export function registerPeerHandshakeRoutes(app: FastifyInstance): void {
|
||||||
|
// ─── POST /api/federation/peer/initiate ────────────────────────────────────
|
||||||
|
// Admin-only: start a peering handshake with a remote instance.
|
||||||
|
app.post<{ Body: { remoteOrigin: string } }>(
|
||||||
|
'/api/federation/peer/initiate',
|
||||||
|
{ preHandler: [authenticate, requireAdmin] },
|
||||||
|
async (request, reply) => {
|
||||||
|
const { remoteOrigin: rawOrigin } = request.body ?? {};
|
||||||
|
if (!rawOrigin || typeof rawOrigin !== 'string') {
|
||||||
|
return reply.code(400).send({ error: 'remoteOrigin is required', statusCode: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const remoteOrigin = validateOrigin(rawOrigin);
|
||||||
|
if (!remoteOrigin) {
|
||||||
|
return reply.code(400).send({ error: 'remoteOrigin must be a valid HTTPS URL (HTTP is only allowed for localhost)', statusCode: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
// Check if a peer already exists for this origin
|
||||||
|
const existing = db
|
||||||
|
.select()
|
||||||
|
.from(schema.federationPeers)
|
||||||
|
.where(eq(schema.federationPeers.origin, remoteOrigin))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
if (existing.status === 'active') {
|
||||||
|
return reply.code(200).send({ peer: sanitizePeer(existing) });
|
||||||
|
}
|
||||||
|
if (existing.status === 'pending') {
|
||||||
|
return reply.code(409).send({
|
||||||
|
error: 'A peering handshake with this instance is already in progress',
|
||||||
|
statusCode: 409,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (existing.status === 'awaiting_approval') {
|
||||||
|
return reply.code(409).send({
|
||||||
|
error: "A peering handshake with this instance is awaiting the remote admin's approval",
|
||||||
|
statusCode: 409,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Every remaining terminal/parked state is safe to clear and re-initiate
|
||||||
|
// from — falling through here (rather than to the db.insert below) is what
|
||||||
|
// keeps this route from violating UNIQUE(origin) and 500-ing.
|
||||||
|
// - revoked: local admin revoked; re-initiate cleanly.
|
||||||
|
// - rejected: a prior attempt was rejected; allow the local admin's
|
||||||
|
// authenticated retry (mirrors revoked).
|
||||||
|
// - needs_attention: this IS the one-click Re-peer step (resetPeer +
|
||||||
|
// initiate). Deleting the row here is equivalent to the documented
|
||||||
|
// reset: the reset-heal snapshot lives on users.federation_heal_pending
|
||||||
|
// (not the peer row) and the federation_reset_events journal is designed
|
||||||
|
// to survive peer-row deletion (design §4.2/§6.1), and onPeerActivated
|
||||||
|
// after the fresh handshake re-triggers the heal — so no recovery state
|
||||||
|
// is lost by removing the local needs_attention peer row here.
|
||||||
|
if (
|
||||||
|
existing.status === 'revoked' ||
|
||||||
|
existing.status === 'rejected' ||
|
||||||
|
existing.status === 'needs_attention'
|
||||||
|
) {
|
||||||
|
db.delete(schema.federationPeers).where(eq(schema.federationPeers.id, existing.id)).run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let localOrigin: string;
|
||||||
|
try {
|
||||||
|
localOrigin = resolveLocalOrigin();
|
||||||
|
} catch {
|
||||||
|
return reply.code(500).send({
|
||||||
|
error: 'Cannot determine local instance origin. Set the DOMAIN environment variable.',
|
||||||
|
statusCode: 500,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent self-peering
|
||||||
|
if (localOrigin === remoteOrigin) {
|
||||||
|
return reply.code(400).send({ error: 'Cannot peer with yourself', statusCode: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const hmacSecret = generateHmacSecret();
|
||||||
|
const peerId = generateSnowflake();
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Store peer as pending
|
||||||
|
db.insert(schema.federationPeers).values({
|
||||||
|
id: peerId,
|
||||||
|
origin: remoteOrigin,
|
||||||
|
hmacSecret,
|
||||||
|
status: 'pending',
|
||||||
|
createdAt: now,
|
||||||
|
}).run();
|
||||||
|
|
||||||
|
// Initiate the server-to-server handshake
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${remoteOrigin}/api/federation/peer/accept`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
sourceOrigin: localOrigin,
|
||||||
|
hmacSecret,
|
||||||
|
instanceName: db
|
||||||
|
.select({ name: schema.instanceSettings.instanceName })
|
||||||
|
.from(schema.instanceSettings)
|
||||||
|
.where(eq(schema.instanceSettings.id, 1))
|
||||||
|
.get()?.name ?? undefined,
|
||||||
|
instanceId: getInstanceId(),
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 202) {
|
||||||
|
// Remote instance queued our request for admin approval
|
||||||
|
// (autoAcceptPeering is off on their side). Do NOT activate the
|
||||||
|
// local peer — mirror the auto-peer flow in federationPeering.ts
|
||||||
|
// by transitioning the pending record to awaiting_approval.
|
||||||
|
// Capture the approval token they returned so the next inbound
|
||||||
|
// /peer/accept (when their admin approves) can be verified. §3.7.
|
||||||
|
let returnedToken: string | null = null;
|
||||||
|
try {
|
||||||
|
const body = (await response.json()) as { approvalToken?: string };
|
||||||
|
if (typeof body?.approvalToken === 'string' && body.approvalToken.length > 0) {
|
||||||
|
returnedToken = body.approvalToken;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Non-JSON / empty body — legacy peer.
|
||||||
|
}
|
||||||
|
|
||||||
|
db.update(schema.federationPeers)
|
||||||
|
.set({ status: 'awaiting_approval', approvalToken: returnedToken })
|
||||||
|
.where(eq(schema.federationPeers.id, peerId))
|
||||||
|
.run();
|
||||||
|
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
|
||||||
|
|
||||||
|
const peer = db
|
||||||
|
.select()
|
||||||
|
.from(schema.federationPeers)
|
||||||
|
.where(eq(schema.federationPeers.id, peerId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!peer) {
|
||||||
|
return reply.code(500).send({ error: 'Failed to read peer after queuing', statusCode: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.code(202).send({ peer: sanitizePeer(peer) });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
// Read the body exactly ONCE here — response.json()/text() consumes the
|
||||||
|
// stream, so both the honest-409 branch and the generic branch below
|
||||||
|
// share this single parse (no double-read of the same Response).
|
||||||
|
const rawBody = await response.text().catch(() => '');
|
||||||
|
let parsed: { error?: string; code?: string } = {};
|
||||||
|
try { parsed = JSON.parse(rawBody) as { error?: string; code?: string }; } catch { /* non-JSON body */ }
|
||||||
|
|
||||||
|
// Responder honestly refused: it already holds peering for us and will
|
||||||
|
// not rekey (anti-hijack). Do NOT create a conflicting row — delete the
|
||||||
|
// pending row so our slot stays clean and the remote's own later Re-peer
|
||||||
|
// can land on a fresh responder slot. Surface an actionable reason.
|
||||||
|
if (response.status === 409 && parsed.code === 'PEER_EXISTS_RESET_REQUIRED') {
|
||||||
|
db.delete(schema.federationPeers).where(eq(schema.federationPeers.id, peerId)).run();
|
||||||
|
return reply.code(409).send({
|
||||||
|
error: 'The remote instance still holds stale peering for you. Ask its admin to reset (or Re-peer) their side, then try again.',
|
||||||
|
code: 'PEER_EXISTS_RESET_REQUIRED',
|
||||||
|
statusCode: 409,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorMessage = parsed.error || `Remote instance rejected peering (HTTP ${response.status})`;
|
||||||
|
// Clean up the pending peer
|
||||||
|
db.delete(schema.federationPeers).where(eq(schema.federationPeers.id, peerId)).run();
|
||||||
|
return reply.code(502).send({ error: errorMessage, statusCode: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remote accepted — activate the peer. Parse the remote's instanceName
|
||||||
|
// and instanceId (epoch) from the response body so the federation panel
|
||||||
|
// renders a friendly label and we record the peer's authenticated
|
||||||
|
// epoch baseline. Tolerate omission and non-JSON bodies.
|
||||||
|
let remoteInstanceName: string | null = null;
|
||||||
|
let remoteInstanceId: string | null = null;
|
||||||
|
try {
|
||||||
|
const body = (await response.json()) as { instanceName?: string | null; instanceId?: string | null };
|
||||||
|
if (typeof body?.instanceName === 'string' && body.instanceName.length > 0) {
|
||||||
|
remoteInstanceName = body.instanceName;
|
||||||
|
}
|
||||||
|
if (typeof body?.instanceId === 'string' && body.instanceId.length > 0) {
|
||||||
|
remoteInstanceId = body.instanceId;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Non-JSON body — leave null.
|
||||||
|
}
|
||||||
|
|
||||||
|
// The responder returned 200 → it claims it adopted our secret. PROVE it
|
||||||
|
// with a signed round-trip before trusting the peering (catches BUG-1: a
|
||||||
|
// responder that reported success without adopting, and any residual
|
||||||
|
// desync). fetchPeerEpoch signs with the just-negotiated secret; a desync
|
||||||
|
// → 401/403 → null. Park the peer in needs_attention instead of falsely
|
||||||
|
// activating so the admin sees "re-peer incomplete", not a dead-active row.
|
||||||
|
const verifiedEpoch = await fetchPeerEpoch({ origin: remoteOrigin, hmacSecret });
|
||||||
|
if (!verifiedEpoch) {
|
||||||
|
db.update(schema.federationPeers)
|
||||||
|
.set({ status: 'needs_attention', needsAttentionReason: 'repeer_incomplete', lastSeenAt: Date.now() })
|
||||||
|
.where(eq(schema.federationPeers.id, peerId))
|
||||||
|
.run();
|
||||||
|
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
|
||||||
|
const parked = db.select().from(schema.federationPeers).where(eq(schema.federationPeers.id, peerId)).get();
|
||||||
|
return reply.code(200).send({ peer: parked ? sanitizePeer(parked) : null, verified: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.update(schema.federationPeers)
|
||||||
|
// The baseline is trust-consequential (design §9 — a poisoned baseline can drive
|
||||||
|
// a spurious heal), so store the epoch we cryptographically verified via the signed
|
||||||
|
// /epoch round-trip, not the unverified handshake-response body. They are normally
|
||||||
|
// identical; the verified one is authoritative if they ever differ.
|
||||||
|
.set({ status: 'active', lastSeenAt: Date.now(), instanceName: remoteInstanceName, peerInstanceId: verifiedEpoch, needsAttentionReason: null, approvalToken: null })
|
||||||
|
.where(eq(schema.federationPeers.id, peerId))
|
||||||
|
.run();
|
||||||
|
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
|
||||||
|
onPeerActivated(peerId, 'initiate_accepted').catch(err =>
|
||||||
|
console.error('[federation] onPeerActivated from /peer/initiate failed:', err)
|
||||||
|
);
|
||||||
|
|
||||||
|
const peer = db
|
||||||
|
.select()
|
||||||
|
.from(schema.federationPeers)
|
||||||
|
.where(eq(schema.federationPeers.id, peerId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!peer) {
|
||||||
|
return reply.code(500).send({ error: 'Failed to read peer after activation', statusCode: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.code(200).send({ peer: sanitizePeer(peer), verified: true });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
// Clean up the pending peer on network/timeout errors
|
||||||
|
db.delete(schema.federationPeers).where(eq(schema.federationPeers.id, peerId)).run();
|
||||||
|
|
||||||
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
if (err instanceof DOMException && err.name === 'TimeoutError') {
|
||||||
|
return reply.code(504).send({
|
||||||
|
error: 'Remote instance did not respond within 10 seconds',
|
||||||
|
statusCode: 504,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return reply.code(502).send({
|
||||||
|
error: `Failed to reach remote instance: ${message}`,
|
||||||
|
statusCode: 502,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── POST /api/federation/peer/accept ──────────────────────────────────────
|
||||||
|
// Server-to-server: accept a peering request from a remote instance.
|
||||||
|
// No JWT auth — this is first contact. Rate-limited by IP.
|
||||||
|
app.post<{ Body: { sourceOrigin: string; challenge?: string; hmacSecret: string; instanceName?: string; instanceId?: string; approvalToken?: string } }>(
|
||||||
|
'/api/federation/peer/accept',
|
||||||
|
async (request, reply) => {
|
||||||
|
const clientIp = request.ip;
|
||||||
|
if (isAcceptRateLimited(clientIp)) {
|
||||||
|
return reply.code(429).send({
|
||||||
|
error: 'Too many peering requests — try again later',
|
||||||
|
statusCode: 429,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { sourceOrigin: rawOrigin, hmacSecret, instanceName: reqInstanceName, instanceId: reqInstanceId, approvalToken: inboundToken } = request.body ?? {};
|
||||||
|
|
||||||
|
if (!rawOrigin || typeof rawOrigin !== 'string') {
|
||||||
|
return reply.code(400).send({ error: 'sourceOrigin is required', statusCode: 400 });
|
||||||
|
}
|
||||||
|
if (!hmacSecret || typeof hmacSecret !== 'string') {
|
||||||
|
return reply.code(400).send({ error: 'hmacSecret is required', statusCode: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceOrigin = validateOrigin(rawOrigin);
|
||||||
|
if (!sourceOrigin) {
|
||||||
|
return reply.code(400).send({ error: 'sourceOrigin must be a valid HTTPS URL (HTTP is only allowed for localhost)', statusCode: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
const settings = db
|
||||||
|
.select({
|
||||||
|
instanceName: schema.instanceSettings.instanceName,
|
||||||
|
autoAcceptPeering: schema.instanceSettings.autoAcceptPeering,
|
||||||
|
})
|
||||||
|
.from(schema.instanceSettings)
|
||||||
|
.where(eq(schema.instanceSettings.id, 1))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const ourInstanceName = settings?.instanceName ?? null;
|
||||||
|
const ourInstanceId = getInstanceId();
|
||||||
|
const autoAccept = settings?.autoAcceptPeering ?? 1;
|
||||||
|
|
||||||
|
// ── autoAcceptPeering gate ──────────────────────────────────────────
|
||||||
|
// When auto-accept is disabled, only allow incoming accept requests
|
||||||
|
// that correspond to a local pending peer (i.e., a local admin
|
||||||
|
// initiated the handshake). Unsolicited requests are rejected.
|
||||||
|
|
||||||
|
if (autoAccept === 0) {
|
||||||
|
// Check if the local admin already initiated or approved peering with this origin.
|
||||||
|
// 'pending' = admin used peer/initiate (handshake in progress)
|
||||||
|
// 'awaiting_approval' = admin approved an earlier request, handshake was sent,
|
||||||
|
// remote queued it (202). Now the remote admin approved too and is handshaking
|
||||||
|
// back to us. We should accept — both admins have approved.
|
||||||
|
const localPending = db
|
||||||
|
.select({ id: schema.federationPeers.id })
|
||||||
|
.from(schema.federationPeers)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.federationPeers.origin, sourceOrigin),
|
||||||
|
inArray(schema.federationPeers.status, ['pending', 'awaiting_approval']),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!localPending) {
|
||||||
|
// Check if this origin is blocked (previously denied)
|
||||||
|
const blockedPeer = db
|
||||||
|
.select({ id: schema.federationPeers.id })
|
||||||
|
.from(schema.federationPeers)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.federationPeers.origin, sourceOrigin),
|
||||||
|
eq(schema.federationPeers.status, 'rejected'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (blockedPeer) {
|
||||||
|
return reply.code(403).send({
|
||||||
|
error: 'This instance requires manual peering approval',
|
||||||
|
code: 'PEERING_REQUIRES_APPROVAL',
|
||||||
|
statusCode: 403,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return queueApprovalRequest(db, reply, sourceOrigin, hmacSecret, reqInstanceName ?? null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if peer already exists
|
||||||
|
const existing = db
|
||||||
|
.select()
|
||||||
|
.from(schema.federationPeers)
|
||||||
|
.where(eq(schema.federationPeers.origin, sourceOrigin))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
if (existing.status === 'active' || existing.status === 'needs_attention') {
|
||||||
|
// Idempotent — already peered (or peering is in needs_attention state).
|
||||||
|
// In both cases we refuse to overwrite hmac_secret via this
|
||||||
|
// unauthenticated endpoint. An unauthenticated caller cannot
|
||||||
|
// prove prior trust, and needs_attention means "we don't know
|
||||||
|
// why this broke" — letting an unauthenticated request flip it
|
||||||
|
// to active with a new secret defeats the purpose.
|
||||||
|
//
|
||||||
|
// Legitimate recovery path: local admin clicks "Reset peering" →
|
||||||
|
// row is deleted → remote's /peer/accept then lands on a
|
||||||
|
// non-existent row and the normal handshake path runs.
|
||||||
|
//
|
||||||
|
// Detection-only: if the inbound epoch differs from our trusted
|
||||||
|
// baseline, the peer is a NEW incarnation on the same domain (a
|
||||||
|
// wipe-and-reinstall). Route it to needs_attention + snapshot +
|
||||||
|
// journal — but STILL return 409 (PEER_EXISTS_RESET_REQUIRED) and
|
||||||
|
// STILL do not rekey. The anti-hijack guard above is preserved
|
||||||
|
// verbatim; detection never grants capability.
|
||||||
|
if (reqInstanceId && existing.peerInstanceId && reqInstanceId !== existing.peerInstanceId) {
|
||||||
|
markPeerReset(existing.id, sourceOrigin, existing.peerInstanceId, reqInstanceId);
|
||||||
|
}
|
||||||
|
// Anti-hijack: we did NOT adopt the caller's secret. Report that
|
||||||
|
// honestly (409) instead of a false success (was 200 {accepted:true}),
|
||||||
|
// so the initiator does not false-activate into a permanent HMAC
|
||||||
|
// desync. Legacy initiators read only response.ok → they fail loudly
|
||||||
|
// (never a silent desync); new initiators special-case this code.
|
||||||
|
return reply.code(409).send({
|
||||||
|
accepted: false,
|
||||||
|
code: 'PEER_EXISTS_RESET_REQUIRED',
|
||||||
|
error: 'This instance already holds peering for you; its admin must reset that peering before a new handshake can be accepted.',
|
||||||
|
instanceName: ourInstanceName,
|
||||||
|
instanceId: ourInstanceId,
|
||||||
|
statusCode: 409,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (existing.status === 'revoked') {
|
||||||
|
return reply.code(403).send({
|
||||||
|
error: 'Peering with this instance has been revoked',
|
||||||
|
statusCode: 403,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (existing.status === 'rejected') {
|
||||||
|
// A remote admin manually initiated peering with us after we
|
||||||
|
// previously auto-rejected them. Override rejected → active.
|
||||||
|
db.update(schema.federationPeers)
|
||||||
|
.set({
|
||||||
|
hmacSecret,
|
||||||
|
instanceName: reqInstanceName ?? null,
|
||||||
|
peerInstanceId: reqInstanceId ?? null,
|
||||||
|
status: 'active',
|
||||||
|
lastSeenAt: Date.now(),
|
||||||
|
})
|
||||||
|
.where(eq(schema.federationPeers.id, existing.id))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// Broadcast activation to all connected local users
|
||||||
|
for (const uid of connectionManager.getAllOnlineUserIds()) {
|
||||||
|
connectionManager.sendToUser(uid, {
|
||||||
|
type: 'federation_peer_active' as const,
|
||||||
|
peerOrigin: sourceOrigin,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
|
||||||
|
onPeerActivated(existing.id, 'accept_rejected_override').catch(err =>
|
||||||
|
console.error('[federation] onPeerActivated from /peer/accept (rejected override) failed:', err)
|
||||||
|
);
|
||||||
|
|
||||||
|
return reply.code(200).send({ accepted: true, instanceName: ourInstanceName, instanceId: ourInstanceId });
|
||||||
|
}
|
||||||
|
if (existing.status === 'awaiting_approval') {
|
||||||
|
// Spec §3.5: token verification gates the awaiting_approval → active
|
||||||
|
// promotion. Without proof the inbound came from the remote's
|
||||||
|
// /approve endpoint, an adversarial timing-knowledge attack or a
|
||||||
|
// bug-prone background code path could falsely flip this row to
|
||||||
|
// active. The token is single-use entropy issued in the 202 we
|
||||||
|
// returned when the remote's outbound /peer/accept first hit our
|
||||||
|
// queue — only their /approve endpoint forwards it.
|
||||||
|
const tokenValid =
|
||||||
|
typeof existing.approvalToken === 'string' &&
|
||||||
|
existing.approvalToken.length > 0 &&
|
||||||
|
existing.approvalToken === inboundToken;
|
||||||
|
|
||||||
|
if (tokenValid) {
|
||||||
|
db.update(schema.federationPeers)
|
||||||
|
.set({
|
||||||
|
hmacSecret,
|
||||||
|
instanceName: reqInstanceName ?? null,
|
||||||
|
peerInstanceId: reqInstanceId ?? null,
|
||||||
|
status: 'active',
|
||||||
|
lastSeenAt: Date.now(),
|
||||||
|
approvalToken: null,
|
||||||
|
})
|
||||||
|
.where(eq(schema.federationPeers.id, existing.id))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// Clean up any stale approval-request row for this origin (e.g.,
|
||||||
|
// queued debris from a prior bypass attempt that did not promote).
|
||||||
|
db.delete(schema.peerApprovalRequests)
|
||||||
|
.where(eq(schema.peerApprovalRequests.origin, sourceOrigin))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
for (const uid of connectionManager.getAllOnlineUserIds()) {
|
||||||
|
connectionManager.sendToUser(uid, {
|
||||||
|
type: 'federation_peer_active' as const,
|
||||||
|
peerOrigin: sourceOrigin,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
|
||||||
|
onPeerActivated(existing.id, 'accept_awaiting_approval').catch(err =>
|
||||||
|
console.error('[federation] onPeerActivated from /peer/accept (awaiting_approval) failed:', err)
|
||||||
|
);
|
||||||
|
return reply.code(200).send({ accepted: true, instanceName: ourInstanceName, instanceId: ourInstanceId });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token absent or mismatched. Cannot prove mutual approval.
|
||||||
|
if (autoAccept === 1) {
|
||||||
|
// We accept any inbound anyway — promoting here is no weaker than
|
||||||
|
// accepting a fresh handshake from a new peer. Clear the stored
|
||||||
|
// token (moot now) and proceed.
|
||||||
|
db.update(schema.federationPeers)
|
||||||
|
.set({
|
||||||
|
hmacSecret,
|
||||||
|
instanceName: reqInstanceName ?? null,
|
||||||
|
peerInstanceId: reqInstanceId ?? null,
|
||||||
|
status: 'active',
|
||||||
|
lastSeenAt: Date.now(),
|
||||||
|
approvalToken: null,
|
||||||
|
})
|
||||||
|
.where(eq(schema.federationPeers.id, existing.id))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
for (const uid of connectionManager.getAllOnlineUserIds()) {
|
||||||
|
connectionManager.sendToUser(uid, {
|
||||||
|
type: 'federation_peer_active' as const,
|
||||||
|
peerOrigin: sourceOrigin,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
|
||||||
|
onPeerActivated(existing.id, 'accept_awaiting_approval_fallback').catch(err =>
|
||||||
|
console.error('[federation] onPeerActivated from /peer/accept (awaiting_approval fallback) failed:', err)
|
||||||
|
);
|
||||||
|
return reply.code(200).send({ accepted: true, instanceName: ourInstanceName, instanceId: ourInstanceId });
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoAccept=0 + unverifiable inbound → queue as new approval-request.
|
||||||
|
// Existing awaiting_approval row stays untouched; the new approval-
|
||||||
|
// request lets the local admin decide whether to honor this inbound.
|
||||||
|
return queueApprovalRequest(db, reply, sourceOrigin, hmacSecret, reqInstanceName ?? null);
|
||||||
|
}
|
||||||
|
// Pending — update with new secret and activate
|
||||||
|
db.update(schema.federationPeers)
|
||||||
|
.set({
|
||||||
|
hmacSecret,
|
||||||
|
instanceName: reqInstanceName ?? null,
|
||||||
|
peerInstanceId: reqInstanceId ?? null,
|
||||||
|
status: 'active',
|
||||||
|
lastSeenAt: Date.now(),
|
||||||
|
})
|
||||||
|
.where(eq(schema.federationPeers.id, existing.id))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
|
||||||
|
onPeerActivated(existing.id, 'accept_pending').catch(err =>
|
||||||
|
console.error('[federation] onPeerActivated from /peer/accept (pending) failed:', err)
|
||||||
|
);
|
||||||
|
|
||||||
|
return reply.code(200).send({ accepted: true, instanceName: ourInstanceName, instanceId: ourInstanceId });
|
||||||
|
}
|
||||||
|
|
||||||
|
// New peer — create and activate
|
||||||
|
const peerId = generateSnowflake();
|
||||||
|
db.insert(schema.federationPeers).values({
|
||||||
|
id: peerId,
|
||||||
|
origin: sourceOrigin,
|
||||||
|
hmacSecret,
|
||||||
|
instanceName: reqInstanceName ?? null,
|
||||||
|
peerInstanceId: reqInstanceId ?? null,
|
||||||
|
status: 'active',
|
||||||
|
lastSeenAt: Date.now(),
|
||||||
|
createdAt: Date.now(),
|
||||||
|
}).run();
|
||||||
|
|
||||||
|
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
|
||||||
|
onPeerActivated(peerId, 'accept_new').catch(err =>
|
||||||
|
console.error('[federation] onPeerActivated from /peer/accept (new) failed:', err)
|
||||||
|
);
|
||||||
|
|
||||||
|
return reply.code(200).send({ accepted: true, instanceName: ourInstanceName, instanceId: ourInstanceId });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── POST /api/federation/peer/ensure ──────────────────────────────────────
|
||||||
|
// JWT-authenticated (any user): trigger auto-peering with a remote instance.
|
||||||
|
// Rate-limited per user (3 requests per 15 minutes).
|
||||||
|
app.post<{ Body: { remoteOrigin: string } }>(
|
||||||
|
'/api/federation/peer/ensure',
|
||||||
|
{ preHandler: [authenticate] },
|
||||||
|
async (request, reply) => {
|
||||||
|
const { remoteOrigin: rawOrigin } = request.body ?? {};
|
||||||
|
if (!rawOrigin || typeof rawOrigin !== 'string') {
|
||||||
|
return reply.code(400).send({ error: 'remoteOrigin is required', statusCode: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const remoteOrigin = validateOrigin(rawOrigin);
|
||||||
|
if (!remoteOrigin) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
error: 'remoteOrigin must be a valid HTTPS URL (HTTP is only allowed for localhost)',
|
||||||
|
statusCode: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEnsureRateLimited(request.userId)) {
|
||||||
|
return reply.code(429).send({
|
||||||
|
error: 'Too many peering requests — try again later',
|
||||||
|
statusCode: 429,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { ensurePeered } = await import('../../../utils/federationPeering.js');
|
||||||
|
// NOTE: /peer/ensure is currently only invoked from friend-add client paths
|
||||||
|
// (see packages/web/src/stores/instanceStore.ts ensurePeered references).
|
||||||
|
// The hardcoded reason here is correct TODAY but will become wrong when
|
||||||
|
// DM-to-stranger or space-join grow into the gate. When that happens,
|
||||||
|
// surface the reason and target through the request body instead. Do NOT
|
||||||
|
// silently leave the hardcoding in place when adding a new caller.
|
||||||
|
const result = await ensurePeered(remoteOrigin, {
|
||||||
|
kind: 'user_action',
|
||||||
|
userId: request.userId,
|
||||||
|
reason: 'friend_add',
|
||||||
|
target: remoteOrigin,
|
||||||
|
});
|
||||||
|
|
||||||
|
// NOTE: The internal EnsurePeeredResult status names differ from the client-facing
|
||||||
|
// peeringStatus values. The mapping:
|
||||||
|
// 'active' → 'active' (peer is live)
|
||||||
|
// 'rejected' → 'rejected' (permanently blocked)
|
||||||
|
// 'pending' → 'awaiting_approval' (queued on remote, waiting for admin)
|
||||||
|
// 'failed' → 'pending' (transient error, will retry automatically)
|
||||||
|
// 'admin_required' → 'admin_required' (local outbound gate fired — our admin must approve)
|
||||||
|
// The internal 'pending' means "we got a 202 from the remote — admin hasn't acted yet",
|
||||||
|
// while 'failed' means "network/timeout — the outbox worker will retry next tick".
|
||||||
|
// The client sees 'awaiting_approval' (actionable info) vs 'pending' (transient, will resolve).
|
||||||
|
switch (result.status) {
|
||||||
|
case 'active':
|
||||||
|
return reply.code(200).send({ peeringStatus: 'active', peerId: result.peerId });
|
||||||
|
case 'rejected':
|
||||||
|
return reply.code(200).send({ peeringStatus: 'rejected', error: result.error });
|
||||||
|
case 'pending':
|
||||||
|
return reply.code(200).send({ peeringStatus: 'awaiting_approval', error: result.error });
|
||||||
|
case 'failed':
|
||||||
|
return reply.code(200).send({ peeringStatus: 'pending', error: result.error });
|
||||||
|
case 'admin_required':
|
||||||
|
return reply.code(200).send({ peeringStatus: 'admin_required' });
|
||||||
|
default:
|
||||||
|
return reply.code(200).send({ peeringStatus: 'pending', error: 'Unknown peering result' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── POST /api/federation/peer/rotate ───────────────────────────────────────
|
||||||
|
// Server-to-server: accept a secret rotation request from a peer instance.
|
||||||
|
// Authenticated via HMAC-SHA256 signature (current secret), NOT JWT.
|
||||||
|
// NON-ADOPTER of authenticateS2SPeer (deliberate): active-only like the helper
|
||||||
|
// but runs NO nonce replay check (the rotation body is its own replay unit);
|
||||||
|
// sharing the helper would add a nonce gate this endpoint never had.
|
||||||
|
app.post<{ Body: { newSecret: string } }>(
|
||||||
|
'/api/federation/peer/rotate',
|
||||||
|
async (request, reply) => {
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
// 1. Verify HMAC signature
|
||||||
|
const fedHeaders = parseFederationHeaders(request.headers as Record<string, string | string[] | undefined>);
|
||||||
|
if (!fedHeaders) {
|
||||||
|
return reply.code(401).send({ error: 'Missing or malformed federation headers', statusCode: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const peer = db
|
||||||
|
.select()
|
||||||
|
.from(schema.federationPeers)
|
||||||
|
.where(eq(schema.federationPeers.origin, fedHeaders.origin))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!peer || peer.status !== 'active') {
|
||||||
|
return reply.code(403).send({ error: 'Unknown or inactive peer', statusCode: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const bodyString = JSON.stringify(request.body);
|
||||||
|
if (!verifyPeerSignature(bodyString, fedHeaders.signature, fedHeaders.timestamp, fedHeaders.nonce, peer)) {
|
||||||
|
return reply.code(401).send({ error: 'Invalid signature', statusCode: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Validate request body
|
||||||
|
const { newSecret } = request.body ?? {};
|
||||||
|
if (!newSecret || typeof newSecret !== 'string' || newSecret.length !== 64 || !/^[0-9a-f]+$/.test(newSecret)) {
|
||||||
|
return reply.code(400).send({ error: 'newSecret must be a 64-character hex string', statusCode: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Reject if rotation already in progress
|
||||||
|
if (peer.pendingHmacSecret) {
|
||||||
|
return reply.code(409).send({
|
||||||
|
error: 'A secret rotation is already in progress — wait for it to complete',
|
||||||
|
statusCode: 409,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Store pending secret and activate grace period
|
||||||
|
db.update(schema.federationPeers)
|
||||||
|
.set({
|
||||||
|
pendingHmacSecret: newSecret,
|
||||||
|
secretRotationAt: Date.now(),
|
||||||
|
})
|
||||||
|
.where(eq(schema.federationPeers.id, peer.id))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
console.log(`[federation] Secret rotation accepted from peer ${peer.origin}`);
|
||||||
|
|
||||||
|
return reply.code(200).send({ accepted: true, gracePeriodMs: 900_000 });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── POST /api/federation/peer/denied ─────────────────────────────────────
|
||||||
|
// Server-to-server: receive a denial notification from a remote instance.
|
||||||
|
// Authenticated via HMAC-SHA256 signature (the secret we sent in our original
|
||||||
|
// peer/accept request, which the remote stored in their approval queue).
|
||||||
|
// NON-ADOPTER of authenticateS2SPeer (deliberate): gates on 'awaiting_approval'
|
||||||
|
// (404 on no peer row, 409 on wrong status — not the helper's active-only 403),
|
||||||
|
// verifies against a SYNTHETIC no-grace secret object, and runs no nonce check.
|
||||||
|
// Entirely different control flow.
|
||||||
|
app.post<{ Body: { origin: string; reason: 'denied_by_admin' | 'expired'; message?: string } }>(
|
||||||
|
'/api/federation/peer/denied',
|
||||||
|
async (request, reply) => {
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
// Verify HMAC signature
|
||||||
|
const fedHeaders = parseFederationHeaders(request.headers as Record<string, string | string[] | undefined>);
|
||||||
|
if (!fedHeaders) {
|
||||||
|
return reply.code(401).send({ error: 'Missing or malformed federation headers', statusCode: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { origin: senderOrigin, signature, timestamp, nonce } = fedHeaders;
|
||||||
|
|
||||||
|
// Find the local peer for this origin
|
||||||
|
const peer = db
|
||||||
|
.select()
|
||||||
|
.from(schema.federationPeers)
|
||||||
|
.where(eq(schema.federationPeers.origin, senderOrigin))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!peer) {
|
||||||
|
return reply.code(404).send({ error: 'No peer record for this origin', statusCode: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only accept denial for awaiting_approval peers
|
||||||
|
if (peer.status !== 'awaiting_approval') {
|
||||||
|
return reply.code(409).send({
|
||||||
|
error: `Peer is in '${peer.status}' state, not awaiting_approval`,
|
||||||
|
statusCode: 409,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify signature using our stored hmacSecret (the one we sent in the original request)
|
||||||
|
const rawBody = JSON.stringify(request.body);
|
||||||
|
const isValid = verifyPeerSignature(rawBody, signature, timestamp, nonce, {
|
||||||
|
hmacSecret: peer.hmacSecret,
|
||||||
|
pendingHmacSecret: null,
|
||||||
|
secretRotationAt: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
return reply.code(401).send({ error: 'Invalid HMAC signature', statusCode: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { reason, message } = request.body;
|
||||||
|
|
||||||
|
// Transition to rejected
|
||||||
|
db.update(schema.federationPeers)
|
||||||
|
.set({ status: 'rejected' })
|
||||||
|
.where(eq(schema.federationPeers.id, peer.id))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
|
||||||
|
|
||||||
|
// Push federation_peer_rejected WS event to affected users
|
||||||
|
const entries = db
|
||||||
|
.select({
|
||||||
|
contextId: schema.federationOutbox.contextId,
|
||||||
|
contextType: schema.federationOutbox.contextType,
|
||||||
|
})
|
||||||
|
.from(schema.federationOutbox)
|
||||||
|
.where(eq(schema.federationOutbox.peerId, peer.id))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const contextMap = new Map<string, string>();
|
||||||
|
for (const entry of entries) {
|
||||||
|
contextMap.set(entry.contextId, entry.contextType);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Purge outbox entries
|
||||||
|
db.delete(schema.federationOutbox)
|
||||||
|
.where(eq(schema.federationOutbox.peerId, peer.id))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// Build and send WS event
|
||||||
|
if (contextMap.size > 0) {
|
||||||
|
const { pushPeerRejectedEvent } = await import('../../../utils/federationWorker.js');
|
||||||
|
pushPeerRejectedEvent(
|
||||||
|
senderOrigin,
|
||||||
|
contextMap,
|
||||||
|
message || (reason === 'expired'
|
||||||
|
? 'Request expired — no response from admin within 30 days'
|
||||||
|
: 'Request denied by admin'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.code(200).send({ acknowledged: true });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,772 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
import { config } from '../../../config.js';
|
||||||
|
import { getDb, getRawDb, schema } from '../../../db/index.js';
|
||||||
|
import { getOurOrigin, parseFederationHeaders, verifyPeerSignature } from '../../../utils/federationAuth.js';
|
||||||
|
import { sendSignedJson } from './signedResponse.js';
|
||||||
|
import { getInstanceId } from '../../../utils/federationEpoch.js';
|
||||||
|
import { getDmParticipants } from '../../../utils/federationOutbox.js';
|
||||||
|
import { deleteAttachmentFiles } from '../../../utils/fileCleanup.js';
|
||||||
|
import { sanitizeUser } from '../../../utils/sanitize.js';
|
||||||
|
import { collectDeletionBroadcastTargets, tombstoneUser } from '../../../utils/userDeletion.js';
|
||||||
|
import { connectionManager } from '../../../ws/handler.js';
|
||||||
|
import { and, eq, isNull, or } from 'drizzle-orm';
|
||||||
|
import type { FederationIdentityDeleteS2SRequest, FederationRelayAttachment, FederationRelayEvent, FederationRelayRequest, FederationRelayResponse, FederationSyncRequest, FederationSyncResponse } from '@backspace/shared';
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { processRelayEvents } from '../events/dispatch.js';
|
||||||
|
import { extractDomain } from '../identity.js';
|
||||||
|
import { resolveLocalOrigin } from '../origin.js';
|
||||||
|
import { isRelayRateLimited } from '../rateLimits.js';
|
||||||
|
import { authenticateS2SPeer } from './s2sAuth.js';
|
||||||
|
|
||||||
|
export function registerRelayRoutes(app: FastifyInstance): void {
|
||||||
|
// ─── DELETE /api/federation/identity ──────────────────────────────────────
|
||||||
|
// S2S endpoint: delete a federated user's identity on this instance.
|
||||||
|
// Called by the user's home instance via HMAC-signed request.
|
||||||
|
app.delete<{ Body: FederationIdentityDeleteS2SRequest }>(
|
||||||
|
'/api/federation/identity',
|
||||||
|
async (request, reply) => {
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
// Shared inbound S2S-auth preamble: headers → active peer → signature →
|
||||||
|
// nonce replay. No rate limiter; warns on a legacy peer's missing nonce.
|
||||||
|
const auth = authenticateS2SPeer(request, reply, { logMissingNonce: true });
|
||||||
|
if (!auth.ok) return;
|
||||||
|
const { peer } = auth;
|
||||||
|
|
||||||
|
// 2. Validate body
|
||||||
|
const { homeUserId, homeInstance, mode } = request.body;
|
||||||
|
if (!homeUserId || !homeInstance || !['soft', 'full'].includes(mode)) {
|
||||||
|
return reply.code(400).send({ error: 'Invalid request: homeUserId, homeInstance, and mode (soft|full) required', statusCode: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Resolve the live (non-deleted) federated user.
|
||||||
|
// Must filter isDeleted=0: after a prior deletion + re-federation,
|
||||||
|
// multiple records share the same homeUserId (one deleted, one live).
|
||||||
|
const user = db.select().from(schema.users)
|
||||||
|
.where(and(eq(schema.users.homeUserId, homeUserId), eq(schema.users.isDeleted, 0)))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
// Idempotent: no live user means already deleted or never existed
|
||||||
|
if (!user) {
|
||||||
|
return reply.code(200).send({ success: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Attribution guard: only the user's home instance can delete them
|
||||||
|
if (!user.homeInstance || extractDomain(user.homeInstance) !== extractDomain(peer.origin)) {
|
||||||
|
return reply.code(403).send({ error: 'Attribution mismatch: you can only delete users from your own instance', statusCode: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detached (home-orphaned) accounts are sovereign local accounts. The
|
||||||
|
// domain's new incarnation must not delete them by replaying old
|
||||||
|
// homeUserIds. Idempotent 200: from the caller's perspective this
|
||||||
|
// identity does not exist here.
|
||||||
|
if (user.federationHomeOrphaned === 1) {
|
||||||
|
console.log(`[federation] Ignoring S2S identity delete for detached account ${user.id} from ${peer.origin}`);
|
||||||
|
return reply.code(200).send({ success: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Check for owned spaces
|
||||||
|
const ownedSpaces = db.select({ id: schema.spaces.id, name: schema.spaces.name })
|
||||||
|
.from(schema.spaces)
|
||||||
|
.where(eq(schema.spaces.ownerId, user.id))
|
||||||
|
.all();
|
||||||
|
if (ownedSpaces.length > 0) {
|
||||||
|
return reply.code(409).send({ error: 'owns_spaces', ownedSpaces, statusCode: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Collect broadcast targets BEFORE deletion removes memberships
|
||||||
|
const { memberSpaceIds, targetUserIds } = collectDeletionBroadcastTargets(user.id);
|
||||||
|
|
||||||
|
// 7. Execute deletion
|
||||||
|
const filesToDelete = tombstoneUser(user.id, { purgeContent: mode === 'full' });
|
||||||
|
|
||||||
|
// 8. Clean up files from disk
|
||||||
|
deleteAttachmentFiles(filesToDelete.map(f => ({ filename: f })));
|
||||||
|
|
||||||
|
// 9. Broadcast member_left to other connected clients for each space
|
||||||
|
for (const spaceId of memberSpaceIds) {
|
||||||
|
connectionManager.sendToSpace(spaceId, {
|
||||||
|
type: 'member_left',
|
||||||
|
spaceId,
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10. Broadcast user_updated with sanitized deleted user data
|
||||||
|
const deletedRow = db.select().from(schema.users).where(eq(schema.users.id, user.id)).get();
|
||||||
|
if (deletedRow) {
|
||||||
|
const deletedUser = sanitizeUser(deletedRow);
|
||||||
|
const userUpdatedEvent = { type: 'user_updated' as const, user: deletedUser };
|
||||||
|
for (const uid of targetUserIds) {
|
||||||
|
connectionManager.sendToUser(uid, userUpdatedEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 11. Force-disconnect WS if somehow still connected (unlikely but safe)
|
||||||
|
connectionManager.forceDisconnectUser(user.id);
|
||||||
|
|
||||||
|
console.log(`[federation] Identity deleted for user ${user.id} (${user.username}) via S2S from ${peer.origin}, mode=${mode}`);
|
||||||
|
|
||||||
|
return reply.code(200).send({ success: true });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── POST /api/federation/relay ────────────────────────────────────────────
|
||||||
|
// Server-to-server: receive relayed DM events from a peer instance.
|
||||||
|
// Authenticated via HMAC-SHA256 signature, NOT JWT.
|
||||||
|
app.post<{ Body: FederationRelayRequest }>(
|
||||||
|
'/api/federation/relay',
|
||||||
|
{ bodyLimit: 10 * 1024 * 1024 },
|
||||||
|
async (request, reply) => {
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
// Shared inbound S2S-auth preamble: headers → active peer → rate limit →
|
||||||
|
// signature → nonce replay. The per-peer relay rate limiter runs BEFORE
|
||||||
|
// signature verification (avoid HMAC work on a flood); warns on a legacy
|
||||||
|
// peer's missing nonce.
|
||||||
|
const auth = authenticateS2SPeer(request, reply, {
|
||||||
|
rateLimiter: { limited: isRelayRateLimited },
|
||||||
|
logMissingNonce: true,
|
||||||
|
});
|
||||||
|
if (!auth.ok) return;
|
||||||
|
const { peer } = auth;
|
||||||
|
|
||||||
|
// 1b-epoch. Fast-path baseline population (design §3.2). The signature the
|
||||||
|
// preamble verified proves the peer holds the current shared secret, so the
|
||||||
|
// epoch it carries in `sourceInstanceId` is authentic. Populate-if-null
|
||||||
|
// ONLY: a valid relay can never carry an epoch differing from a non-null
|
||||||
|
// baseline (a different incarnation implies a different secret that fails
|
||||||
|
// HMAC), so we only ever fill a NULL — never overwrite. Independent of
|
||||||
|
// per-event processing; does not affect relay accept/reject in any way. Old
|
||||||
|
// peers omit the field → skip (backward-compatible no-op).
|
||||||
|
const claimedEpoch = request.body.sourceInstanceId;
|
||||||
|
if (claimedEpoch && !peer.peerInstanceId) {
|
||||||
|
db.update(schema.federationPeers)
|
||||||
|
.set({ peerInstanceId: claimedEpoch })
|
||||||
|
.where(and(
|
||||||
|
eq(schema.federationPeers.id, peer.id),
|
||||||
|
isNull(schema.federationPeers.peerInstanceId),
|
||||||
|
))
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Validate request body shape
|
||||||
|
const body = request.body;
|
||||||
|
if (!body || body.version !== 1 || !Array.isArray(body.events)) {
|
||||||
|
return reply.code(400).send({ error: 'Invalid relay request format', statusCode: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.events.length > 50) {
|
||||||
|
return reply.code(400).send({ error: 'Maximum 50 events per batch', statusCode: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceInstance = body.sourceInstance;
|
||||||
|
if (!sourceInstance || typeof sourceInstance !== 'string') {
|
||||||
|
return reply.code(400).send({ error: 'sourceInstance is required', statusCode: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Process each event
|
||||||
|
const { accepted, rejected, undeliverable } = await processRelayEvents(body.events, sourceInstance, peer.origin, db);
|
||||||
|
|
||||||
|
// 4. Update peer status
|
||||||
|
db.update(schema.federationPeers)
|
||||||
|
.set({
|
||||||
|
lastSeenAt: Date.now(),
|
||||||
|
consecutiveFailures: 0,
|
||||||
|
...(auth.nonce && !peer.nonceSupported ? { nonceSupported: 1 } : {}),
|
||||||
|
})
|
||||||
|
.where(eq(schema.federationPeers.id, peer.id))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// 5. Return response with max upload size info
|
||||||
|
const settings = db
|
||||||
|
.select({ maxUploadSizeBytes: schema.instanceSettings.maxUploadSizeBytes })
|
||||||
|
.from(schema.instanceSettings)
|
||||||
|
.where(eq(schema.instanceSettings.id, 1))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const response: FederationRelayResponse = {
|
||||||
|
accepted,
|
||||||
|
rejected,
|
||||||
|
maxUploadSize: settings?.maxUploadSizeBytes ?? config.maxUploadSize,
|
||||||
|
...(undeliverable.length > 0 ? { undeliverable } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
return reply.code(200).send(response);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── POST /api/federation/epoch ────────────────────────────────────────────
|
||||||
|
// Server-to-server: return this instance's persistent epoch (instance_id).
|
||||||
|
// Authenticated via HMAC-SHA256 signature on the REQUEST (only a peer holding
|
||||||
|
// the shared secret may call it), and the RESPONSE body is HMAC-SIGNED with
|
||||||
|
// the same secret so the caller can verify the epoch it newly trusts before
|
||||||
|
// writing it as the peer's baseline (design §3.2 / §9). The value itself
|
||||||
|
// (instanceId) is already public via /instance/info; signing is for
|
||||||
|
// baseline-integrity, not confidentiality.
|
||||||
|
//
|
||||||
|
// NON-ADOPTER of authenticateS2SPeer (deliberate): gates on status !== 'revoked'
|
||||||
|
// (ANY non-revoked peer must answer so a needs_attention/unreachable peer can
|
||||||
|
// drive RECOVERY via this signed round-trip), returns 400 (not 401) on missing
|
||||||
|
// headers, and runs NO nonce check. Folding it into the helper would flatten the
|
||||||
|
// recovery gate and the status code.
|
||||||
|
app.post(
|
||||||
|
'/api/federation/epoch',
|
||||||
|
{ bodyLimit: 4 * 1024 },
|
||||||
|
async (request, reply) => {
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
// 1. Parse and require federation headers (mirror relay/users-lookup).
|
||||||
|
const fedHeaders = parseFederationHeaders(request.headers as Record<string, string | string[] | undefined>);
|
||||||
|
if (!fedHeaders) {
|
||||||
|
return reply.code(400).send({ error: 'Missing or malformed federation headers', statusCode: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Resolve the peer by origin. Reject unknown or revoked peers.
|
||||||
|
const peer = db
|
||||||
|
.select()
|
||||||
|
.from(schema.federationPeers)
|
||||||
|
.where(eq(schema.federationPeers.origin, fedHeaders.origin))
|
||||||
|
.get();
|
||||||
|
if (!peer || peer.status === 'revoked') {
|
||||||
|
return reply.code(403).send({ error: 'Not peered', statusCode: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Verify the inbound request signature (honours rotation grace).
|
||||||
|
const bodyString = JSON.stringify(request.body ?? {});
|
||||||
|
if (!verifyPeerSignature(bodyString, fedHeaders.signature, fedHeaders.timestamp, fedHeaders.nonce, peer)) {
|
||||||
|
return reply.code(401).send({ error: 'Invalid signature', statusCode: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Sign the response body with the peer's shared secret and return it.
|
||||||
|
return sendSignedJson(reply, { instanceId: getInstanceId() }, peer.hmacSecret);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── POST /api/federation/sync ──────────────────────────────────────────────
|
||||||
|
// Server-to-server: checkpoint catch-up sync. A peer calls this after downtime
|
||||||
|
// to retrieve missed DM mutations from the mutation log.
|
||||||
|
// Authenticated via HMAC-SHA256 signature, same as /relay.
|
||||||
|
app.post<{ Body: FederationSyncRequest }>(
|
||||||
|
'/api/federation/sync',
|
||||||
|
{ bodyLimit: 1024 * 64 },
|
||||||
|
async (request, reply) => {
|
||||||
|
const db = getDb();
|
||||||
|
const rawDb = getRawDb();
|
||||||
|
|
||||||
|
// Shared inbound S2S-auth preamble: headers → active peer → signature →
|
||||||
|
// nonce replay. No rate limiter; warns (with the ` [sync]` tag) on a legacy
|
||||||
|
// peer's missing nonce.
|
||||||
|
const auth = authenticateS2SPeer(request, reply, { logMissingNonce: true, logContext: 'sync' });
|
||||||
|
if (!auth.ok) return;
|
||||||
|
const { peer } = auth;
|
||||||
|
|
||||||
|
// Ratchet: mark peer as nonce-supporting if this is the first nonce we've seen
|
||||||
|
if (auth.nonce && !peer.nonceSupported) {
|
||||||
|
db.update(schema.federationPeers)
|
||||||
|
.set({ nonceSupported: 1 })
|
||||||
|
.where(eq(schema.federationPeers.id, peer.id))
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Validate & normalize request body
|
||||||
|
const body = request.body;
|
||||||
|
if (!body || typeof body.sinceTimestamp !== 'number' || body.sinceTimestamp < 0) {
|
||||||
|
return reply.code(400).send({ error: 'sinceTimestamp must be a non-negative number', statusCode: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sinceTimestamp = body.sinceTimestamp;
|
||||||
|
const dmChannelIdFilter = body.dmChannelId && typeof body.dmChannelId === 'string' ? body.dmChannelId : null;
|
||||||
|
const federatedIdFilter = body.federatedId && typeof body.federatedId === 'string' ? body.federatedId : null;
|
||||||
|
const contextTypeFilter = body.contextType && typeof body.contextType === 'string'
|
||||||
|
? body.contextType as 'dm' | 'friend' | 'profile'
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Clamp limit: min 1, max 500, default 100
|
||||||
|
let limit = typeof body.limit === 'number' ? body.limit : 100;
|
||||||
|
limit = Math.max(1, Math.min(500, Math.floor(limit)));
|
||||||
|
|
||||||
|
// 3. Query mutation log — branch by contextType
|
||||||
|
let mutationRows: Array<{
|
||||||
|
id: string;
|
||||||
|
entity_id: string;
|
||||||
|
context_id: string;
|
||||||
|
context_type: string;
|
||||||
|
mutation_type: string;
|
||||||
|
mutated_at: number;
|
||||||
|
payload: string | null;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
// Maps local DM channel ID → federatedId for O(1) lookup in serializers.
|
||||||
|
// Only populated in the DM branch (friend/profile branches don't need it).
|
||||||
|
let channelFederatedIdMap = new Map<string, string>();
|
||||||
|
|
||||||
|
// Friend-branch pagination must be computed from PRE-filter rows —
|
||||||
|
// filtering in place would stall the checkpoint / drop pages (spec §3.2).
|
||||||
|
let prefilterCount: number | null = null;
|
||||||
|
let prefilterLastTs: number | null = null;
|
||||||
|
|
||||||
|
if (contextTypeFilter === 'friend') {
|
||||||
|
// ── Friend event sync: relevance-scoped to the requesting peer ──
|
||||||
|
const fetchedFriendRows = rawDb.prepare(`
|
||||||
|
SELECT id, entity_id, context_id, context_type, mutation_type, mutated_at, payload
|
||||||
|
FROM federation_mutation_log
|
||||||
|
WHERE context_type = 'friend' AND mutated_at > ?
|
||||||
|
ORDER BY mutated_at ASC
|
||||||
|
LIMIT ?
|
||||||
|
`).all(sinceTimestamp, limit) as typeof mutationRows;
|
||||||
|
|
||||||
|
prefilterCount = fetchedFriendRows.length;
|
||||||
|
prefilterLastTs = fetchedFriendRows.length > 0
|
||||||
|
? fetchedFriendRows[fetchedFriendRows.length - 1]!.mutated_at
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const peerDomainFriend = extractDomain(peer.origin).toLowerCase();
|
||||||
|
const normHome = `lower(replace(replace(coalesce(home_instance, ''), 'https://', ''), 'http://', ''))`;
|
||||||
|
const localRowStmt = rawDb.prepare(`
|
||||||
|
SELECT is_deleted, federation_home_orphaned FROM users
|
||||||
|
WHERE home_user_id = ? AND ${normHome} = ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
// An event qualifies iff at least one side is homed at the requester's
|
||||||
|
// domain AND that side, when it resolves to a local row, is live and
|
||||||
|
// non-detached. A detached/tombstoned row belongs to a dead incarnation
|
||||||
|
// of the requester, not to the requester (spec §3.2).
|
||||||
|
const sideQualifies = (side: { homeUserId?: string; homeInstance?: string } | undefined): boolean => {
|
||||||
|
if (!side?.homeUserId || !side.homeInstance) return false;
|
||||||
|
if (extractDomain(side.homeInstance).toLowerCase() !== peerDomainFriend) return false;
|
||||||
|
const local = localRowStmt.get(side.homeUserId, peerDomainFriend) as
|
||||||
|
{ is_deleted: number; federation_home_orphaned: number } | undefined;
|
||||||
|
if (local && (local.is_deleted === 1 || local.federation_home_orphaned === 1)) return false;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
mutationRows = fetchedFriendRows.filter((row) => {
|
||||||
|
if (!row.payload) return false;
|
||||||
|
let friendship: { from?: { homeUserId?: string; homeInstance?: string }; to?: { homeUserId?: string; homeInstance?: string } } | undefined;
|
||||||
|
try {
|
||||||
|
friendship = (JSON.parse(row.payload) as { friendship?: typeof friendship }).friendship;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!friendship) return false;
|
||||||
|
return sideQualifies(friendship.from) || sideQualifies(friendship.to);
|
||||||
|
});
|
||||||
|
} else if (contextTypeFilter === 'profile') {
|
||||||
|
// ── Profile event sync: no DM channel logic needed ──
|
||||||
|
mutationRows = rawDb.prepare(`
|
||||||
|
SELECT id, entity_id, context_id, context_type, mutation_type, mutated_at, payload
|
||||||
|
FROM federation_mutation_log
|
||||||
|
WHERE context_type = 'profile' AND mutated_at > ?
|
||||||
|
ORDER BY mutated_at ASC
|
||||||
|
LIMIT ?
|
||||||
|
`).all(sinceTimestamp, limit) as typeof mutationRows;
|
||||||
|
} else {
|
||||||
|
// ── DM sync path ──
|
||||||
|
// Determine which DM channels to sync.
|
||||||
|
// Use federated_id: any channel with a federated ID is a federated DM
|
||||||
|
// that should be synced. The peer's relay endpoint will create the channel
|
||||||
|
// if it doesn't exist, or match by federated_id if it does.
|
||||||
|
// Relevance scoping (dead-incarnation spec §3.2): only offer channels
|
||||||
|
// with at least one LIVE member homed at the requesting peer's domain.
|
||||||
|
// A reset peer's former users are detached (federation_home_orphaned=1)
|
||||||
|
// or tombstoned here — their channels are our history, not the new
|
||||||
|
// incarnation's. Channels not involving the requester at all are none
|
||||||
|
// of its business either (third-instance over-broadcast).
|
||||||
|
const peerDomain = extractDomain(peer.origin).toLowerCase();
|
||||||
|
const sharedChannelRows = rawDb.prepare(`
|
||||||
|
SELECT DISTINCT c.id as dm_channel_id, c.federated_id
|
||||||
|
FROM dm_channels c
|
||||||
|
JOIN dm_members m ON m.dm_channel_id = c.id
|
||||||
|
JOIN users u ON u.id = m.user_id
|
||||||
|
WHERE c.federated_id IS NOT NULL AND c.deleted_at IS NULL
|
||||||
|
AND u.is_deleted = 0
|
||||||
|
AND u.federation_home_orphaned = 0
|
||||||
|
AND lower(replace(replace(coalesce(u.home_instance, ''), 'https://', ''), 'http://', '')) = ?
|
||||||
|
`).all(peerDomain) as Array<{ dm_channel_id: string; federated_id: string }>;
|
||||||
|
|
||||||
|
const sharedChannelIds = sharedChannelRows.map(r => r.dm_channel_id);
|
||||||
|
channelFederatedIdMap = new Map<string, string>(
|
||||||
|
sharedChannelRows.map(r => [r.dm_channel_id, r.federated_id])
|
||||||
|
);
|
||||||
|
|
||||||
|
// If filtering by federatedId, resolve to local channel ID
|
||||||
|
let effectiveChannelFilter = dmChannelIdFilter;
|
||||||
|
if (federatedIdFilter && !effectiveChannelFilter) {
|
||||||
|
const fedChannel = rawDb.prepare(`
|
||||||
|
SELECT id FROM dm_channels WHERE federated_id = ?
|
||||||
|
`).get(federatedIdFilter) as { id: string } | undefined;
|
||||||
|
if (fedChannel) {
|
||||||
|
effectiveChannelFilter = fedChannel.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sharedChannelIds.length === 0) {
|
||||||
|
const syncResponse: FederationSyncResponse = {
|
||||||
|
events: [],
|
||||||
|
hasMore: false,
|
||||||
|
checkpoint: sinceTimestamp,
|
||||||
|
};
|
||||||
|
return reply.code(200).send(syncResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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., 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
|
||||||
|
if (!sharedChannelIds.includes(effectiveChannelFilter)) {
|
||||||
|
const syncResponse: FederationSyncResponse = {
|
||||||
|
events: [],
|
||||||
|
hasMore: false,
|
||||||
|
checkpoint: sinceTimestamp,
|
||||||
|
};
|
||||||
|
return reply.code(200).send(syncResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
mutationRows = rawDb.prepare(`
|
||||||
|
SELECT ml.id, ml.entity_id, ml.context_id, ml.context_type, ml.mutation_type, ml.mutated_at, ml.payload
|
||||||
|
FROM federation_mutation_log ml
|
||||||
|
LEFT JOIN dm_messages dm ON ml.entity_id = dm.id
|
||||||
|
WHERE ml.context_id = ?
|
||||||
|
AND ml.context_type = 'dm'
|
||||||
|
AND ml.mutated_at > ?
|
||||||
|
AND (dm.id IS NOT NULL OR ml.mutation_type IN (
|
||||||
|
'delete', 'member_add', 'member_remove', 'ownership_transfer',
|
||||||
|
'dm_close', 'dm_reopen', 'read_state_update', 'file_rejected'
|
||||||
|
))
|
||||||
|
ORDER BY ml.mutated_at ASC
|
||||||
|
LIMIT ?
|
||||||
|
`).all(effectiveChannelFilter, sinceTimestamp, limit) as typeof mutationRows;
|
||||||
|
|
||||||
|
// For delete mutations, the dm_messages row won't exist — handle separately
|
||||||
|
const deleteMutations = rawDb.prepare(`
|
||||||
|
SELECT ml.id, ml.entity_id, ml.context_id, ml.context_type, ml.mutation_type, ml.mutated_at, ml.payload
|
||||||
|
FROM federation_mutation_log ml
|
||||||
|
WHERE ml.context_id = ?
|
||||||
|
AND ml.context_type = 'dm'
|
||||||
|
AND ml.mutated_at > ?
|
||||||
|
AND ml.mutation_type = 'delete'
|
||||||
|
AND ml.entity_id NOT IN (SELECT dm.id FROM dm_messages dm WHERE dm.id = ml.entity_id)
|
||||||
|
ORDER BY ml.mutated_at ASC
|
||||||
|
LIMIT ?
|
||||||
|
`).all(effectiveChannelFilter, sinceTimestamp, limit) as typeof mutationRows;
|
||||||
|
|
||||||
|
// Merge, deduplicate, sort, and re-limit
|
||||||
|
const seen = new Set(mutationRows.map(r => r.id));
|
||||||
|
for (const row of deleteMutations) {
|
||||||
|
if (!seen.has(row.id)) {
|
||||||
|
mutationRows.push(row);
|
||||||
|
seen.add(row.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mutationRows.sort((a, b) => a.mutated_at - b.mutated_at);
|
||||||
|
if (mutationRows.length > limit) {
|
||||||
|
mutationRows = mutationRows.slice(0, limit);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// All shared channels — build IN clause with placeholders
|
||||||
|
const placeholders = sharedChannelIds.map(() => '?').join(',');
|
||||||
|
|
||||||
|
mutationRows = rawDb.prepare(`
|
||||||
|
SELECT ml.id, ml.entity_id, ml.context_id, ml.context_type, ml.mutation_type, ml.mutated_at, ml.payload
|
||||||
|
FROM federation_mutation_log ml
|
||||||
|
LEFT JOIN dm_messages dm ON ml.entity_id = dm.id
|
||||||
|
WHERE ml.context_id IN (${placeholders})
|
||||||
|
AND ml.context_type = 'dm'
|
||||||
|
AND ml.mutated_at > ?
|
||||||
|
AND (dm.id IS NOT NULL OR ml.mutation_type IN (
|
||||||
|
'delete', 'member_add', 'member_remove', 'ownership_transfer',
|
||||||
|
'dm_close', 'dm_reopen', 'read_state_update', 'file_rejected'
|
||||||
|
))
|
||||||
|
ORDER BY ml.mutated_at ASC
|
||||||
|
LIMIT ?
|
||||||
|
`).all(...sharedChannelIds, sinceTimestamp, limit) as typeof mutationRows;
|
||||||
|
|
||||||
|
// For delete mutations, the dm_messages row won't exist — handle separately
|
||||||
|
const deleteMutations = rawDb.prepare(`
|
||||||
|
SELECT ml.id, ml.entity_id, ml.context_id, ml.context_type, ml.mutation_type, ml.mutated_at, ml.payload
|
||||||
|
FROM federation_mutation_log ml
|
||||||
|
WHERE ml.context_id IN (${placeholders})
|
||||||
|
AND ml.context_type = 'dm'
|
||||||
|
AND ml.mutated_at > ?
|
||||||
|
AND ml.mutation_type = 'delete'
|
||||||
|
AND ml.entity_id NOT IN (SELECT dm.id FROM dm_messages dm WHERE dm.id = ml.entity_id)
|
||||||
|
ORDER BY ml.mutated_at ASC
|
||||||
|
LIMIT ?
|
||||||
|
`).all(...sharedChannelIds, sinceTimestamp, limit) as typeof mutationRows;
|
||||||
|
|
||||||
|
// Merge, deduplicate, sort, and re-limit
|
||||||
|
const seen = new Set(mutationRows.map(r => r.id));
|
||||||
|
for (const row of deleteMutations) {
|
||||||
|
if (!seen.has(row.id)) {
|
||||||
|
mutationRows.push(row);
|
||||||
|
seen.add(row.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mutationRows.sort((a, b) => a.mutated_at - b.mutated_at);
|
||||||
|
if (mutationRows.length > limit) {
|
||||||
|
mutationRows = mutationRows.slice(0, limit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Build response events from mutation log entries
|
||||||
|
const events: FederationRelayEvent[] = [];
|
||||||
|
|
||||||
|
for (const mutation of mutationRows) {
|
||||||
|
const mutationType = mutation.mutation_type as 'create' | 'update' | 'delete' | 'reaction_add' | 'reaction_remove'
|
||||||
|
| 'member_add' | 'member_remove' | 'ownership_transfer'
|
||||||
|
| 'friend_request_create' | 'friend_request_update' | 'friend_request_cancel'
|
||||||
|
| 'friend_add' | 'friend_remove'
|
||||||
|
| 'dm_close' | 'dm_reopen' | 'read_state_update' | 'file_rejected'
|
||||||
|
| 'profile_update';
|
||||||
|
|
||||||
|
if (['member_add', 'member_remove', 'ownership_transfer',
|
||||||
|
'friend_request_create', 'friend_request_update', 'friend_request_cancel',
|
||||||
|
'friend_add', 'friend_remove'].includes(mutationType)) {
|
||||||
|
// Membership and friend mutations store the full event in the payload
|
||||||
|
const payload = mutation.payload ? JSON.parse(mutation.payload) : {};
|
||||||
|
events.push({
|
||||||
|
eventType: mutationType as FederationRelayEvent['eventType'],
|
||||||
|
contextType: (mutation.context_type ?? 'dm') as 'dm' | 'friend' | 'profile',
|
||||||
|
...(mutation.context_type === 'dm' || !mutation.context_type ? { dmChannelId: mutation.context_id } : {}),
|
||||||
|
messageId: mutation.entity_id,
|
||||||
|
encryptionVersion: 0,
|
||||||
|
timestamp: mutation.mutated_at,
|
||||||
|
...payload,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mutationType === 'delete') {
|
||||||
|
// For deletes, we don't need the message content — just the ID and channel
|
||||||
|
events.push({
|
||||||
|
eventType: 'delete',
|
||||||
|
dmChannelId: mutation.context_id,
|
||||||
|
messageId: mutation.entity_id,
|
||||||
|
encryptionVersion: 0,
|
||||||
|
timestamp: mutation.mutated_at,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mutationType === 'reaction_add' || mutationType === 'reaction_remove') {
|
||||||
|
// Use the stored payload from the mutation log
|
||||||
|
if (mutation.payload) {
|
||||||
|
let reactionData: { userId: string; homeUserId: string; homeInstance?: string; emoji: string; createdAt?: number } | null = null;
|
||||||
|
try {
|
||||||
|
reactionData = JSON.parse(mutation.payload) as { userId: string; homeUserId: string; homeInstance?: string; emoji: string; createdAt?: number };
|
||||||
|
} catch {
|
||||||
|
// Skip malformed payload
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
events.push({
|
||||||
|
eventType: mutationType,
|
||||||
|
dmChannelId: mutation.context_id,
|
||||||
|
messageId: mutation.entity_id,
|
||||||
|
encryptionVersion: 0,
|
||||||
|
timestamp: mutation.mutated_at,
|
||||||
|
reaction: {
|
||||||
|
userId: reactionData.userId,
|
||||||
|
homeUserId: reactionData.homeUserId,
|
||||||
|
homeInstance: reactionData.homeInstance || getOurOrigin(),
|
||||||
|
emoji: reactionData.emoji,
|
||||||
|
createdAt: reactionData.createdAt ?? mutation.mutated_at,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mutationType === 'dm_close' || mutationType === 'dm_reopen') {
|
||||||
|
if (!mutation.payload) continue;
|
||||||
|
let dmCloseReopenPayload: { homeUserId: string; homeInstance: string } | null = null;
|
||||||
|
try { dmCloseReopenPayload = JSON.parse(mutation.payload); } catch { continue; }
|
||||||
|
if (!dmCloseReopenPayload) continue;
|
||||||
|
const fedIdCloseReopen = channelFederatedIdMap.get(mutation.context_id);
|
||||||
|
if (!fedIdCloseReopen) continue;
|
||||||
|
events.push({
|
||||||
|
eventType: mutationType,
|
||||||
|
dmChannelId: mutation.context_id,
|
||||||
|
messageId: mutation.entity_id,
|
||||||
|
federatedId: fedIdCloseReopen,
|
||||||
|
encryptionVersion: 0,
|
||||||
|
timestamp: mutation.mutated_at,
|
||||||
|
dmCloseReopen: dmCloseReopenPayload,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mutationType === 'read_state_update') {
|
||||||
|
if (!mutation.payload) continue;
|
||||||
|
let readState: NonNullable<FederationRelayEvent['readState']> | null = null;
|
||||||
|
try { readState = JSON.parse(mutation.payload) as NonNullable<FederationRelayEvent['readState']>; } catch { continue; }
|
||||||
|
if (!readState) continue;
|
||||||
|
const fedIdReadState = channelFederatedIdMap.get(mutation.context_id);
|
||||||
|
if (!fedIdReadState) continue;
|
||||||
|
events.push({
|
||||||
|
eventType: 'read_state_update',
|
||||||
|
dmChannelId: mutation.context_id,
|
||||||
|
messageId: mutation.entity_id,
|
||||||
|
federatedId: fedIdReadState,
|
||||||
|
encryptionVersion: 0,
|
||||||
|
timestamp: mutation.mutated_at,
|
||||||
|
readState,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mutationType === 'file_rejected') {
|
||||||
|
if (!mutation.payload) continue;
|
||||||
|
let fileRejectedPayload: {
|
||||||
|
attachmentId: string;
|
||||||
|
sourceFilename: string;
|
||||||
|
rejectionReason: string;
|
||||||
|
rejectionLimit: number;
|
||||||
|
affectedUserIds: string[];
|
||||||
|
} | null = null;
|
||||||
|
try { fileRejectedPayload = JSON.parse(mutation.payload); } catch { continue; }
|
||||||
|
if (!fileRejectedPayload) continue;
|
||||||
|
events.push({
|
||||||
|
eventType: 'file_rejected',
|
||||||
|
dmChannelId: mutation.context_id,
|
||||||
|
messageId: mutation.entity_id,
|
||||||
|
encryptionVersion: 0,
|
||||||
|
timestamp: mutation.mutated_at,
|
||||||
|
attachmentId: fileRejectedPayload.attachmentId,
|
||||||
|
sourceFilename: fileRejectedPayload.sourceFilename,
|
||||||
|
rejectionReason: fileRejectedPayload.rejectionReason,
|
||||||
|
rejectionLimit: fileRejectedPayload.rejectionLimit,
|
||||||
|
affectedUserIds: fileRejectedPayload.affectedUserIds,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mutationType === 'profile_update') {
|
||||||
|
if (!mutation.payload) continue;
|
||||||
|
let profileOuter: { profileUpdate?: NonNullable<FederationRelayEvent['profileUpdate']> } | null = null;
|
||||||
|
try { profileOuter = JSON.parse(mutation.payload); } catch { continue; }
|
||||||
|
if (!profileOuter?.profileUpdate) continue;
|
||||||
|
events.push({
|
||||||
|
eventType: 'profile_update',
|
||||||
|
contextType: 'profile',
|
||||||
|
messageId: mutation.entity_id,
|
||||||
|
encryptionVersion: 0,
|
||||||
|
timestamp: mutation.mutated_at,
|
||||||
|
profileUpdate: profileOuter.profileUpdate,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For create and update: fetch the current message state
|
||||||
|
const message = db
|
||||||
|
.select()
|
||||||
|
.from(schema.dmMessages)
|
||||||
|
.where(eq(schema.dmMessages.id, mutation.entity_id))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!message) {
|
||||||
|
// Message was deleted after this create/update mutation was logged — skip it.
|
||||||
|
// The delete mutation will handle the cleanup on the peer side.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the author user to get homeUserId and homeInstance
|
||||||
|
const authorUser = db
|
||||||
|
.select()
|
||||||
|
.from(schema.users)
|
||||||
|
.where(eq(schema.users.id, message.userId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!authorUser) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const homeUserId = authorUser.homeUserId || authorUser.id;
|
||||||
|
const homeInstance = authorUser.homeInstance || (config.domain ? `https://${config.domain}` : '');
|
||||||
|
|
||||||
|
// Fetch attachments for the message
|
||||||
|
const attachmentRows = db
|
||||||
|
.select()
|
||||||
|
.from(schema.attachments)
|
||||||
|
.where(eq(schema.attachments.dmMessageId, message.id))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
let localOrigin: string;
|
||||||
|
try {
|
||||||
|
localOrigin = resolveLocalOrigin();
|
||||||
|
} catch {
|
||||||
|
localOrigin = config.domain ? `https://${config.domain}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachments: FederationRelayAttachment[] = attachmentRows.map(a => ({
|
||||||
|
id: a.id,
|
||||||
|
filename: a.filename,
|
||||||
|
originalName: a.originalName,
|
||||||
|
mimetype: a.mimetype,
|
||||||
|
size: a.size,
|
||||||
|
width: a.width ?? undefined,
|
||||||
|
height: a.height ?? undefined,
|
||||||
|
duration: a.duration ?? undefined,
|
||||||
|
playable: a.playable ?? null,
|
||||||
|
thumbnailFilename: a.thumbnailFilename ?? undefined,
|
||||||
|
sourceUrl: `${localOrigin}/api/uploads/${a.filename}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Include federatedId for group DMs so the peer uses the correct
|
||||||
|
// channel lookup path instead of computing a 1-on-1 pair hash.
|
||||||
|
const syncChannel = db
|
||||||
|
.select({ federatedId: schema.dmChannels.federatedId, ownerId: schema.dmChannels.ownerId })
|
||||||
|
.from(schema.dmChannels)
|
||||||
|
.where(eq(schema.dmChannels.id, mutation.context_id))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
events.push({
|
||||||
|
eventType: mutationType,
|
||||||
|
...(syncChannel?.federatedId && syncChannel.ownerId ? { federatedId: syncChannel.federatedId } : {}),
|
||||||
|
dmChannelId: mutation.context_id,
|
||||||
|
messageId: message.id,
|
||||||
|
encryptionVersion: 0,
|
||||||
|
timestamp: mutation.mutated_at,
|
||||||
|
participants: getDmParticipants(mutation.context_id),
|
||||||
|
message: {
|
||||||
|
userId: message.userId,
|
||||||
|
homeUserId,
|
||||||
|
homeInstance,
|
||||||
|
content: message.content,
|
||||||
|
replyToId: message.replyToId ?? null,
|
||||||
|
editedAt: message.editedAt ?? null,
|
||||||
|
createdAt: message.createdAt,
|
||||||
|
attachments: attachments.length > 0 ? attachments : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Compute pagination metadata — from PRE-filter rows when the friend
|
||||||
|
// branch filtered, so filtered-out events still advance the cursor.
|
||||||
|
const hasMore = (prefilterCount ?? mutationRows.length) >= limit;
|
||||||
|
const checkpoint = prefilterLastTs
|
||||||
|
?? (mutationRows.length > 0
|
||||||
|
? mutationRows[mutationRows.length - 1]!.mutated_at
|
||||||
|
: sinceTimestamp);
|
||||||
|
|
||||||
|
// 7. Update peer last-seen timestamp
|
||||||
|
db.update(schema.federationPeers)
|
||||||
|
.set({ lastSeenAt: Date.now() })
|
||||||
|
.where(eq(schema.federationPeers.id, peer.id))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
const syncResponse: FederationSyncResponse = {
|
||||||
|
events,
|
||||||
|
hasMore,
|
||||||
|
checkpoint,
|
||||||
|
};
|
||||||
|
|
||||||
|
return reply.code(200).send(syncResponse);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import Fastify, { type FastifyInstance } from 'fastify';
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import * as schema from '../../../db/schema.js';
|
||||||
|
import { setWorkerId } from '../../../utils/snowflake.js';
|
||||||
|
import { signRequest } from '../../../utils/federationAuth.js';
|
||||||
|
import type { S2SAuthOptions } from './s2sAuth.js';
|
||||||
|
|
||||||
|
setWorkerId(1);
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
// Module-level mutable state. Each beforeEach reassigns sqlite/testDb;
|
||||||
|
// the getDb getter in the mock closes over the current binding.
|
||||||
|
type TestDb = ReturnType<typeof drizzle<typeof schema>>;
|
||||||
|
let sqlite: Database.Database;
|
||||||
|
let testDb: TestDb;
|
||||||
|
|
||||||
|
const PEER_ORIGIN = 'https://orbit.test';
|
||||||
|
const PEER_SECRET = 'a'.repeat(64);
|
||||||
|
|
||||||
|
vi.mock('../../../db/index.js', () => ({
|
||||||
|
getDb: () => testDb,
|
||||||
|
getRawDb: () => sqlite,
|
||||||
|
schema,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Wrap verifyPeerSignature in a passthrough spy so ordering (rate-limit BEFORE
|
||||||
|
// signature) can be asserted by call count while real HMAC verification still runs.
|
||||||
|
const { verifySpy } = vi.hoisted(() => ({ verifySpy: vi.fn() }));
|
||||||
|
vi.mock('../../../utils/federationAuth.js', async (importActual) => {
|
||||||
|
const actual = await importActual<typeof import('../../../utils/federationAuth.js')>();
|
||||||
|
verifySpy.mockImplementation(actual.verifyPeerSignature);
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
verifyPeerSignature: (...args: Parameters<typeof actual.verifyPeerSignature>) => verifySpy(...args),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function applyMigrations(db: Database.Database): void {
|
||||||
|
const dir = path.resolve(__dirname, '../../../../drizzle');
|
||||||
|
for (const f of fs.readdirSync(dir).filter(f => f.endsWith('.sql')).sort()) {
|
||||||
|
const sqlText = fs.readFileSync(path.join(dir, f), 'utf8');
|
||||||
|
for (const stmt of sqlText.split(/-->\s*statement-breakpoint/)) {
|
||||||
|
const clean = stmt.trim();
|
||||||
|
if (clean) db.exec(clean);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedPeer(status = 'active', nonceSupported = 0): void {
|
||||||
|
testDb.insert(schema.federationPeers).values({
|
||||||
|
id: 'peer-1',
|
||||||
|
origin: PEER_ORIGIN,
|
||||||
|
hmacSecret: PEER_SECRET,
|
||||||
|
status,
|
||||||
|
nonceSupported,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
lastSeenAt: Date.now(),
|
||||||
|
consecutiveFailures: 0,
|
||||||
|
consecutiveAuthFailures: 0,
|
||||||
|
} as typeof schema.federationPeers.$inferInsert).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a tiny app whose sole route drives authenticateS2SPeer and echoes the
|
||||||
|
// result. `authOpts` is injected verbatim so the rate-limiter (an injectable
|
||||||
|
// plain object) and log flags can be controlled per test.
|
||||||
|
async function buildApp(authOpts: S2SAuthOptions = {}): Promise<FastifyInstance> {
|
||||||
|
const app = Fastify({ logger: false });
|
||||||
|
const { authenticateS2SPeer } = await import('./s2sAuth.js');
|
||||||
|
app.post('/probe', async (request, reply) => {
|
||||||
|
const result = authenticateS2SPeer(request, reply, authOpts);
|
||||||
|
if (!result.ok) return; // a reply was already sent
|
||||||
|
return reply.code(200).send({
|
||||||
|
ok: true,
|
||||||
|
peerOrigin: result.peer.origin,
|
||||||
|
nonce: result.nonce,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await app.ready();
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Signed headers WITH a nonce (default valid path). */
|
||||||
|
function signedHeaders(body: string, nonce: string = randomUUID()): Record<string, string> {
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const sig = signRequest(body, PEER_SECRET, timestamp, nonce);
|
||||||
|
return {
|
||||||
|
'X-Federation-Origin': PEER_ORIGIN,
|
||||||
|
'X-Federation-Timestamp': String(timestamp),
|
||||||
|
'X-Federation-Nonce': nonce,
|
||||||
|
'X-Federation-Signature': `sha256=${sig}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Signed headers WITHOUT a nonce (legacy peer form: sign `${ts}.${body}`). */
|
||||||
|
function signedHeadersNoNonce(body: string): Record<string, string> {
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const sig = signRequest(body, PEER_SECRET, timestamp, null);
|
||||||
|
return {
|
||||||
|
'X-Federation-Origin': PEER_ORIGIN,
|
||||||
|
'X-Federation-Timestamp': String(timestamp),
|
||||||
|
'X-Federation-Signature': `sha256=${sig}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probe(app: FastifyInstance, headers: Record<string, string>, body: object = {}) {
|
||||||
|
return app.inject({ method: 'POST', url: '/probe', headers, payload: JSON.stringify(body) });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('authenticateS2SPeer', () => {
|
||||||
|
let warnSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
sqlite = new Database(':memory:');
|
||||||
|
testDb = drizzle(sqlite, { schema });
|
||||||
|
applyMigrations(sqlite);
|
||||||
|
verifySpy.mockClear();
|
||||||
|
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
sqlite.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('missing/invalid federation headers → 401 (helper never emits /epoch\'s 400)', async () => {
|
||||||
|
seedPeer('active');
|
||||||
|
const app = await buildApp();
|
||||||
|
const res = await probe(app, { 'Content-Type': 'application/json' }, { hello: 'world' });
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
expect(JSON.parse(res.body)).toEqual({ error: 'Missing or malformed federation headers', statusCode: 401 });
|
||||||
|
// Guard against the non-adopter /epoch's 400: the shared helper is 401-only here.
|
||||||
|
expect(res.statusCode).not.toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('peer not found → 403 with exact body', async () => {
|
||||||
|
// No peer seeded.
|
||||||
|
const app = await buildApp();
|
||||||
|
const body = { hello: 'world' };
|
||||||
|
const res = await probe(app, signedHeaders(JSON.stringify(body)), body);
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
expect(JSON.parse(res.body)).toEqual({ error: 'Unknown or inactive peer', statusCode: 403 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('peer present but non-active status → 403 with exact body', async () => {
|
||||||
|
seedPeer('needs_attention');
|
||||||
|
const app = await buildApp();
|
||||||
|
const body = { hello: 'world' };
|
||||||
|
const res = await probe(app, signedHeaders(JSON.stringify(body)), body);
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
expect(JSON.parse(res.body)).toEqual({ error: 'Unknown or inactive peer', statusCode: 403 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rate-limited WITH retryAfterSeconds → 429 + Retry-After header', async () => {
|
||||||
|
seedPeer('active');
|
||||||
|
const app = await buildApp({ rateLimiter: { limited: () => true, retryAfterSeconds: 60 } });
|
||||||
|
const body = { hello: 'world' };
|
||||||
|
const res = await probe(app, signedHeaders(JSON.stringify(body)), body);
|
||||||
|
expect(res.statusCode).toBe(429);
|
||||||
|
expect(res.headers['retry-after']).toBe('60');
|
||||||
|
expect(JSON.parse(res.body)).toEqual({ error: 'Rate limit exceeded', statusCode: 429 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rate-limited WITHOUT retryAfterSeconds → 429, no Retry-After header', async () => {
|
||||||
|
seedPeer('active');
|
||||||
|
const app = await buildApp({ rateLimiter: { limited: () => true } });
|
||||||
|
const body = { hello: 'world' };
|
||||||
|
const res = await probe(app, signedHeaders(JSON.stringify(body)), body);
|
||||||
|
expect(res.statusCode).toBe(429);
|
||||||
|
expect(res.headers['retry-after']).toBeUndefined();
|
||||||
|
expect(JSON.parse(res.body)).toEqual({ error: 'Rate limit exceeded', statusCode: 429 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rate-limit fires BEFORE signature verification (verifyPeerSignature not reached)', async () => {
|
||||||
|
seedPeer('active');
|
||||||
|
const app = await buildApp({ rateLimiter: { limited: () => true, retryAfterSeconds: 60 } });
|
||||||
|
// Deliberately BAD signature: if signature ran first we would see 401, not 429.
|
||||||
|
const headers = signedHeaders(JSON.stringify({ hello: 'world' }));
|
||||||
|
headers['X-Federation-Signature'] = 'sha256=' + 'f'.repeat(64);
|
||||||
|
const res = await probe(app, headers, { hello: 'world' });
|
||||||
|
expect(res.statusCode).toBe(429);
|
||||||
|
expect(verifySpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bad signature → 401 with exact body', async () => {
|
||||||
|
seedPeer('active');
|
||||||
|
const app = await buildApp();
|
||||||
|
const headers = signedHeaders(JSON.stringify({ hello: 'world' }));
|
||||||
|
headers['X-Federation-Signature'] = 'sha256=' + 'f'.repeat(64);
|
||||||
|
const res = await probe(app, headers, { hello: 'world' });
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
expect(JSON.parse(res.body)).toEqual({ error: 'Invalid signature', statusCode: 401 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('duplicate nonce → 409 with exact body', async () => {
|
||||||
|
seedPeer('active');
|
||||||
|
const app = await buildApp();
|
||||||
|
const body = { hello: 'world' };
|
||||||
|
const nonce = 'dup-nonce-fixed-1';
|
||||||
|
// First request records the nonce and passes.
|
||||||
|
const first = await probe(app, signedHeaders(JSON.stringify(body), nonce), body);
|
||||||
|
expect(first.statusCode).toBe(200);
|
||||||
|
// Second request with the SAME nonce is a replay.
|
||||||
|
const second = await probe(app, signedHeaders(JSON.stringify(body), nonce), body);
|
||||||
|
expect(second.statusCode).toBe(409);
|
||||||
|
expect(JSON.parse(second.body)).toEqual({ error: 'Duplicate nonce — possible replay', statusCode: 409 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nonce missing + peer SUPPORTS nonce → 401 with exact body', async () => {
|
||||||
|
seedPeer('active', 1); // nonceSupported = 1
|
||||||
|
const app = await buildApp();
|
||||||
|
const body = { hello: 'world' };
|
||||||
|
const res = await probe(app, signedHeadersNoNonce(JSON.stringify(body)), body);
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
expect(JSON.parse(res.body)).toEqual({ error: 'Nonce required — peer previously supported nonces', statusCode: 401 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nonce missing + peer does NOT support nonce → passes; logMissingNonce=true warns', async () => {
|
||||||
|
seedPeer('active', 0);
|
||||||
|
const app = await buildApp({ logMissingNonce: true });
|
||||||
|
const body = { hello: 'world' };
|
||||||
|
const res = await probe(app, signedHeadersNoNonce(JSON.stringify(body)), body);
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(JSON.parse(res.body)).toEqual({ ok: true, peerOrigin: PEER_ORIGIN, nonce: null });
|
||||||
|
expect(warnSpy).toHaveBeenCalledWith(
|
||||||
|
`[federation] Peer ${PEER_ORIGIN} does not support replay protection (no nonce)`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nonce missing + peer does NOT support nonce → passes; logMissingNonce=false stays silent', async () => {
|
||||||
|
seedPeer('active', 0);
|
||||||
|
const app = await buildApp({ logMissingNonce: false });
|
||||||
|
const body = { hello: 'world' };
|
||||||
|
const res = await probe(app, signedHeadersNoNonce(JSON.stringify(body)), body);
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(warnSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logContext appends the endpoint suffix to the missing-nonce warn (sync parity)', async () => {
|
||||||
|
seedPeer('active', 0);
|
||||||
|
const app = await buildApp({ logMissingNonce: true, logContext: 'sync' });
|
||||||
|
const body = { hello: 'world' };
|
||||||
|
const res = await probe(app, signedHeadersNoNonce(JSON.stringify(body)), body);
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(warnSpy).toHaveBeenCalledWith(
|
||||||
|
`[federation] Peer ${PEER_ORIGIN} does not support replay protection (no nonce) [sync]`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('success → { ok:true, peer, nonce } with the parsed nonce', async () => {
|
||||||
|
seedPeer('active');
|
||||||
|
const app = await buildApp();
|
||||||
|
const body = { hello: 'world' };
|
||||||
|
const nonce = 'success-nonce-1';
|
||||||
|
const res = await probe(app, signedHeaders(JSON.stringify(body), nonce), body);
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(JSON.parse(res.body)).toEqual({ ok: true, peerOrigin: PEER_ORIGIN, nonce });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('success with a rate-limiter that is under the cap → passes through', async () => {
|
||||||
|
seedPeer('active');
|
||||||
|
const app = await buildApp({ rateLimiter: { limited: () => false, retryAfterSeconds: 60 } });
|
||||||
|
const body = { hello: 'world' };
|
||||||
|
const res = await probe(app, signedHeaders(JSON.stringify(body)), body);
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(JSON.parse(res.body).ok).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import { getDb, schema } from '../../../db/index.js';
|
||||||
|
import { parseFederationHeaders, verifyPeerSignature } from '../../../utils/federationAuth.js';
|
||||||
|
import { isNonceDuplicate } from '../rateLimits.js';
|
||||||
|
|
||||||
|
/** A `federation_peers` row, as returned by a `select().from(...).get()`. */
|
||||||
|
type FederationPeerRow = typeof schema.federationPeers.$inferSelect;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A rate limiter for the auth preamble. `limited(key)` returns true once the key
|
||||||
|
* (always `peer.origin` here) is at capacity. When `retryAfterSeconds` is set, a
|
||||||
|
* `Retry-After` header carrying that value is added to the 429 response.
|
||||||
|
*/
|
||||||
|
export interface S2SRateLimiter {
|
||||||
|
limited: (key: string) => boolean;
|
||||||
|
retryAfterSeconds?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface S2SAuthOptions {
|
||||||
|
/** Run this limiter (keyed on `peer.origin`) BEFORE signature verification. */
|
||||||
|
rateLimiter?: S2SRateLimiter;
|
||||||
|
/**
|
||||||
|
* When a request omits a nonce AND the peer has never advertised nonce
|
||||||
|
* support, emit the legacy `console.warn`. Endpoints that historically logged
|
||||||
|
* this pass `true`; those that stayed silent pass `false`/omit.
|
||||||
|
*/
|
||||||
|
logMissingNonce?: boolean;
|
||||||
|
/**
|
||||||
|
* Optional suffix for the missing-nonce warning, appended as ` [${logContext}]`.
|
||||||
|
* Preserves the per-endpoint log tag (`/sync` logged a ` [sync]` suffix; the
|
||||||
|
* `/identity` and `/relay` handlers logged no suffix).
|
||||||
|
*/
|
||||||
|
logContext?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of {@link authenticateS2SPeer}. On `ok: false` a reply has ALREADY been
|
||||||
|
* sent — the caller MUST `return` immediately without touching `reply` again.
|
||||||
|
*/
|
||||||
|
export type S2SAuthResult =
|
||||||
|
| { ok: true; peer: FederationPeerRow; nonce: string | null }
|
||||||
|
| { ok: false };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared inbound S2S-auth preamble for HMAC-signed federation endpoints.
|
||||||
|
*
|
||||||
|
* Runs, IN THIS EXACT ORDER, the boilerplate that six endpoints share verbatim:
|
||||||
|
* 1. Parse federation headers — missing/malformed → 401.
|
||||||
|
* 2. Resolve the peer by origin; require `status === 'active'` → else 403.
|
||||||
|
* 3. (optional) Rate-limit on `peer.origin` — 429 (+ `Retry-After` when
|
||||||
|
* configured). Deliberately BEFORE signature verification so a flooded peer
|
||||||
|
* never costs an HMAC computation.
|
||||||
|
* 4. Verify the HMAC signature (honours rotation grace) → 401 on failure.
|
||||||
|
* 5. Nonce replay protection: present + duplicate → 409; absent while the peer
|
||||||
|
* advertises nonce support → 401; absent otherwise → pass (optionally warn).
|
||||||
|
*
|
||||||
|
* On success returns `{ ok: true, peer, nonce }`; the caller resumes with its
|
||||||
|
* own body validation and side effects. On any rejection the reply is sent and
|
||||||
|
* `{ ok: false }` is returned — the caller must `return` at once.
|
||||||
|
*
|
||||||
|
* ── INTENTIONAL NON-ADOPTERS (do NOT fold these into this helper) ─────────────
|
||||||
|
* Three S2S endpoints deliberately keep bespoke auth because a load-bearing gate
|
||||||
|
* differs; sharing this helper would silently flatten it:
|
||||||
|
* • `POST /api/federation/epoch` — gates on `status !== 'revoked'` (ANY
|
||||||
|
* non-revoked peer answers, so a needs_attention/unreachable peer can drive
|
||||||
|
* RECOVERY via the signed epoch round-trip), returns **400** (not 401) on
|
||||||
|
* missing headers, and runs **no** nonce check.
|
||||||
|
* • `POST /api/federation/peer/rotate` — active-only but runs **no** nonce
|
||||||
|
* check (a lone shape; the rotation body is the replay unit).
|
||||||
|
* • `POST /api/federation/peer/denied` — gates on `awaiting_approval` (404 on
|
||||||
|
* no peer row, 409 on wrong status) and verifies against a SYNTHETIC
|
||||||
|
* no-grace secret object; entirely different control flow.
|
||||||
|
* Also out of scope: `/peer/accept`, `/peer/initiate`, `/peer/ensure`
|
||||||
|
* (first-contact / JWT, not S2S-HMAC).
|
||||||
|
*/
|
||||||
|
export function authenticateS2SPeer(
|
||||||
|
request: FastifyRequest,
|
||||||
|
reply: FastifyReply,
|
||||||
|
opts: S2SAuthOptions = {},
|
||||||
|
): S2SAuthResult {
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
// 1. Parse and require federation headers.
|
||||||
|
const fedHeaders = parseFederationHeaders(
|
||||||
|
request.headers as Record<string, string | string[] | undefined>,
|
||||||
|
);
|
||||||
|
if (!fedHeaders) {
|
||||||
|
reply.code(401).send({ error: 'Missing or malformed federation headers', statusCode: 401 });
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Resolve the peer by origin; require an active relationship.
|
||||||
|
const peer = db
|
||||||
|
.select()
|
||||||
|
.from(schema.federationPeers)
|
||||||
|
.where(eq(schema.federationPeers.origin, fedHeaders.origin))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!peer || peer.status !== 'active') {
|
||||||
|
reply.code(403).send({ error: 'Unknown or inactive peer', statusCode: 403 });
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Rate-limit BEFORE signature verification (avoid HMAC work on a flood).
|
||||||
|
if (opts.rateLimiter && opts.rateLimiter.limited(peer.origin)) {
|
||||||
|
reply.code(429);
|
||||||
|
if (opts.rateLimiter.retryAfterSeconds !== undefined) {
|
||||||
|
reply.header('Retry-After', String(opts.rateLimiter.retryAfterSeconds));
|
||||||
|
}
|
||||||
|
reply.send({ error: 'Rate limit exceeded', statusCode: 429 });
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Verify the HMAC signature over the exact serialized body.
|
||||||
|
const bodyString = JSON.stringify(request.body);
|
||||||
|
if (!verifyPeerSignature(bodyString, fedHeaders.signature, fedHeaders.timestamp, fedHeaders.nonce, peer)) {
|
||||||
|
reply.code(401).send({ error: 'Invalid signature', statusCode: 401 });
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Nonce-based replay protection.
|
||||||
|
if (fedHeaders.nonce) {
|
||||||
|
if (isNonceDuplicate(peer.origin, fedHeaders.nonce)) {
|
||||||
|
reply.code(409).send({ error: 'Duplicate nonce — possible replay', statusCode: 409 });
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
} else if (peer.nonceSupported) {
|
||||||
|
// Peer previously proved nonce support but this request omits one — reject.
|
||||||
|
reply.code(401).send({ error: 'Nonce required — peer previously supported nonces', statusCode: 401 });
|
||||||
|
return { ok: false };
|
||||||
|
} else if (opts.logMissingNonce) {
|
||||||
|
const suffix = opts.logContext ? ` [${opts.logContext}]` : '';
|
||||||
|
console.warn(`[federation] Peer ${peer.origin} does not support replay protection (no nonce)${suffix}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, peer, nonce: fedHeaders.nonce };
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { FastifyReply } from 'fastify';
|
||||||
|
import { buildFederationHeaders, getOurOrigin } from '../../../utils/federationAuth.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize `payload` as JSON and send it as a `200` response signed with the
|
||||||
|
* peer's shared HMAC secret, so the receiving instance can verify authenticity
|
||||||
|
* (or trust a fail-closed verdict) of the body it carries.
|
||||||
|
*
|
||||||
|
* This is the single definition of how this instance signs S2S responses: the
|
||||||
|
* body is stringified once and the signature is computed over those exact bytes,
|
||||||
|
* which are the bytes sent (Content-Type is set explicitly so Fastify does not
|
||||||
|
* re-serialize and desync the signature).
|
||||||
|
*/
|
||||||
|
export function sendSignedJson(reply: FastifyReply, payload: unknown, hmacSecret: string): FastifyReply {
|
||||||
|
const responseBody = JSON.stringify(payload);
|
||||||
|
const sigHeaders = buildFederationHeaders(responseBody, hmacSecret, getOurOrigin());
|
||||||
|
reply.headers({
|
||||||
|
'X-Federation-Signature': sigHeaders['X-Federation-Signature'],
|
||||||
|
'X-Federation-Timestamp': sigHeaders['X-Federation-Timestamp'],
|
||||||
|
'X-Federation-Nonce': sigHeaders['X-Federation-Nonce'],
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
});
|
||||||
|
return reply.code(200).send(responseBody);
|
||||||
|
}
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
import { config } from '../../config.js';
|
||||||
|
import { getDb, schema } from '../../db/index.js';
|
||||||
|
import { getOurOrigin } from '../../utils/federationAuth.js';
|
||||||
|
import { generateSnowflake } from '../../utils/snowflake.js';
|
||||||
|
import { and, eq, isNull, or, sql } from 'drizzle-orm';
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract bare domain from a homeInstance value.
|
||||||
|
* Handles both full URLs ("https://nova.ddns.net") and bare domains ("nova.ddns.net").
|
||||||
|
* Used to normalize homeInstance to a canonical format for identity matching.
|
||||||
|
*/
|
||||||
|
export function extractDomain(homeInstance: string): string {
|
||||||
|
try {
|
||||||
|
return new URL(homeInstance).hostname;
|
||||||
|
} catch {
|
||||||
|
// Already a bare domain or malformed — strip protocol manually
|
||||||
|
return homeInstance.replace(/^https?:\/\//, '').split('/')[0] ?? homeInstance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bare lowercase domain that constitutes this instance's federated
|
||||||
|
* identity authority. Derives from DOMAIN (identity), falling back to
|
||||||
|
* getOurOrigin() only when DOMAIN is unset (dev/tests). PUBLIC_ORIGIN is a
|
||||||
|
* transport override and deliberately NOT consulted first — identity
|
||||||
|
* comparisons must not shift when the transport origin is overridden.
|
||||||
|
*/
|
||||||
|
export function getOurIdentityDomain(): string | null {
|
||||||
|
if (config.domain) return config.domain.toLowerCase();
|
||||||
|
const origin = getOurOrigin();
|
||||||
|
if (!origin) return null;
|
||||||
|
return extractDomain(origin).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify that an acting user's homeInstance is legitimate for this relay.
|
||||||
|
*
|
||||||
|
* 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., 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.
|
||||||
|
*/
|
||||||
|
export function verifyAttribution(actingUserHomeInstance: string, sourceInstance: string): boolean {
|
||||||
|
const authorDomain = extractDomain(actingUserHomeInstance);
|
||||||
|
// Case 1: author belongs to the source peer
|
||||||
|
if (authorDomain === extractDomain(sourceInstance)) return true;
|
||||||
|
// Case 2: homeward relay — author belongs to THIS (receiving) instance
|
||||||
|
if (authorDomain === extractDomain(getOurOrigin())) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a home user ID to a local user.
|
||||||
|
* Matches users where home_user_id = homeUserId, or where
|
||||||
|
* the user's own id equals homeUserId and they have no home_instance set (local user).
|
||||||
|
*/
|
||||||
|
export function resolveLocalUser(
|
||||||
|
homeUserId: string,
|
||||||
|
db: ReturnType<typeof getDb>,
|
||||||
|
): typeof schema.users.$inferSelect | undefined {
|
||||||
|
const candidates = db
|
||||||
|
.select()
|
||||||
|
.from(schema.users)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
or(
|
||||||
|
eq(schema.users.homeUserId, homeUserId),
|
||||||
|
and(eq(schema.users.id, homeUserId), isNull(schema.users.homeInstance)),
|
||||||
|
),
|
||||||
|
eq(schema.users.isDeleted, 0),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
// Prefer non-deleted active users; if multiple, prefer the one with homeUserId set
|
||||||
|
// (replicated user) over a local user match
|
||||||
|
if (candidates.length === 0) return undefined;
|
||||||
|
if (candidates.length === 1) return candidates[0];
|
||||||
|
return candidates.find(u => u.homeUserId === homeUserId) ?? candidates[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified federated user lookup — finds a user regardless of which code path
|
||||||
|
* created them (auth registration vs S2S relay stub).
|
||||||
|
*
|
||||||
|
* Three-tier matching:
|
||||||
|
* 1. Fast path: homeUserId column match (existing resolveLocalUser logic)
|
||||||
|
* 2. Domain + username hint: normalized homeInstance domain + username base match
|
||||||
|
* 3. Not found: returns undefined
|
||||||
|
*
|
||||||
|
* Does NOT perform side effects (backfill). See `backfillHomeUserId` for that.
|
||||||
|
*/
|
||||||
|
export function findFederatedUser(
|
||||||
|
homeUserId: string,
|
||||||
|
homeInstance: string,
|
||||||
|
db: ReturnType<typeof getDb>,
|
||||||
|
hints?: { username?: string | null },
|
||||||
|
): typeof schema.users.$inferSelect | undefined {
|
||||||
|
// Tier 1: fast path — existing resolveLocalUser logic
|
||||||
|
const fastMatch = resolveLocalUser(homeUserId, db);
|
||||||
|
if (fastMatch) return fastMatch;
|
||||||
|
|
||||||
|
// Tier 2: domain + username hint match
|
||||||
|
if (!hints?.username) return undefined;
|
||||||
|
|
||||||
|
const domain = extractDomain(homeInstance);
|
||||||
|
const hintLower = hints.username.toLowerCase();
|
||||||
|
|
||||||
|
// Scoped SQL query: match on homeInstance domain + username base
|
||||||
|
// Username base is the part before '@'. We use SQL LIKE to match
|
||||||
|
// '{hint}@%' pattern, plus an exact match for users without '@'.
|
||||||
|
const candidates = db
|
||||||
|
.select()
|
||||||
|
.from(schema.users)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.users.homeInstance, domain),
|
||||||
|
eq(schema.users.isDeleted, 0),
|
||||||
|
// Detached (home-orphaned) accounts are sovereign: never re-bindable to
|
||||||
|
// the domain's new incarnation via username heuristics — that is exactly
|
||||||
|
// how a new same-name user would capture the established account.
|
||||||
|
eq(schema.users.federationHomeOrphaned, 0),
|
||||||
|
or(
|
||||||
|
sql`lower(substr(${schema.users.username}, 1, instr(${schema.users.username}, '@') - 1)) = ${hintLower}`,
|
||||||
|
and(
|
||||||
|
sql`instr(${schema.users.username}, '@') = 0`,
|
||||||
|
sql`lower(${schema.users.username}) = ${hintLower}`,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
if (candidates.length === 0) return undefined;
|
||||||
|
|
||||||
|
// Pick best candidate: prefer real accounts over stubs, then most profile data
|
||||||
|
if (candidates.length === 1) return candidates[0]!;
|
||||||
|
|
||||||
|
return candidates.sort((a, b) => {
|
||||||
|
// Real account (not federation-replicated) wins
|
||||||
|
const aReal = a.passwordHash !== '!federation-replicated' ? 1 : 0;
|
||||||
|
const bReal = b.passwordHash !== '!federation-replicated' ? 1 : 0;
|
||||||
|
if (aReal !== bReal) return bReal - aReal;
|
||||||
|
// More profile data wins
|
||||||
|
const profileCount = (u: typeof a) =>
|
||||||
|
[u.displayName, u.avatar, u.banner, u.bio].filter(Boolean).length;
|
||||||
|
return profileCount(b) - profileCount(a);
|
||||||
|
})[0]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backfill homeUserId on an existing user record so future lookups
|
||||||
|
* use the fast path (tier 1). Called by resolveOrCreateReplicatedUser
|
||||||
|
* after findFederatedUser matches via tier 2.
|
||||||
|
*/
|
||||||
|
export function backfillHomeUserId(
|
||||||
|
user: typeof schema.users.$inferSelect,
|
||||||
|
homeUserId: string,
|
||||||
|
db: ReturnType<typeof getDb>,
|
||||||
|
): typeof schema.users.$inferSelect {
|
||||||
|
if (user.homeUserId === homeUserId) return user;
|
||||||
|
// Only backfill if the user has no homeUserId yet. If they already have a
|
||||||
|
// DIFFERENT non-null homeUserId, this means the wrong user was matched —
|
||||||
|
// overwriting would corrupt their identity.
|
||||||
|
if (user.homeUserId) {
|
||||||
|
console.warn(`[federation] Refusing to overwrite homeUserId on user ${user.id} (${user.username}): existing=${user.homeUserId}, incoming=${homeUserId}`);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
db.update(schema.users)
|
||||||
|
.set({ homeUserId })
|
||||||
|
.where(eq(schema.users.id, user.id))
|
||||||
|
.run();
|
||||||
|
console.log(`[federation] Backfilled homeUserId=${homeUserId} on user ${user.id} (${user.username})`);
|
||||||
|
return { ...user, homeUserId };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a federated participant to a local user, creating a minimal
|
||||||
|
* replicated user stub if one doesn't already exist. This is needed
|
||||||
|
* for the group-DM bootstrap path: when Instance C receives a
|
||||||
|
* member_add event whose roster includes users that only live on
|
||||||
|
* Instance A or B, those users won't have been pre-replicated via the
|
||||||
|
* friend-connect flow. We create a bare-bones row so the local DB
|
||||||
|
* can reference them in dm_members / dm_messages.
|
||||||
|
*/
|
||||||
|
export function resolveOrCreateReplicatedUser(
|
||||||
|
homeUserId: string,
|
||||||
|
homeInstance: string,
|
||||||
|
db: ReturnType<typeof getDb>,
|
||||||
|
hints?: { username?: string | null; status?: 'online' | 'idle' | 'dnd' | 'offline' | null; deleted?: boolean | null },
|
||||||
|
): typeof schema.users.$inferSelect | null {
|
||||||
|
const existing = findFederatedUser(homeUserId, homeInstance, db, hints);
|
||||||
|
if (existing) return backfillHomeUserId(existing, homeUserId, db);
|
||||||
|
|
||||||
|
// A participant the sender marks as deleted must not materialize as a new
|
||||||
|
// stub — mirror of the local-tombstone skip below. An existing row still
|
||||||
|
// resolves above, so historical attribution is unaffected (spec §3.3).
|
||||||
|
if (hints?.deleted) {
|
||||||
|
console.log(`[federation] Skipping stub creation for remotely-deleted identity homeUserId=${homeUserId}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this identity was previously deleted — don't resurrect a tombstoned
|
||||||
|
// user by creating a new stub. The isDeleted=0 filter in findFederatedUser
|
||||||
|
// already hides the deleted row, so we must query without that filter here.
|
||||||
|
const domain = extractDomain(homeInstance);
|
||||||
|
|
||||||
|
// An instance never hosts a replicated stub homed at itself. A self-domain
|
||||||
|
// identity that is live resolves at tier 1 above (native id match); one
|
||||||
|
// that reaches the create path is a dead incarnation from before an
|
||||||
|
// instance reset (e.g. replayed by a peer's initial sync). Creating a row
|
||||||
|
// here is what produced the self-homed double-domain junk stubs.
|
||||||
|
const ourDomain = getOurIdentityDomain();
|
||||||
|
if (ourDomain && domain.toLowerCase() === ourDomain) {
|
||||||
|
console.log(`[federation] Refusing self-homed stub for homeUserId=${homeUserId} (${domain}) — dead incarnation of this instance`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletedMatch = db
|
||||||
|
.select({ id: schema.users.id, isDeleted: schema.users.isDeleted })
|
||||||
|
.from(schema.users)
|
||||||
|
.where(and(eq(schema.users.homeUserId, homeUserId), eq(schema.users.homeInstance, domain)))
|
||||||
|
.get();
|
||||||
|
if (deletedMatch?.isDeleted) {
|
||||||
|
console.log(`[federation] Skipping stub creation for deleted identity homeUserId=${homeUserId} (tombstoned)`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the home user's real username when the caller passes a hint (the wire
|
||||||
|
// profile snapshot from friend_request_create / friend_add / DM relay carries
|
||||||
|
// it). This makes the local stub's `username` human-readable, so client-side
|
||||||
|
// `parseFederatedUsername(username).baseName` returns the real handle. Falls
|
||||||
|
// back to the snowflake-id scheme when no hint is available (legacy paths).
|
||||||
|
const localPart = (hints?.username ?? homeUserId).toLowerCase();
|
||||||
|
const baseUsername = `${localPart}@${domain}`.toLowerCase();
|
||||||
|
|
||||||
|
// Guard against the (unlikely) case where this username already
|
||||||
|
// exists — e.g. a prior partial replication or manual creation.
|
||||||
|
let username = baseUsername;
|
||||||
|
let collision = db.select().from(schema.users).where(eq(schema.users.username, username)).get();
|
||||||
|
let attempt = 0;
|
||||||
|
while (collision) {
|
||||||
|
attempt++;
|
||||||
|
username = `${localPart}_${attempt}@${domain}`.toLowerCase();
|
||||||
|
collision = db.select().from(schema.users).where(eq(schema.users.username, username)).get();
|
||||||
|
if (attempt > 10) {
|
||||||
|
// Extremely unlikely; use a random suffix to break out
|
||||||
|
username = `${localPart}_${randomBytes(4).toString('hex')}@${domain}`.toLowerCase();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = generateSnowflake();
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Seed status from the wire snapshot when available — without this, a
|
||||||
|
// freshly-created stub for an already-online remote sticks at 'offline'
|
||||||
|
// until the home next emits a presence transition (presence_update only
|
||||||
|
// fires on changes, not on stub creation). Falls back to 'offline'.
|
||||||
|
const initialStatus = hints?.status ?? 'offline';
|
||||||
|
|
||||||
|
db.insert(schema.users).values({
|
||||||
|
id: userId,
|
||||||
|
username,
|
||||||
|
displayName: null,
|
||||||
|
passwordHash: '!federation-replicated', // Cannot be used to log in (bcrypt never produces this)
|
||||||
|
status: initialStatus,
|
||||||
|
isAdmin: 0,
|
||||||
|
homeInstance: domain, // Normalized to bare domain
|
||||||
|
homeUserId,
|
||||||
|
createdAt: now,
|
||||||
|
}).run();
|
||||||
|
|
||||||
|
const created = db.select().from(schema.users).where(eq(schema.users.id, userId)).get();
|
||||||
|
if (!created) {
|
||||||
|
throw new Error(`Failed to create replicated user for homeUserId=${homeUserId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[federation] Auto-created replicated user ${userId} (${username}) for homeUserId=${homeUserId} from ${domain}`);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { schema } from '../../db/index.js';
|
||||||
|
import { getOurOrigin } from '../../utils/federationAuth.js';
|
||||||
|
import { or } from 'drizzle-orm';
|
||||||
|
|
||||||
|
/** Fields safe to expose to admin callers (everything except hmacSecret). */
|
||||||
|
export interface SanitizedPeer {
|
||||||
|
id: string;
|
||||||
|
origin: string;
|
||||||
|
instanceName: string | null;
|
||||||
|
status: string;
|
||||||
|
lastSeenAt: number | null;
|
||||||
|
lastFailureAt: number | null;
|
||||||
|
consecutiveFailures: number;
|
||||||
|
lastSyncedAt: number | null;
|
||||||
|
createdAt: number;
|
||||||
|
rotationInProgress: boolean;
|
||||||
|
secretRotatedAt: number | null;
|
||||||
|
autoRotateIntervalDays: number;
|
||||||
|
needsAttentionReason: 'auth_failures' | 'peer_reset_detected' | 'repeer_incomplete' | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function sanitizePeer(row: typeof schema.federationPeers.$inferSelect): SanitizedPeer {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
origin: row.origin,
|
||||||
|
instanceName: row.instanceName,
|
||||||
|
status: row.status,
|
||||||
|
lastSeenAt: row.lastSeenAt,
|
||||||
|
lastFailureAt: row.lastFailureAt,
|
||||||
|
consecutiveFailures: row.consecutiveFailures,
|
||||||
|
lastSyncedAt: row.lastSyncedAt,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
rotationInProgress: row.pendingHmacSecret !== null,
|
||||||
|
secretRotatedAt: row.secretRotatedAt,
|
||||||
|
autoRotateIntervalDays: row.autoRotateIntervalDays,
|
||||||
|
needsAttentionReason: row.needsAttentionReason as SanitizedPeer['needsAttentionReason'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine this instance's public origin for the peering handshake.
|
||||||
|
*
|
||||||
|
* Delegates to `getOurOrigin()` so the handshake `sourceOrigin` is IDENTICAL to
|
||||||
|
* the `X-Federation-Origin` value used for authenticated S2S requests. This
|
||||||
|
* honors `PUBLIC_ORIGIN` (getOurOrigin's precedence: PUBLIC_ORIGIN →
|
||||||
|
* `https://${DOMAIN}` → `http://localhost:${PORT}`). Using DOMAIN directly here
|
||||||
|
* previously desynced the responder's peer-row key from the auth origin,
|
||||||
|
* causing permanent `403 Not peered` whenever PUBLIC_ORIGIN != https://DOMAIN.
|
||||||
|
*/
|
||||||
|
export function resolveLocalOrigin(): string {
|
||||||
|
return getOurOrigin();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that a string is a well-formed HTTP(S) URL origin.
|
||||||
|
* Returns the normalized origin (no trailing slash) or null if invalid.
|
||||||
|
*/
|
||||||
|
export function validateOrigin(raw: string): string | null {
|
||||||
|
try {
|
||||||
|
const url = new URL(raw);
|
||||||
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
|
||||||
|
if (url.protocol === 'http:' && !['localhost', '127.0.0.1'].includes(url.hostname)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return url.origin;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { config } from '../../config.js';
|
||||||
|
import { getDb, schema } from '../../db/index.js';
|
||||||
|
import { deleteUploadFile } from '../../utils/fileCleanup.js';
|
||||||
|
import { sanitizeUser } from '../../utils/sanitize.js';
|
||||||
|
import { generateSnowflake } from '../../utils/snowflake.js';
|
||||||
|
import { collectProfileBroadcastTargetIds } from '../../utils/userDeletion.js';
|
||||||
|
import { connectionManager } from '../../ws/handler.js';
|
||||||
|
import { and, eq, or, sql } from 'drizzle-orm';
|
||||||
|
import { Readable } from 'node:stream';
|
||||||
|
import { pipeline } from 'node:stream/promises';
|
||||||
|
import type { FederationRelayEvent, FederationRelayProfileSnapshot } from '@backspace/shared';
|
||||||
|
import { extractDomain } from './identity.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hydrate a replicated user stub with profile data from a relay event.
|
||||||
|
* Only updates fields that are currently null/empty on the local row,
|
||||||
|
* so manually-set local values are preserved.
|
||||||
|
*/
|
||||||
|
export async function hydrateReplicatedUserProfile(
|
||||||
|
user: typeof schema.users.$inferSelect,
|
||||||
|
profile: FederationRelayProfileSnapshot | undefined,
|
||||||
|
db: ReturnType<typeof getDb>,
|
||||||
|
): Promise<typeof schema.users.$inferSelect> {
|
||||||
|
if (!profile) return user;
|
||||||
|
if (!user.homeInstance) return user; // Don't update native users
|
||||||
|
// Detached accounts are sovereign local accounts: the home domain now belongs
|
||||||
|
// to a different incarnation, so a relayed snapshot resolved via an old
|
||||||
|
// homeUserId (tier-1 historical hit) must never fill this row's fields. No-op
|
||||||
|
// return, mirroring the profile_update / presence_update / identity-delete
|
||||||
|
// guards (detach spec §4.3).
|
||||||
|
if (user.federationHomeOrphaned === 1) return user;
|
||||||
|
|
||||||
|
const baseUrl = user.homeInstance.startsWith('http') ? user.homeInstance : `https://${user.homeInstance}`;
|
||||||
|
const buildAbsoluteUrl = (value: string): string => {
|
||||||
|
if (value.startsWith('http')) return value;
|
||||||
|
const path = value.startsWith('/') ? value : `/api/uploads/${value}`;
|
||||||
|
return `${baseUrl}${path}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Resolve a snapshot asset to a local filename (preferred) or, on download
|
||||||
|
// failure, fall back to the absolute URL so the avatar still renders while
|
||||||
|
// the home instance is reachable.
|
||||||
|
const resolveAsset = async (snapshot: string): Promise<string> => {
|
||||||
|
const absoluteUrl = buildAbsoluteUrl(snapshot);
|
||||||
|
const localFile = await downloadProfileAsset(absoluteUrl, baseUrl);
|
||||||
|
return localFile ?? absoluteUrl;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updates: Record<string, string | null> = {};
|
||||||
|
// Use displayName from profile, falling back to the home username (without
|
||||||
|
// the @domain suffix that the local replicated username carries). This
|
||||||
|
// ensures federated users show a human-readable name instead of the raw
|
||||||
|
// "user@instance.example" federation username.
|
||||||
|
const effectiveDisplayName = profile.displayName || profile.username || null;
|
||||||
|
if (effectiveDisplayName && !user.displayName) updates.displayName = effectiveDisplayName;
|
||||||
|
// Hydrate is best-effort: only fill empty fields. Never overwrite existing
|
||||||
|
// avatar/banner values — that is exclusively processProfileUpdateEvent's job
|
||||||
|
// (which carries a monotonic version). In particular, locally-downloaded
|
||||||
|
// bare filenames produced by that path must not be clobbered back to URLs.
|
||||||
|
if (profile.avatar && !user.avatar) updates.avatar = await resolveAsset(profile.avatar);
|
||||||
|
if (profile.avatarColor) updates.avatarColor = profile.avatarColor;
|
||||||
|
if (profile.banner && !user.banner) updates.banner = await resolveAsset(profile.banner);
|
||||||
|
if (profile.bio && !user.bio) updates.bio = profile.bio;
|
||||||
|
|
||||||
|
if (Object.keys(updates).length === 0) return user;
|
||||||
|
|
||||||
|
db.update(schema.users)
|
||||||
|
.set(updates)
|
||||||
|
.where(eq(schema.users.id, user.id))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
return { ...user, ...updates };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download a profile image (avatar or banner) from a remote instance.
|
||||||
|
* Returns the local filename on success, or null on failure.
|
||||||
|
* On failure, the caller stores the absolute URL as a display fallback.
|
||||||
|
*/
|
||||||
|
export async function downloadProfileAsset(
|
||||||
|
url: string,
|
||||||
|
sourceInstance: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
// SSRF: hostname must match the authenticated source instance
|
||||||
|
try {
|
||||||
|
const urlHostname = new URL(url).hostname;
|
||||||
|
const sourceHostname = new URL(sourceInstance).hostname;
|
||||||
|
if (urlHostname !== sourceHostname) {
|
||||||
|
console.warn(`[federation] Profile asset SSRF blocked: URL hostname "${urlHostname}" != source "${sourceHostname}"`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = path.extname(new URL(url).pathname) || '.webp';
|
||||||
|
const localId = generateSnowflake();
|
||||||
|
const finalFilename = `${localId}${ext}`;
|
||||||
|
const tempFilename = `temp_${localId}${ext}`;
|
||||||
|
const tempPath = path.join(config.uploadDir, tempFilename);
|
||||||
|
const finalPath = path.join(config.uploadDir, finalFilename);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok || !response.body) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content-type must be an image
|
||||||
|
const contentType = response.headers.get('content-type') ?? '';
|
||||||
|
if (!contentType.startsWith('image/')) {
|
||||||
|
console.warn(`[federation] Profile asset rejected: non-image content-type "${contentType}" from ${url}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure upload directory exists
|
||||||
|
fs.mkdirSync(config.uploadDir, { recursive: true });
|
||||||
|
|
||||||
|
// Stream to temp file
|
||||||
|
const nodeStream = Readable.fromWeb(response.body as ReadableStream);
|
||||||
|
const writeStream = fs.createWriteStream(tempPath);
|
||||||
|
await pipeline(nodeStream, writeStream);
|
||||||
|
|
||||||
|
// Atomic rename
|
||||||
|
fs.renameSync(tempPath, finalPath);
|
||||||
|
|
||||||
|
return finalFilename;
|
||||||
|
} catch (err) {
|
||||||
|
// Clean up temp file on any failure
|
||||||
|
try { fs.unlinkSync(tempPath); } catch { /* may not exist */ }
|
||||||
|
console.warn(`[federation] Profile asset download failed for ${url}:`, (err as Error).message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export async function processProfileUpdateEvent(
|
||||||
|
event: FederationRelayEvent,
|
||||||
|
sourceInstance: string,
|
||||||
|
db: ReturnType<typeof getDb>,
|
||||||
|
accepted: string[],
|
||||||
|
rejected: Array<{ messageId: string; reason: string }>,
|
||||||
|
): Promise<void> {
|
||||||
|
const payload = event.profileUpdate;
|
||||||
|
if (!payload) {
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'missing_profile_update_payload' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strict attribution: profile updates MUST originate from the home instance.
|
||||||
|
// No homeward relay exception — unlike DMs, profile updates always come from home.
|
||||||
|
const payloadDomain = extractDomain(payload.homeInstance);
|
||||||
|
const sourceDomain = extractDomain(sourceInstance);
|
||||||
|
if (payloadDomain !== sourceDomain) {
|
||||||
|
console.warn(`[federation] Attribution mismatch in profile_update: homeInstance=${payloadDomain} source=${sourceDomain}`);
|
||||||
|
rejected.push({ messageId: event.messageId, reason: 'attribution_mismatch' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up the local replicated user by canonical identity
|
||||||
|
const localUser = db
|
||||||
|
.select()
|
||||||
|
.from(schema.users)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.users.homeUserId, payload.homeUserId),
|
||||||
|
eq(schema.users.isDeleted, 0),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!localUser) {
|
||||||
|
// This peer has no replica of this user — silently accept
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the homeInstance domain matches (guard against homeUserId collisions)
|
||||||
|
if (localUser.homeInstance && extractDomain(localUser.homeInstance) !== payloadDomain) {
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detached accounts are sovereign: the domain now belongs to a different
|
||||||
|
// incarnation, which must never overwrite the established account's profile
|
||||||
|
// by replaying its old homeUserId. Ack (not reject) — the sender considers
|
||||||
|
// this identity theirs to update; from our side the update simply no-ops.
|
||||||
|
if (localUser.federationHomeOrphaned === 1) {
|
||||||
|
console.log(`[federation] Skipping profile_update for detached account ${localUser.id} (home-orphaned)`);
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Version check: reject stale/duplicate events
|
||||||
|
const storedTs = localUser.profileUpdatedAt ?? 0;
|
||||||
|
const incomingTs = payload.profileUpdatedAt ?? 0;
|
||||||
|
if (incomingTs <= storedTs) {
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Resolve avatar/banner: download locally, fall back to absolute URL ──
|
||||||
|
let resolvedAvatar: string | null = payload.avatar ?? null;
|
||||||
|
let resolvedBanner: string | null = payload.banner ?? null;
|
||||||
|
|
||||||
|
// Download avatar
|
||||||
|
if (resolvedAvatar && resolvedAvatar.startsWith('http')) {
|
||||||
|
const localFile = await downloadProfileAsset(resolvedAvatar, sourceInstance);
|
||||||
|
resolvedAvatar = localFile ?? resolvedAvatar; // local filename or absolute URL fallback
|
||||||
|
} else if (resolvedAvatar && !resolvedAvatar.startsWith('http')) {
|
||||||
|
// Bare filename (shouldn't happen) — resolve to absolute URL
|
||||||
|
const baseUrl = sourceInstance.startsWith('http') ? sourceInstance : `https://${sourceInstance}`;
|
||||||
|
const absoluteUrl = `${baseUrl}/api/uploads/${resolvedAvatar}`;
|
||||||
|
const localFile = await downloadProfileAsset(absoluteUrl, sourceInstance);
|
||||||
|
resolvedAvatar = localFile ?? absoluteUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download banner
|
||||||
|
if (resolvedBanner && resolvedBanner.startsWith('http')) {
|
||||||
|
const localFile = await downloadProfileAsset(resolvedBanner, sourceInstance);
|
||||||
|
resolvedBanner = localFile ?? resolvedBanner;
|
||||||
|
} else if (resolvedBanner && !resolvedBanner.startsWith('http')) {
|
||||||
|
const baseUrl = sourceInstance.startsWith('http') ? sourceInstance : `https://${sourceInstance}`;
|
||||||
|
const absoluteUrl = `${baseUrl}/api/uploads/${resolvedBanner}`;
|
||||||
|
const localFile = await downloadProfileAsset(absoluteUrl, sourceInstance);
|
||||||
|
resolvedBanner = localFile ?? absoluteUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up old local files being replaced
|
||||||
|
const oldAvatar = localUser.avatar;
|
||||||
|
const oldBanner = localUser.banner;
|
||||||
|
if (oldAvatar && !oldAvatar.startsWith('http') && oldAvatar !== resolvedAvatar) {
|
||||||
|
deleteUploadFile(oldAvatar);
|
||||||
|
}
|
||||||
|
if (oldBanner && !oldBanner.startsWith('http') && oldBanner !== resolvedBanner) {
|
||||||
|
deleteUploadFile(oldBanner);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authoritative overwrite — home instance is always right.
|
||||||
|
// displayName falls back to the home user's canonical username when null,
|
||||||
|
// mirroring hydrateReplicatedUserProfile so stubs whose home user has no
|
||||||
|
// displayName show the real handle instead of getting clobbered to null.
|
||||||
|
// (The username field on the wire is the home's canonical handle, not the
|
||||||
|
// stub's local-part; usernames are immutable on the home instance, so we
|
||||||
|
// never rewrite the stub's username column here.)
|
||||||
|
const effectiveDisplayName = payload.displayName ?? payload.username ?? null;
|
||||||
|
db.update(schema.users)
|
||||||
|
.set({
|
||||||
|
displayName: effectiveDisplayName,
|
||||||
|
avatar: resolvedAvatar,
|
||||||
|
banner: resolvedBanner,
|
||||||
|
accentColor: payload.accentColor,
|
||||||
|
avatarColor: payload.avatarColor,
|
||||||
|
bio: payload.bio,
|
||||||
|
profileUpdatedAt: payload.profileUpdatedAt,
|
||||||
|
})
|
||||||
|
.where(eq(schema.users.id, localUser.id))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// Broadcast user_updated to local clients
|
||||||
|
const updatedUser = db.select().from(schema.users).where(eq(schema.users.id, localUser.id)).get();
|
||||||
|
if (updatedUser) {
|
||||||
|
const sanitized = sanitizeUser(updatedUser, false);
|
||||||
|
const targetUserIds = collectProfileBroadcastTargetIds(localUser.id);
|
||||||
|
targetUserIds.add(localUser.id); // Include self (other tabs/connections)
|
||||||
|
const userUpdatedEvent = { type: 'user_updated' as const, user: sanitized };
|
||||||
|
for (const uid of targetUserIds) {
|
||||||
|
connectionManager.sendToUser(uid, userUpdatedEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
accepted.push(event.messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-time / idempotent pass that converts existing absolute-URL avatars and
|
||||||
|
* banners on replicated users into local files via downloadProfileAsset.
|
||||||
|
*
|
||||||
|
* Why: hydrateReplicatedUserProfile historically wrote home-instance URLs into
|
||||||
|
* users.avatar / users.banner. When the home instance is offline those URLs
|
||||||
|
* 404, leaving sidebars (server activity, friend activity, DM list) showing
|
||||||
|
* letter fallbacks. processProfileUpdateEvent only re-downloads on the next
|
||||||
|
* profile edit, which most users don't do — so this worker cleans up the
|
||||||
|
* accumulated URL rows.
|
||||||
|
*
|
||||||
|
* Behavior: best-effort. Rows where the home instance can't be reached are
|
||||||
|
* left as URLs (they still render while the peer is up), and the worker is
|
||||||
|
* safe to re-run on every startup.
|
||||||
|
*/
|
||||||
|
export async function backfillReplicatedProfileAssets(): Promise<void> {
|
||||||
|
const db = getDb();
|
||||||
|
const rows = db
|
||||||
|
.select({
|
||||||
|
id: schema.users.id,
|
||||||
|
homeInstance: schema.users.homeInstance,
|
||||||
|
avatar: schema.users.avatar,
|
||||||
|
banner: schema.users.banner,
|
||||||
|
})
|
||||||
|
.from(schema.users)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
sql`${schema.users.homeInstance} IS NOT NULL`,
|
||||||
|
eq(schema.users.isDeleted, 0),
|
||||||
|
or(
|
||||||
|
sql`${schema.users.avatar} LIKE 'http%'`,
|
||||||
|
sql`${schema.users.banner} LIKE 'http%'`,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
if (rows.length === 0) return;
|
||||||
|
|
||||||
|
let avatarOk = 0;
|
||||||
|
let bannerOk = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!row.homeInstance) continue;
|
||||||
|
const baseUrl = row.homeInstance.startsWith('http')
|
||||||
|
? row.homeInstance
|
||||||
|
: `https://${row.homeInstance}`;
|
||||||
|
|
||||||
|
const updates: Record<string, string | null> = {};
|
||||||
|
|
||||||
|
if (row.avatar && row.avatar.startsWith('http')) {
|
||||||
|
const localFile = await downloadProfileAsset(row.avatar, baseUrl);
|
||||||
|
if (localFile) {
|
||||||
|
updates.avatar = localFile;
|
||||||
|
avatarOk++;
|
||||||
|
} else {
|
||||||
|
skipped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.banner && row.banner.startsWith('http')) {
|
||||||
|
const localFile = await downloadProfileAsset(row.banner, baseUrl);
|
||||||
|
if (localFile) {
|
||||||
|
updates.banner = localFile;
|
||||||
|
bannerOk++;
|
||||||
|
} else {
|
||||||
|
skipped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(updates).length > 0) {
|
||||||
|
db.update(schema.users)
|
||||||
|
.set(updates)
|
||||||
|
.where(eq(schema.users.id, row.id))
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[federation] Replicated profile asset backfill: ${avatarOk} avatars, ${bannerOk} banners downloaded; ${skipped} unreachable (will retry next start)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
// ─── In-memory sliding-window rate limiters (federation S2S endpoints) ───────
|
||||||
|
//
|
||||||
|
// Each limiter keeps a per-key ring of request timestamps inside a fixed window.
|
||||||
|
// `limited(key)` prunes that key's expired entries, then returns true (without
|
||||||
|
// recording a hit) once the key is at capacity. `sweep()` prunes every key and
|
||||||
|
// drops emptied buckets to bound memory; it runs on a timer, not per request.
|
||||||
|
|
||||||
|
interface SlidingWindowLimiter {
|
||||||
|
/** True if `key` is already at capacity for the current window; otherwise records the hit and returns false. */
|
||||||
|
limited(key: string): boolean;
|
||||||
|
/** Prune expired timestamps across all keys and drop now-empty buckets. */
|
||||||
|
sweep(): void;
|
||||||
|
/** Underlying buckets — exposed only so tests can reset state. */
|
||||||
|
readonly buckets: Map<string, number[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLimiter(windowMs: number, max: number): SlidingWindowLimiter {
|
||||||
|
const buckets = new Map<string, number[]>();
|
||||||
|
const prune = (timestamps: number[], cutoff: number): void => {
|
||||||
|
while (timestamps.length > 0 && (timestamps[0] ?? Infinity) < cutoff) {
|
||||||
|
timestamps.shift();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
buckets,
|
||||||
|
limited(key: string): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
let timestamps = buckets.get(key);
|
||||||
|
if (!timestamps) {
|
||||||
|
timestamps = [];
|
||||||
|
buckets.set(key, timestamps);
|
||||||
|
}
|
||||||
|
prune(timestamps, now - windowMs);
|
||||||
|
if (timestamps.length >= max) return true;
|
||||||
|
timestamps.push(now);
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
sweep(): void {
|
||||||
|
const cutoff = Date.now() - windowMs;
|
||||||
|
for (const [key, timestamps] of buckets) {
|
||||||
|
prune(timestamps, cutoff);
|
||||||
|
if (timestamps.length === 0) buckets.delete(key);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const RATE_WINDOW_MS = 60_000;
|
||||||
|
const ENSURE_WINDOW_MS = 15 * 60_000;
|
||||||
|
|
||||||
|
// accept: per source IP · relay & user-lookup: per peer origin · ensure: per user
|
||||||
|
const acceptLimiter = createLimiter(RATE_WINDOW_MS, 10);
|
||||||
|
const relayLimiter = createLimiter(RATE_WINDOW_MS, 90);
|
||||||
|
const lookupLimiter = createLimiter(RATE_WINDOW_MS, 60);
|
||||||
|
const ensureLimiter = createLimiter(ENSURE_WINDOW_MS, 3);
|
||||||
|
|
||||||
|
export const isAcceptRateLimited = (ip: string): boolean => acceptLimiter.limited(ip);
|
||||||
|
export const isRelayRateLimited = (peerOrigin: string): boolean => relayLimiter.limited(peerOrigin);
|
||||||
|
export const isLookupRateLimited = (peerOrigin: string): boolean => lookupLimiter.limited(peerOrigin);
|
||||||
|
export const isEnsureRateLimited = (userId: string): boolean => ensureLimiter.limited(userId);
|
||||||
|
|
||||||
|
// Test-only export — used by federation.userLookup.test.ts to reset between cases.
|
||||||
|
export function _resetLookupRateBuckets(): void {
|
||||||
|
lookupLimiter.buckets.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Nonce store for replay protection (per-peer) ────────────────────────────
|
||||||
|
// Maps peerOrigin → (nonce → insertion timestamp). Nonces are evicted after
|
||||||
|
// NONCE_MAX_AGE_MS (15 min) to match the HMAC timestamp window.
|
||||||
|
const NONCE_MAX_AGE_MS = 15 * 60 * 1000;
|
||||||
|
const nonceStore = new Map<string, Map<string, number>>();
|
||||||
|
|
||||||
|
/** Returns true if the nonce is a duplicate (already seen for this peer). */
|
||||||
|
export function isNonceDuplicate(peerOrigin: string, nonce: string): boolean {
|
||||||
|
let peerNonces = nonceStore.get(peerOrigin);
|
||||||
|
if (!peerNonces) {
|
||||||
|
peerNonces = new Map();
|
||||||
|
nonceStore.set(peerOrigin, peerNonces);
|
||||||
|
}
|
||||||
|
if (peerNonces.has(nonce)) return true;
|
||||||
|
peerNonces.set(nonce, Date.now());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Periodic cleanup to bound memory ────────────────────────────────────────
|
||||||
|
// Ensure buckets sweep on their own (long) window. Accept + relay buckets and
|
||||||
|
// nonce eviction share the short window. Lookup buckets are pruned per-call only
|
||||||
|
// (never swept here) — preserving the original behavior.
|
||||||
|
setInterval(() => ensureLimiter.sweep(), ENSURE_WINDOW_MS).unref();
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
acceptLimiter.sweep();
|
||||||
|
relayLimiter.sweep();
|
||||||
|
const nonceCutoff = Date.now() - NONCE_MAX_AGE_MS;
|
||||||
|
for (const [origin, nonces] of nonceStore) {
|
||||||
|
for (const [nonce, ts] of nonces) {
|
||||||
|
if (ts < nonceCutoff) nonces.delete(nonce);
|
||||||
|
}
|
||||||
|
if (nonces.size === 0) nonceStore.delete(origin);
|
||||||
|
}
|
||||||
|
}, RATE_WINDOW_MS).unref();
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import { getRawDb } from '../../db/index.js';
|
||||||
|
import { computeFederatedId } from '../../utils/federationOutbox.js';
|
||||||
|
import { and, or } from 'drizzle-orm';
|
||||||
|
import { getOurIdentityDomain } from './identity.js';
|
||||||
|
|
||||||
|
export interface DmReconcileResult {
|
||||||
|
action: 'noop' | 'rekeyed' | 'merged';
|
||||||
|
channelId: string;
|
||||||
|
targetChannelId: string;
|
||||||
|
affectedUserIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconcile a single 1-on-1 DM channel's deterministic federatedId against its
|
||||||
|
* members' CURRENT home identities (reattach-dm-reconcile spec §3.1). A 1-on-1
|
||||||
|
* federatedId is f(sorted home user ids); when a participant's home_user_id
|
||||||
|
* changes (re-attach), the channel's stored id goes stale and new messages
|
||||||
|
* compute a different id → a split conversation. This re-keys the channel in
|
||||||
|
* place, or — when a channel already carries the correct id (idx_dm_federated is
|
||||||
|
* UNIQUE, so two rows can't share it) — merges this channel INTO that one and
|
||||||
|
* deletes it.
|
||||||
|
*
|
||||||
|
* Idempotent: a correctly-keyed channel is a noop. Group DMs (UUID federatedId
|
||||||
|
* or member count != 2) are skipped. Must be called inside a transaction.
|
||||||
|
*/
|
||||||
|
export function reconcileDmChannelFederatedId(
|
||||||
|
rawDb: ReturnType<typeof getRawDb>,
|
||||||
|
channelId: string,
|
||||||
|
): DmReconcileResult {
|
||||||
|
const noop: DmReconcileResult = { action: 'noop', channelId, targetChannelId: channelId, affectedUserIds: [] };
|
||||||
|
|
||||||
|
const chan = rawDb.prepare(`SELECT id, federated_id FROM dm_channels WHERE id = ? AND deleted_at IS NULL`).get(channelId) as
|
||||||
|
{ id: string; federated_id: string | null } | undefined;
|
||||||
|
if (!chan || !chan.federated_id) return noop;
|
||||||
|
// Only 1-on-1 shape (32 hex). Group DMs use a random UUID.
|
||||||
|
if (!/^[0-9a-f]{32}$/.test(chan.federated_id)) return noop;
|
||||||
|
|
||||||
|
const members = rawDb.prepare(`
|
||||||
|
SELECT u.id, u.home_user_id FROM dm_members m JOIN users u ON u.id = m.user_id
|
||||||
|
WHERE m.dm_channel_id = ?
|
||||||
|
`).all(channelId) as Array<{ id: string; home_user_id: string | null }>;
|
||||||
|
if (members.length !== 2) return noop;
|
||||||
|
|
||||||
|
const homeA = members[0]!.home_user_id || members[0]!.id;
|
||||||
|
const homeB = members[1]!.home_user_id || members[1]!.id;
|
||||||
|
const expected = computeFederatedId(homeA, homeB);
|
||||||
|
if (expected === chan.federated_id) return noop;
|
||||||
|
|
||||||
|
const target = rawDb.prepare(`SELECT id FROM dm_channels WHERE federated_id = ? AND deleted_at IS NULL AND id != ?`).get(expected, channelId) as
|
||||||
|
{ id: string } | undefined;
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
rawDb.prepare(`UPDATE dm_channels SET federated_id = ? WHERE id = ?`).run(expected, channelId);
|
||||||
|
return { action: 'rekeyed', channelId, targetChannelId: channelId, affectedUserIds: members.map(m => m.id) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge source (channelId) INTO target, then delete source.
|
||||||
|
const targetId = target.id;
|
||||||
|
const targetMemberIds = (rawDb.prepare(`SELECT user_id FROM dm_members WHERE dm_channel_id = ?`).all(targetId) as Array<{ user_id: string }>).map(r => r.user_id);
|
||||||
|
const affected = Array.from(new Set([...members.map(m => m.id), ...targetMemberIds]));
|
||||||
|
|
||||||
|
// Messages: globally-unique ids, straight move (attachments + dm_reactions
|
||||||
|
// reference dm_message_id and follow automatically).
|
||||||
|
rawDb.prepare(`UPDATE dm_messages SET dm_channel_id = ? WHERE dm_channel_id = ?`).run(targetId, channelId);
|
||||||
|
// Members: drop source rows already present on target (composite PK), repoint the rest.
|
||||||
|
rawDb.prepare(`DELETE FROM dm_members WHERE dm_channel_id = ? AND user_id IN (SELECT user_id FROM dm_members WHERE dm_channel_id = ?)`).run(channelId, targetId);
|
||||||
|
rawDb.prepare(`UPDATE dm_members SET dm_channel_id = ? WHERE dm_channel_id = ?`).run(targetId, channelId);
|
||||||
|
// read_states: keyed by channel_id; dedupe on (user_id, channel_id) then repoint.
|
||||||
|
rawDb.prepare(`DELETE FROM read_states WHERE channel_id = ? AND user_id IN (SELECT user_id FROM read_states WHERE channel_id = ?)`).run(channelId, targetId);
|
||||||
|
rawDb.prepare(`UPDATE read_states SET channel_id = ? WHERE channel_id = ?`).run(targetId, channelId);
|
||||||
|
// Remove the now-empty source channel.
|
||||||
|
rawDb.prepare(`DELETE FROM dm_channels WHERE id = ?`).run(channelId);
|
||||||
|
|
||||||
|
return { action: 'merged', channelId, targetChannelId: targetId, affectedUserIds: affected };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Startup sweep: reconcile any 1-on-1 DM channel whose stored federatedId has
|
||||||
|
* drifted from its members' current home identities (reattach-dm-reconcile
|
||||||
|
* spec §3.3). Heals accounts re-attached before inline reconciliation shipped
|
||||||
|
* (e.g. the live split-conversation duplicate). Idempotent; a noop on a clean DB.
|
||||||
|
*/
|
||||||
|
export function reconcileDriftedDmFederatedIds(): void {
|
||||||
|
const rawDb = getRawDb();
|
||||||
|
const candidates = rawDb.prepare(`
|
||||||
|
SELECT c.id FROM dm_channels c
|
||||||
|
WHERE c.deleted_at IS NULL
|
||||||
|
AND c.federated_id IS NOT NULL
|
||||||
|
AND (SELECT count(*) FROM dm_members m WHERE m.dm_channel_id = c.id) = 2
|
||||||
|
`).all() as Array<{ id: string }>;
|
||||||
|
if (candidates.length === 0) return;
|
||||||
|
|
||||||
|
let rekeyed = 0;
|
||||||
|
let merged = 0;
|
||||||
|
rawDb.transaction(() => {
|
||||||
|
for (const c of candidates) {
|
||||||
|
// A prior merge in this loop may have deleted this id — reconcile returns
|
||||||
|
// noop for a missing/mutated channel, so this is safe.
|
||||||
|
const r = reconcileDmChannelFederatedId(rawDb, c.id);
|
||||||
|
if (r.action === 'rekeyed') rekeyed++;
|
||||||
|
else if (r.action === 'merged') merged++;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (rekeyed > 0 || merged > 0) {
|
||||||
|
console.log(`[federation] DM federatedId reconciliation: rekeyed ${rekeyed}, merged ${merged}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove dead-incarnation artifacts produced by pre-fix initial syncs
|
||||||
|
* (dead-incarnation spec §3.4): DM channels with no native member, and
|
||||||
|
* replicated stubs homed at this instance's own domain. Idempotent —
|
||||||
|
* a no-op on a clean database. Synchronous (better-sqlite3), runs once
|
||||||
|
* at startup from startFederationWorkers.
|
||||||
|
*
|
||||||
|
* Child rows are deleted explicitly: FK cascade enforcement cannot be
|
||||||
|
* assumed ON, and dm_messages.user_id has no cascade anyway.
|
||||||
|
*/
|
||||||
|
export function sweepDeadIncarnationArtifacts(): void {
|
||||||
|
const ourDomain = getOurIdentityDomain();
|
||||||
|
if (!ourDomain) return;
|
||||||
|
const rawDb = getRawDb();
|
||||||
|
const normHome = `lower(replace(replace(coalesce(home_instance, ''), 'https://', ''), 'http://', ''))`;
|
||||||
|
|
||||||
|
// ── 1. DM channels with no native member. A legitimate channel always
|
||||||
|
// involves a native user; native-less channels are sync junk. ──
|
||||||
|
const junkChannelIds = (rawDb.prepare(`
|
||||||
|
SELECT c.id FROM dm_channels c
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM dm_members m JOIN users u ON u.id = m.user_id
|
||||||
|
WHERE m.dm_channel_id = c.id AND u.home_instance IS NULL
|
||||||
|
)
|
||||||
|
`).all() as Array<{ id: string }>).map(r => r.id);
|
||||||
|
|
||||||
|
if (junkChannelIds.length > 0) {
|
||||||
|
const ph = junkChannelIds.map(() => '?').join(',');
|
||||||
|
rawDb.transaction(() => {
|
||||||
|
rawDb.prepare(`DELETE FROM dm_reactions WHERE dm_message_id IN (SELECT id FROM dm_messages WHERE dm_channel_id IN (${ph}))`).run(...junkChannelIds);
|
||||||
|
rawDb.prepare(`DELETE FROM attachments WHERE dm_message_id IN (SELECT id FROM dm_messages WHERE dm_channel_id IN (${ph}))`).run(...junkChannelIds);
|
||||||
|
rawDb.prepare(`DELETE FROM dm_messages WHERE dm_channel_id IN (${ph})`).run(...junkChannelIds);
|
||||||
|
rawDb.prepare(`DELETE FROM dm_members WHERE dm_channel_id IN (${ph})`).run(...junkChannelIds);
|
||||||
|
rawDb.prepare(`DELETE FROM read_states WHERE channel_id IN (${ph})`).run(...junkChannelIds);
|
||||||
|
rawDb.prepare(`DELETE FROM dm_channels WHERE id IN (${ph})`).run(...junkChannelIds);
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. Self-homed replicated stubs. Junk social rows referencing them go
|
||||||
|
// first; then stubs with no remaining non-cascading references. ──
|
||||||
|
const stubSelect = `SELECT id FROM users WHERE password_hash = '!federation-replicated' AND ${normHome} = ?`;
|
||||||
|
const allStubIds = (rawDb.prepare(stubSelect).all(ourDomain) as Array<{ id: string }>).map(r => r.id);
|
||||||
|
|
||||||
|
let deletedStubs = 0;
|
||||||
|
if (allStubIds.length > 0) {
|
||||||
|
rawDb.transaction(() => {
|
||||||
|
rawDb.prepare(`DELETE FROM friends WHERE user_id IN (${stubSelect}) OR friend_id IN (${stubSelect})`).run(ourDomain, ourDomain);
|
||||||
|
rawDb.prepare(`DELETE FROM friend_requests WHERE from_id IN (${stubSelect}) OR to_id IN (${stubSelect})`).run(ourDomain, ourDomain);
|
||||||
|
|
||||||
|
// Deletable = no rows left in any table whose FK to users.id does NOT
|
||||||
|
// cascade, and no surviving dm/space membership or authored message.
|
||||||
|
const deletable = (rawDb.prepare(`
|
||||||
|
${stubSelect}
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM dm_messages WHERE user_id = users.id)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM messages WHERE user_id = users.id)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM dm_members WHERE user_id = users.id)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM space_members WHERE user_id = users.id)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM spaces WHERE owner_id = users.id)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM bans WHERE banned_by = users.id)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM join_requests WHERE decided_by = users.id)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM voice_restrictions WHERE moderator_id = users.id)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM invite_links WHERE created_by = users.id)
|
||||||
|
`).all(ourDomain) as Array<{ id: string }>).map(r => r.id);
|
||||||
|
|
||||||
|
if (deletable.length > 0) {
|
||||||
|
const dph = deletable.map(() => '?').join(',');
|
||||||
|
// Explicit child cleanup for the cascade-declared tables too — FK
|
||||||
|
// enforcement cannot be assumed ON.
|
||||||
|
rawDb.prepare(`DELETE FROM dm_reactions WHERE user_id IN (${dph})`).run(...deletable);
|
||||||
|
rawDb.prepare(`DELETE FROM reactions WHERE user_id IN (${dph})`).run(...deletable);
|
||||||
|
rawDb.prepare(`DELETE FROM read_states WHERE user_id IN (${dph})`).run(...deletable);
|
||||||
|
rawDb.prepare(`DELETE FROM users WHERE id IN (${dph})`).run(...deletable);
|
||||||
|
deletedStubs = deletable.length;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
const skipped = allStubIds.length - deletedStubs;
|
||||||
|
if (junkChannelIds.length > 0 || allStubIds.length > 0) {
|
||||||
|
console.log(`[federation] Dead-incarnation sweep: removed ${junkChannelIds.length} channels, ${deletedStubs} self-homed stubs${skipped > 0 ? `, skipped ${skipped} still-referenced stubs` : ''}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Replicated Profile Asset Backfill ──────────────────────────────────────
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import Fastify, { type FastifyInstance } from 'fastify';
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import * as schema from '../db/schema.js';
|
||||||
|
import { setWorkerId } from '../utils/snowflake.js';
|
||||||
|
|
||||||
|
setWorkerId(1);
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
type TestDb = ReturnType<typeof drizzle<typeof schema>>;
|
||||||
|
let sqlite: Database.Database;
|
||||||
|
let testDb: TestDb;
|
||||||
|
let currentUserId = 'joiner';
|
||||||
|
|
||||||
|
vi.mock('../db/index.js', () => ({
|
||||||
|
getDb: () => testDb,
|
||||||
|
getRawDb: () => sqlite,
|
||||||
|
schema,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../utils/auth.js', () => ({
|
||||||
|
authenticate: async (req: { userId?: string }) => {
|
||||||
|
req.userId = currentUserId;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../ws/handler.js', () => ({
|
||||||
|
connectionManager: {
|
||||||
|
addUserSpace: vi.fn(),
|
||||||
|
sendToSpace: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
function applyMigrations(db: Database.Database): void {
|
||||||
|
const migrationsDir = path.resolve(__dirname, '../../drizzle');
|
||||||
|
const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort();
|
||||||
|
for (const f of files) {
|
||||||
|
const sqlText = fs.readFileSync(path.join(migrationsDir, f), 'utf8');
|
||||||
|
for (const stmt of sqlText.split(/-->\s*statement-breakpoint/)) {
|
||||||
|
const clean = stmt.trim();
|
||||||
|
if (clean) db.exec(clean);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const OWNER_ID = 'owner';
|
||||||
|
const now = 1_700_000_000_000;
|
||||||
|
|
||||||
|
async function buildApp(): Promise<FastifyInstance> {
|
||||||
|
const { spaceRoutes } = await import('./spaces.js');
|
||||||
|
const f = Fastify();
|
||||||
|
await f.register(spaceRoutes);
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
let app: FastifyInstance;
|
||||||
|
|
||||||
|
function makeSpace(id: string, visibility: 'public' | 'request' | 'private', inviteCode: string): void {
|
||||||
|
testDb.insert(schema.spaces).values({
|
||||||
|
id,
|
||||||
|
name: `space-${visibility}`,
|
||||||
|
ownerId: OWNER_ID,
|
||||||
|
inviteCode,
|
||||||
|
visibility,
|
||||||
|
createdAt: now,
|
||||||
|
}).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
sqlite = new Database(':memory:');
|
||||||
|
sqlite.pragma('foreign_keys = ON');
|
||||||
|
applyMigrations(sqlite);
|
||||||
|
testDb = drizzle(sqlite, { schema });
|
||||||
|
currentUserId = 'joiner';
|
||||||
|
|
||||||
|
for (const id of [OWNER_ID, 'joiner']) {
|
||||||
|
testDb.insert(schema.users).values({
|
||||||
|
id, username: id, passwordHash: 'x', createdAt: now,
|
||||||
|
}).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
app = await buildApp();
|
||||||
|
});
|
||||||
|
|
||||||
|
function isMember(spaceId: string, userId: string): boolean {
|
||||||
|
return testDb.select().from(schema.spaceMembers).all()
|
||||||
|
.some(m => m.spaceId === spaceId && m.userId === userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('POST /api/spaces/:id/join — visibility guard', () => {
|
||||||
|
it('rejects an invite-code join for a request-only space (approval required)', async () => {
|
||||||
|
makeSpace('s-req', 'request', 'code-req');
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/spaces/s-req/join',
|
||||||
|
payload: { inviteCode: 'code-req' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
expect(isMember('s-req', 'joiner')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows an invite-code join for a private space (invite is the only entry path)', async () => {
|
||||||
|
makeSpace('s-priv', 'private', 'code-priv');
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/spaces/s-priv/join',
|
||||||
|
payload: { inviteCode: 'code-priv' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(isMember('s-priv', 'joiner')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows an invite-code join for a public space', async () => {
|
||||||
|
makeSpace('s-pub', 'public', 'code-pub');
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/spaces/s-pub/join',
|
||||||
|
payload: { inviteCode: 'code-pub' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(isMember('s-pub', 'joiner')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/spaces/join (codeless) — visibility guard', () => {
|
||||||
|
it('rejects an invite-code join for a request-only space', async () => {
|
||||||
|
makeSpace('s-req2', 'request', 'code-req2');
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/spaces/join',
|
||||||
|
payload: { inviteCode: 'code-req2' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
expect(isMember('s-req2', 'joiner')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows an invite-code join for a private space', async () => {
|
||||||
|
makeSpace('s-priv2', 'private', 'code-priv2');
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/spaces/join',
|
||||||
|
payload: { inviteCode: 'code-priv2' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(isMember('s-priv2', 'joiner')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/spaces/:id/invite — visibility guard', () => {
|
||||||
|
// Caller is the owner (a member with CREATE_INVITE) so we exercise the
|
||||||
|
// visibility guard, not the permission/membership gate.
|
||||||
|
it('refuses to mint/return an invite code for a request-only space', async () => {
|
||||||
|
currentUserId = OWNER_ID;
|
||||||
|
makeSpace('s-req-inv', 'request', 'code-req-inv');
|
||||||
|
const res = await app.inject({ method: 'POST', url: '/api/spaces/s-req-inv/invite' });
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an invite code for a private space', async () => {
|
||||||
|
currentUserId = OWNER_ID;
|
||||||
|
makeSpace('s-priv-inv', 'private', 'code-priv-inv');
|
||||||
|
const res = await app.inject({ method: 'POST', url: '/api/spaces/s-priv-inv/invite' });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().inviteCode).toBe('code-priv-inv');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an invite code for a public space', async () => {
|
||||||
|
currentUserId = OWNER_ID;
|
||||||
|
makeSpace('s-pub-inv', 'public', 'code-pub-inv');
|
||||||
|
const res = await app.inject({ method: 'POST', url: '/api/spaces/s-pub-inv/invite' });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().inviteCode).toBe('code-pub-inv');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -561,9 +561,22 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!hasPermission(request.userId, id, PermissionBits.CREATE_INVITE)) {
|
if (!hasPermission(request.userId, id, PermissionBits.CREATE_INVITE)) {
|
||||||
|
// Owners and instance admins always pass hasPermission, so anyone who lands
|
||||||
|
// here is either a non-member or a member without CREATE_INVITE. Give the
|
||||||
|
// non-member a clearer "go join first" message instead of a permission error.
|
||||||
|
if (!isMember(id, request.userId)) {
|
||||||
|
return reply.code(403).send({ error: 'Space membership required', statusCode: 403 });
|
||||||
|
}
|
||||||
return reply.code(403).send({ error: 'Missing CREATE_INVITE permission', statusCode: 403 });
|
return reply.code(403).send({ error: 'Missing CREATE_INVITE permission', statusCode: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Request-only spaces are approval-gated and never joinable by invite code
|
||||||
|
// (see the join endpoints), so they have no usable invite links. Refuse to
|
||||||
|
// hand one out rather than mint a code that would dead-end at the join guard.
|
||||||
|
if (server.visibility === 'request') {
|
||||||
|
return reply.code(403).send({ error: 'Request-only spaces do not use invite links; entry is by join request', statusCode: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
// Return existing invite code if one exists, otherwise generate a new one
|
// Return existing invite code if one exists, otherwise generate a new one
|
||||||
if (server.inviteCode) {
|
if (server.inviteCode) {
|
||||||
return reply.code(200).send({ inviteCode: server.inviteCode });
|
return reply.code(200).send({ inviteCode: server.inviteCode });
|
||||||
@@ -605,6 +618,13 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return reply.code(409).send({ error: 'You are already a member of this space', statusCode: 409 });
|
return reply.code(409).send({ error: 'You are already a member of this space', statusCode: 409 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Request-only spaces are gated by manager approval: entry must go through
|
||||||
|
// POST /request-join + approval, never a bearer invite code. (Private spaces
|
||||||
|
// remain invite-joinable — that is their only entry path; public too.)
|
||||||
|
if (server.visibility === 'request') {
|
||||||
|
return reply.code(403).send({ error: 'This space requires an approved join request', statusCode: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
db.insert(schema.spaceMembers).values({
|
db.insert(schema.spaceMembers).values({
|
||||||
spaceId: id,
|
spaceId: id,
|
||||||
@@ -661,6 +681,11 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return reply.code(409).send({ error: 'You are already a member of this space', statusCode: 409 });
|
return reply.code(409).send({ error: 'You are already a member of this space', statusCode: 409 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Request-only spaces are gated by manager approval (see POST /:id/join).
|
||||||
|
if (server.visibility === 'request') {
|
||||||
|
return reply.code(403).send({ error: 'This space requires an approved join request', statusCode: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
db.insert(schema.spaceMembers).values({
|
db.insert(schema.spaceMembers).values({
|
||||||
spaceId: server.id,
|
spaceId: server.id,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { generateSnowflake } from './snowflake.js';
|
|||||||
import { getDmMessageWithUser } from '../routes/dm.js';
|
import { getDmMessageWithUser } from '../routes/dm.js';
|
||||||
import { connectionManager } from '../ws/handler.js';
|
import { connectionManager } from '../ws/handler.js';
|
||||||
import { generateThumbnail } from './thumbnail.js';
|
import { generateThumbnail } from './thumbnail.js';
|
||||||
|
import { safeFetch } from './ssrf.js';
|
||||||
import type { FederationRelayRequest, FederationRelayResponse, FederationRelayEvent } from '@backspace/shared';
|
import type { FederationRelayRequest, FederationRelayResponse, FederationRelayEvent } from '@backspace/shared';
|
||||||
import { startupBootstrapSync, onPeerDeactivated } from './federationPeerActivation.js';
|
import { startupBootstrapSync, onPeerDeactivated } from './federationPeerActivation.js';
|
||||||
import { probePeerReachable, recoverOrDetectReset, detectResetOnNeedsAttentionPeers, detectResetForPeer } from './federationRecovery.js';
|
import { probePeerReachable, recoverOrDetectReset, detectResetOnNeedsAttentionPeers, detectResetForPeer } from './federationRecovery.js';
|
||||||
@@ -840,7 +841,8 @@ async function processFileQueueEntry(
|
|||||||
maxUploadSize: number,
|
maxUploadSize: number,
|
||||||
now: number,
|
now: number,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// SSRF protection: validate sourceUrl hostname matches peerOrigin hostname
|
// SSRF protection: sourceUrl must start at the peer host; safeFetch below
|
||||||
|
// re-validates DNS and every redirect hop before downloading bytes.
|
||||||
try {
|
try {
|
||||||
const sourceHostname = new URL(entry.sourceUrl).hostname;
|
const sourceHostname = new URL(entry.sourceUrl).hostname;
|
||||||
const peerHostname = new URL(entry.peerOrigin).hostname;
|
const peerHostname = new URL(entry.peerOrigin).hostname;
|
||||||
@@ -885,7 +887,7 @@ async function processFileQueueEntry(
|
|||||||
fileQueueAbortController = new AbortController();
|
fileQueueAbortController = new AbortController();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(entry.sourceUrl, {
|
const response = await safeFetch(entry.sourceUrl, {
|
||||||
signal: AbortSignal.any([
|
signal: AbortSignal.any([
|
||||||
fileQueueAbortController.signal,
|
fileQueueAbortController.signal,
|
||||||
AbortSignal.timeout(FILE_DOWNLOAD_TIMEOUT_MS),
|
AbortSignal.timeout(FILE_DOWNLOAD_TIMEOUT_MS),
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import * as schema from '../db/schema.js';
|
||||||
|
import {
|
||||||
|
PermissionBits,
|
||||||
|
ALL_PERMISSIONS,
|
||||||
|
DEFAULT_EVERYONE_PERMISSIONS,
|
||||||
|
permissionsToString,
|
||||||
|
} from '@backspace/shared/src/permissions.js';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
type TestDb = ReturnType<typeof drizzle<typeof schema>>;
|
||||||
|
let sqlite: Database.Database;
|
||||||
|
let testDb: TestDb;
|
||||||
|
|
||||||
|
vi.mock('../db/index.js', () => ({
|
||||||
|
getDb: () => testDb,
|
||||||
|
getRawDb: () => sqlite,
|
||||||
|
schema,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function applyMigrations(db: Database.Database): void {
|
||||||
|
const migrationsDir = path.resolve(__dirname, '../../drizzle');
|
||||||
|
const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort();
|
||||||
|
for (const f of files) {
|
||||||
|
const sqlText = fs.readFileSync(path.join(migrationsDir, f), 'utf8');
|
||||||
|
const statements = sqlText.split(/-->\s*statement-breakpoint/);
|
||||||
|
for (const stmt of statements) {
|
||||||
|
const clean = stmt.trim();
|
||||||
|
if (clean) db.exec(clean);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const OWNER_ID = 'owner-1';
|
||||||
|
const MEMBER_ID = 'member-1';
|
||||||
|
const OUTSIDER_ID = 'outsider-1';
|
||||||
|
const ADMIN_ID = 'admin-1';
|
||||||
|
const SPACE_ID = 'space-1';
|
||||||
|
const now = 1_700_000_000_000;
|
||||||
|
|
||||||
|
function seed(): void {
|
||||||
|
for (const [id, isAdmin] of [
|
||||||
|
[OWNER_ID, 0],
|
||||||
|
[MEMBER_ID, 0],
|
||||||
|
[OUTSIDER_ID, 0],
|
||||||
|
[ADMIN_ID, 1],
|
||||||
|
] as const) {
|
||||||
|
testDb.insert(schema.users).values({
|
||||||
|
id,
|
||||||
|
username: id,
|
||||||
|
passwordHash: 'x',
|
||||||
|
isAdmin,
|
||||||
|
createdAt: now,
|
||||||
|
}).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
testDb.insert(schema.spaces).values({
|
||||||
|
id: SPACE_ID,
|
||||||
|
name: 'Test Space',
|
||||||
|
ownerId: OWNER_ID,
|
||||||
|
inviteCode: 'code-1',
|
||||||
|
visibility: 'request',
|
||||||
|
createdAt: now,
|
||||||
|
}).run();
|
||||||
|
|
||||||
|
// @everyone role (id === spaceId) carries the default member permissions.
|
||||||
|
testDb.insert(schema.roles).values({
|
||||||
|
id: SPACE_ID,
|
||||||
|
spaceId: SPACE_ID,
|
||||||
|
name: '@everyone',
|
||||||
|
permissions: permissionsToString(DEFAULT_EVERYONE_PERMISSIONS),
|
||||||
|
createdAt: now,
|
||||||
|
}).run();
|
||||||
|
|
||||||
|
// Owner and one ordinary member are enrolled; OUTSIDER and ADMIN are not.
|
||||||
|
testDb.insert(schema.spaceMembers).values({ spaceId: SPACE_ID, userId: OWNER_ID, joinedAt: now }).run();
|
||||||
|
testDb.insert(schema.spaceMembers).values({ spaceId: SPACE_ID, userId: MEMBER_ID, joinedAt: now }).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
sqlite = new Database(':memory:');
|
||||||
|
sqlite.pragma('foreign_keys = ON');
|
||||||
|
applyMigrations(sqlite);
|
||||||
|
testDb = drizzle(sqlite, { schema });
|
||||||
|
seed();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('computePermissions', () => {
|
||||||
|
it('returns 0n for a user who is not a member of the space', async () => {
|
||||||
|
const { computePermissions } = await import('./permissions.js');
|
||||||
|
expect(computePermissions(OUTSIDER_ID, SPACE_ID)).toBe(0n);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not grant CREATE_INVITE to a non-member via @everyone', async () => {
|
||||||
|
const { hasPermission } = await import('./permissions.js');
|
||||||
|
expect(hasPermission(OUTSIDER_ID, SPACE_ID, PermissionBits.CREATE_INVITE)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not grant read access to a non-member via @everyone', async () => {
|
||||||
|
const { hasPermission } = await import('./permissions.js');
|
||||||
|
const read = PermissionBits.VIEW_CHANNEL | PermissionBits.READ_MESSAGE_HISTORY;
|
||||||
|
expect(hasPermission(OUTSIDER_ID, SPACE_ID, read)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('grants @everyone permissions to an enrolled member', async () => {
|
||||||
|
const { computePermissions } = await import('./permissions.js');
|
||||||
|
const perms = computePermissions(MEMBER_ID, SPACE_ID);
|
||||||
|
expect(perms).toBe(DEFAULT_EVERYONE_PERMISSIONS);
|
||||||
|
expect(perms & PermissionBits.CREATE_INVITE).toBe(PermissionBits.CREATE_INVITE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('grants ALL_PERMISSIONS to the space owner', async () => {
|
||||||
|
const { computePermissions } = await import('./permissions.js');
|
||||||
|
expect(computePermissions(OWNER_ID, SPACE_ID)).toBe(ALL_PERMISSIONS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('grants ALL_PERMISSIONS to an instance admin even when not a member', async () => {
|
||||||
|
const { computePermissions } = await import('./permissions.js');
|
||||||
|
expect(computePermissions(ADMIN_ID, SPACE_ID)).toBe(ALL_PERMISSIONS);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -36,6 +36,13 @@ export function computePermissions(userId: string, spaceId: string, channelId?:
|
|||||||
const userRow = db.select().from(schema.users).where(eq(schema.users.id, userId)).get();
|
const userRow = db.select().from(schema.users).where(eq(schema.users.id, userId)).get();
|
||||||
if (userRow?.isAdmin === 1) return ALL_PERMISSIONS;
|
if (userRow?.isAdmin === 1) return ALL_PERMISSIONS;
|
||||||
|
|
||||||
|
// 1c. Membership gate — a user who has not joined the space has NO permissions
|
||||||
|
// in it. Without this, the @everyone role below leaks default member rights
|
||||||
|
// (VIEW_CHANNEL, READ_MESSAGE_HISTORY, CREATE_INVITE, …) to any authenticated
|
||||||
|
// non-member, which allowed reading channels and minting invite codes for
|
||||||
|
// spaces the caller never joined. Owner and instance admin are handled above.
|
||||||
|
if (!getMember(spaceId, userId)) return 0n;
|
||||||
|
|
||||||
// 2. Base permissions from @everyone role (id === spaceId)
|
// 2. Base permissions from @everyone role (id === spaceId)
|
||||||
const everyoneRole = db.select().from(schema.roles)
|
const everyoneRole = db.select().from(schema.roles)
|
||||||
.where(and(eq(schema.roles.id, spaceId), eq(schema.roles.spaceId, spaceId)))
|
.where(and(eq(schema.roles.id, spaceId), eq(schema.roles.spaceId, spaceId)))
|
||||||
@@ -146,67 +153,6 @@ export function computePermissions(userId: string, spaceId: string, channelId?:
|
|||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Compute permissions at the category level (no channel step).
|
|
||||||
* Used for determining if a category is "private" for a user.
|
|
||||||
*/
|
|
||||||
export function computeCategoryPermissions(userId: string, spaceId: string, categoryId: string): bigint {
|
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
const space = db.select().from(schema.spaces).where(eq(schema.spaces.id, spaceId)).get();
|
|
||||||
if (!space) return 0n;
|
|
||||||
if (space.ownerId === userId) return ALL_PERMISSIONS;
|
|
||||||
|
|
||||||
const userRow = db.select().from(schema.users).where(eq(schema.users.id, userId)).get();
|
|
||||||
if (userRow?.isAdmin === 1) return ALL_PERMISSIONS;
|
|
||||||
|
|
||||||
const everyoneRole = db.select().from(schema.roles)
|
|
||||||
.where(and(eq(schema.roles.id, spaceId), eq(schema.roles.spaceId, spaceId)))
|
|
||||||
.get();
|
|
||||||
let base = everyoneRole ? stringToPermissions(everyoneRole.permissions) : 0n;
|
|
||||||
|
|
||||||
const memberRoleRows = db.select().from(schema.memberRoles)
|
|
||||||
.where(and(eq(schema.memberRoles.spaceId, spaceId), eq(schema.memberRoles.userId, userId)))
|
|
||||||
.all();
|
|
||||||
const assignedRoleIds = memberRoleRows.map(mr => mr.roleId);
|
|
||||||
|
|
||||||
for (const roleId of assignedRoleIds) {
|
|
||||||
const role = db.select().from(schema.roles).where(eq(schema.roles.id, roleId)).get();
|
|
||||||
if (role) base |= stringToPermissions(role.permissions);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((base & PermissionBits.ADMINISTRATOR) !== 0n) return ALL_PERMISSIONS;
|
|
||||||
|
|
||||||
const catOverrides = db.select().from(schema.categoryOverrides)
|
|
||||||
.where(eq(schema.categoryOverrides.categoryId, categoryId))
|
|
||||||
.all();
|
|
||||||
|
|
||||||
if (catOverrides.length === 0) return base;
|
|
||||||
|
|
||||||
const everyoneOverride = catOverrides.find(o => o.targetType === 'role' && o.targetId === spaceId);
|
|
||||||
if (everyoneOverride) {
|
|
||||||
base = (base & ~stringToPermissions(everyoneOverride.deny)) | stringToPermissions(everyoneOverride.allow);
|
|
||||||
}
|
|
||||||
|
|
||||||
let combinedAllow = 0n;
|
|
||||||
let combinedDeny = 0n;
|
|
||||||
for (const roleId of assignedRoleIds) {
|
|
||||||
const roleOverride = catOverrides.find(o => o.targetType === 'role' && o.targetId === roleId);
|
|
||||||
if (roleOverride) {
|
|
||||||
combinedAllow |= stringToPermissions(roleOverride.allow);
|
|
||||||
combinedDeny |= stringToPermissions(roleOverride.deny);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
base = (base & ~combinedDeny) | combinedAllow;
|
|
||||||
|
|
||||||
const memberOverride = catOverrides.find(o => o.targetType === 'member' && o.targetId === userId);
|
|
||||||
if (memberOverride) {
|
|
||||||
base = (base & ~stringToPermissions(memberOverride.deny)) | stringToPermissions(memberOverride.allow);
|
|
||||||
}
|
|
||||||
|
|
||||||
return base;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a user has a specific permission in a space/channel.
|
* Check if a user has a specific permission in a space/channel.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -54,6 +54,18 @@ function seedSpace(spaceId: string): void {
|
|||||||
}).run();
|
}).run();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enroll a user as a space member. computePermissions grants @everyone
|
||||||
|
// permissions only to actual members, and every real join path inserts this row
|
||||||
|
// before voice state is built/pushed — so visibility tests must seed it too.
|
||||||
|
function seedMember(spaceId: string, userId: string): void {
|
||||||
|
seedUser(userId);
|
||||||
|
testDb.insert(schema.spaceMembers).values({
|
||||||
|
spaceId,
|
||||||
|
userId,
|
||||||
|
joinedAt: Date.now(),
|
||||||
|
}).run();
|
||||||
|
}
|
||||||
|
|
||||||
function seedChannel(id: string, spaceId: string, type: 'text' | 'voice'): void {
|
function seedChannel(id: string, spaceId: string, type: 'text' | 'voice'): void {
|
||||||
testDb.insert(schema.channels).values({
|
testDb.insert(schema.channels).values({
|
||||||
id,
|
id,
|
||||||
@@ -178,6 +190,7 @@ describe('connectionManager.buildSpaceVoiceState', () => {
|
|||||||
const privateCh = 'vc-private-1';
|
const privateCh = 'vc-private-1';
|
||||||
seedSpace(spaceId);
|
seedSpace(spaceId);
|
||||||
seedEveryoneRole(spaceId);
|
seedEveryoneRole(spaceId);
|
||||||
|
seedMember(spaceId, 'u-viewer');
|
||||||
seedChannel(publicCh, spaceId, 'voice');
|
seedChannel(publicCh, spaceId, 'voice');
|
||||||
seedChannel(privateCh, spaceId, 'voice');
|
seedChannel(privateCh, spaceId, 'voice');
|
||||||
seedDenyViewOverride(privateCh, spaceId);
|
seedDenyViewOverride(privateCh, spaceId);
|
||||||
@@ -204,6 +217,7 @@ describe('connectionManager.addUserSpace voice-state push', () => {
|
|||||||
const voiceCh = 'vc-push-1';
|
const voiceCh = 'vc-push-1';
|
||||||
seedSpace(spaceId);
|
seedSpace(spaceId);
|
||||||
seedEveryoneRole(spaceId);
|
seedEveryoneRole(spaceId);
|
||||||
|
seedMember(spaceId, 'u-joiner');
|
||||||
seedChannel(voiceCh, spaceId, 'voice');
|
seedChannel(voiceCh, spaceId, 'voice');
|
||||||
|
|
||||||
cm.createRoom(voiceCh, 'space', { type: 'space', spaceId });
|
cm.createRoom(voiceCh, 'space', { type: 'space', spaceId });
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||||
import { bootTwoInstances, type TwoInstanceHarness } from './helpers/twoInstanceHarness.js';
|
import { bootTwoInstances, type TwoInstanceHarness } from './helpers/twoInstanceHarness.js';
|
||||||
import { peerInstances } from './helpers/seedPeer.js';
|
import { peerInstances } from './helpers/seedPeer.js';
|
||||||
import { registerLocal } from './helpers/testUsers.js';
|
import { registerLocal } from './helpers/testUsers.js';
|
||||||
import { buildHeadersForOrigin } from './helpers/hmacSign.js';
|
import { buildHeadersForOrigin } from './helpers/hmacSign.js';
|
||||||
|
|
||||||
|
// Boots real federated instances and drives S2S over HTTP; the 5s unit-test
|
||||||
|
// default is too tight under CI load. See federation-identity-deletion.test.ts
|
||||||
|
// for the rationale. A genuine hang still trips this ceiling.
|
||||||
|
vi.setConfig({ testTimeout: 30_000 });
|
||||||
|
|
||||||
let harness: TwoInstanceHarness;
|
let harness: TwoInstanceHarness;
|
||||||
let sharedSecret: string;
|
let sharedSecret: string;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect, afterEach } from 'vitest';
|
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||||
import {
|
import {
|
||||||
bootTwoInstancesForHandshake,
|
bootTwoInstancesForHandshake,
|
||||||
registerAdmin,
|
registerAdmin,
|
||||||
@@ -11,6 +11,12 @@ import {
|
|||||||
import type { TwoInstanceHarness } from './helpers/twoInstanceHarness.js';
|
import type { TwoInstanceHarness } from './helpers/twoInstanceHarness.js';
|
||||||
import { openInspector } from './helpers/dbInspect.js';
|
import { openInspector } from './helpers/dbInspect.js';
|
||||||
|
|
||||||
|
// Boots real federated instances and drives the peering handshake over HTTP; the
|
||||||
|
// 5s unit-test default is too tight under CI load. See
|
||||||
|
// federation-identity-deletion.test.ts for the rationale. A genuine hang still
|
||||||
|
// trips this ceiling.
|
||||||
|
vi.setConfig({ testTimeout: 30_000 });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Acceptance-gate integration suite for the federation handshake desync bugs.
|
* Acceptance-gate integration suite for the federation handshake desync bugs.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||||
import { bootTwoInstances, bootHomePlusRemotes, type TwoInstanceHarness, type MultiRemoteHarness, type SpawnedInstance } from './helpers/twoInstanceHarness.js';
|
import { bootTwoInstances, bootHomePlusRemotes, type TwoInstanceHarness, type MultiRemoteHarness, type SpawnedInstance } from './helpers/twoInstanceHarness.js';
|
||||||
import { peerInstances } from './helpers/seedPeer.js';
|
import { peerInstances } from './helpers/seedPeer.js';
|
||||||
import type { TestUser } from './helpers/testUsers.js';
|
import type { TestUser } from './helpers/testUsers.js';
|
||||||
import { connectWs } from './helpers/wsListener.js';
|
import { connectWs } from './helpers/wsListener.js';
|
||||||
|
|
||||||
|
// Every test here boots real federated instances and drives S2S over HTTP, and
|
||||||
|
// several deliberately wait on log matchers (e.g. logMatched(..., 1_000) per
|
||||||
|
// remote). The 5s default per-test timeout is meant for unit tests and is too
|
||||||
|
// tight for this — under CI load the multi-remote fan-out tests intermittently
|
||||||
|
// timed out. Give the whole file a realistic ceiling; a genuine hang still trips
|
||||||
|
// it well before then. Hooks keep their own explicit timeouts (beforeAll 90s).
|
||||||
|
vi.setConfig({ testTimeout: 30_000 });
|
||||||
|
|
||||||
let harness: TwoInstanceHarness;
|
let harness: TwoInstanceHarness;
|
||||||
let sharedHmacSecret: string;
|
let sharedHmacSecret: string;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig, configDefaults } from 'vitest/config';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
setupFiles: ['./test/setup-env.ts'],
|
setupFiles: ['./test/setup-env.ts'],
|
||||||
|
// Vitest 4's default `exclude` is only node_modules/.git — it no longer
|
||||||
|
// ignores dist/. Once `pnpm build` (tsc) has emitted the compiled test files
|
||||||
|
// into dist/, vitest would otherwise run those stale .js copies alongside the
|
||||||
|
// real src/*.test.ts — and they fail, because compiled vi.mock() paths
|
||||||
|
// resolve differently than the source. Never run build output as tests.
|
||||||
|
exclude: [...configDefaults.exclude, 'dist/**'],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,9 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"e2e:identity-deletion": "playwright test e2e/identity-deletion.spec.ts"
|
"e2e:identity-deletion": "playwright test e2e/identity-deletion.spec.ts"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -425,6 +425,32 @@ describe('InviteModal', () => {
|
|||||||
expect(screen.getByText('Sam')).toBeInTheDocument();
|
expect(screen.getByText('Sam')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows an approval-required notice and hides invite affordances for request-only spaces', async () => {
|
||||||
|
const generateInvite = vi.fn().mockResolvedValue('should-not-be-used');
|
||||||
|
useUIStore.setState({ activeModal: 'invite', modalData: {} });
|
||||||
|
useSpaceStore.setState({
|
||||||
|
currentSpaceId: 'space-1',
|
||||||
|
spaces: [makeSpace({ visibility: 'request' })] as any,
|
||||||
|
members: [],
|
||||||
|
generateInvite,
|
||||||
|
} as any);
|
||||||
|
useSocialStore.setState({
|
||||||
|
friends: [makeFriend({ id: 'f1', username: 'alex', displayName: 'Alex' })],
|
||||||
|
} as any);
|
||||||
|
useAuthStore.setState({ user: { id: 'me', username: 'me' } } as any);
|
||||||
|
|
||||||
|
render(<InviteModal />);
|
||||||
|
|
||||||
|
// Explanatory copy replaces the invite UI.
|
||||||
|
expect(screen.getByText(/join request/i)).toBeInTheDocument();
|
||||||
|
// None of the invite affordances render.
|
||||||
|
expect(screen.queryByPlaceholderText('Search friends...')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Or share a link')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Alex')).not.toBeInTheDocument();
|
||||||
|
// No invite code is requested for a request-only space (the endpoint 403s).
|
||||||
|
expect(generateInvite).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('passes federated target shape for remote friends', async () => {
|
it('passes federated target shape for remote friends', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
mockSpaceInvite.mockResolvedValue({});
|
mockSpaceInvite.mockResolvedValue({});
|
||||||
|
|||||||
@@ -149,6 +149,10 @@ export function InviteModal() {
|
|||||||
const isOpen = activeModal === 'invite';
|
const isOpen = activeModal === 'invite';
|
||||||
const currentSpace = spaces.find((s) => s.id === currentSpaceId);
|
const currentSpace = spaces.find((s) => s.id === currentSpaceId);
|
||||||
const instanceOrigin = currentSpace?._instanceOrigin ?? '';
|
const instanceOrigin = currentSpace?._instanceOrigin ?? '';
|
||||||
|
// Request-only spaces are approval-gated: they have no usable invite link and
|
||||||
|
// the /invite endpoint 403s. Show an explanatory notice instead of the invite
|
||||||
|
// affordances, and skip the invite-code fetch entirely.
|
||||||
|
const isRequestOnly = currentSpace?.visibility === 'request';
|
||||||
|
|
||||||
const [inviteCode, setInviteCode] = useState('');
|
const [inviteCode, setInviteCode] = useState('');
|
||||||
const [codeError, setCodeError] = useState('');
|
const [codeError, setCodeError] = useState('');
|
||||||
@@ -167,7 +171,7 @@ export function InviteModal() {
|
|||||||
|
|
||||||
// Fetch / generate the per-space invite code on open.
|
// Fetch / generate the per-space invite code on open.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen || !currentSpaceId) return;
|
if (!isOpen || !currentSpaceId || isRequestOnly) return;
|
||||||
setCodeLoading(true);
|
setCodeLoading(true);
|
||||||
setCodeError('');
|
setCodeError('');
|
||||||
generateInvite(currentSpaceId).then(
|
generateInvite(currentSpaceId).then(
|
||||||
@@ -180,7 +184,7 @@ export function InviteModal() {
|
|||||||
setCodeLoading(false);
|
setCodeLoading(false);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}, [isOpen, currentSpaceId, generateInvite]);
|
}, [isOpen, currentSpaceId, generateInvite, isRequestOnly]);
|
||||||
|
|
||||||
// Reset modal state on open.
|
// Reset modal state on open.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -333,6 +337,20 @@ export function InviteModal() {
|
|||||||
title="Invite Friends"
|
title="Invite Friends"
|
||||||
mobileStyle="sheet"
|
mobileStyle="sheet"
|
||||||
>
|
>
|
||||||
|
{isRequestOnly ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-[13px] text-txt-tertiary">
|
||||||
|
This space uses join requests — people join by requesting approval
|
||||||
|
from a manager, so it has no invite link to share.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={closeModal}
|
||||||
|
className="w-full py-2 rounded-md text-[13px] font-semibold glass-pill text-txt-primary"
|
||||||
|
>
|
||||||
|
Got it
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p className="text-[13px] text-txt-tertiary">
|
<p className="text-[13px] text-txt-tertiary">
|
||||||
Send to friends, or share a link.
|
Send to friends, or share a link.
|
||||||
@@ -460,6 +478,7 @@ export function InviteModal() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,25 @@ if (typeof ClipboardItem === 'undefined') {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// jsdom's Blob implementation has no `.stream()` method on Node 20 (Node 25+
|
||||||
|
// happens to provide one, which is why this only surfaces on the pinned target
|
||||||
|
// runtime). undici's `Response` constructor extracts a Blob body by calling
|
||||||
|
// `blob.stream()`, so `new Response(blob)` throws "object.stream is not a
|
||||||
|
// function" without this. Back it with the blob's own arrayBuffer().
|
||||||
|
if (typeof Blob !== 'undefined' && typeof Blob.prototype.stream !== 'function') {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(Blob.prototype as any).stream = function stream(this: Blob): ReadableStream<Uint8Array> {
|
||||||
|
const blob = this;
|
||||||
|
return new ReadableStream<Uint8Array>({
|
||||||
|
async start(controller) {
|
||||||
|
const buffer = await blob.arrayBuffer();
|
||||||
|
controller.enqueue(new Uint8Array(buffer));
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Patch globalThis.Response to preserve Blob content-type in jsdom.
|
// Patch globalThis.Response to preserve Blob content-type in jsdom.
|
||||||
// jsdom's fetch Response.blob() drops the Blob's MIME type; this shim
|
// jsdom's fetch Response.blob() drops the Blob's MIME type; this shim
|
||||||
// restores it so tests that construct `new Response(blob)` behave correctly.
|
// restores it so tests that construct `new Response(blob)` behave correctly.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
/// <reference types="vite-plugin-pwa/react" />
|
||||||
|
|
||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
readonly VITE_FORCE_BOOT_STALL?: string;
|
readonly VITE_FORCE_BOOT_STALL?: string;
|
||||||
|
|||||||
@@ -120,6 +120,9 @@ importers:
|
|||||||
'@types/ws':
|
'@types/ws':
|
||||||
specifier: ^8.18.1
|
specifier: ^8.18.1
|
||||||
version: 8.18.1
|
version: 8.18.1
|
||||||
|
cross-env:
|
||||||
|
specifier: ^7.0.3
|
||||||
|
version: 7.0.3
|
||||||
drizzle-kit:
|
drizzle-kit:
|
||||||
specifier: ^0.24.0
|
specifier: ^0.24.0
|
||||||
version: 0.24.2
|
version: 0.24.2
|
||||||
@@ -2656,6 +2659,11 @@ packages:
|
|||||||
crc@3.8.0:
|
crc@3.8.0:
|
||||||
resolution: {integrity: sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==}
|
resolution: {integrity: sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==}
|
||||||
|
|
||||||
|
cross-env@7.0.3:
|
||||||
|
resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==}
|
||||||
|
engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
cross-spawn@7.0.6:
|
cross-spawn@7.0.6:
|
||||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
@@ -7993,6 +8001,10 @@ snapshots:
|
|||||||
buffer: 5.7.1
|
buffer: 5.7.1
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
cross-env@7.0.3:
|
||||||
|
dependencies:
|
||||||
|
cross-spawn: 7.0.6
|
||||||
|
|
||||||
cross-spawn@7.0.6:
|
cross-spawn@7.0.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
path-key: 3.1.1
|
path-key: 3.1.1
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"signedContributors": [
|
||||||
|
{
|
||||||
|
"name": "BadAtCaptchas",
|
||||||
|
"id": 2359196,
|
||||||
|
"comment_id": 4904982593,
|
||||||
|
"created_at": "2026-07-07T14:35:50Z",
|
||||||
|
"repoId": 1160481903,
|
||||||
|
"pullRequestNo": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 111 KiB |
@@ -0,0 +1,29 @@
|
|||||||
|
# Backspace
|
||||||
|
|
||||||
|
> Backspace is a self-hosted, open-source Discord alternative: a Discord-style chat platform you run on your own hardware. It offers spaces, channels, role-based permissions, voice and video, screen sharing up to 4K/120fps, direct messages, friends, full-text search, and server-to-server federation between independently owned instances. It is licensed under the GNU AGPL-3.0 with a commercial dual-license option, deploys with a single Docker installer, and runs on ARM hardware such as a Raspberry Pi.
|
||||||
|
|
||||||
|
## What it is
|
||||||
|
|
||||||
|
- Category: self-hosted team communication platform, Discord alternative.
|
||||||
|
- License: GNU AGPL-3.0 (open source), commercial license available.
|
||||||
|
- Stack: TypeScript monorepo, Fastify and SQLite on the server, React on the web, LiveKit for voice and video, Electron desktop app, installable mobile PWA.
|
||||||
|
- Deployment: interactive Docker installer with automatic HTTPS via Caddy, prebuilt multi-architecture image (amd64 and arm64).
|
||||||
|
|
||||||
|
## Distinctive features
|
||||||
|
|
||||||
|
- Fine-grained media controls: per-stream codec (VP9 or hardware H.264), bitrate, resolution, and frame rate; independent 0-200% volume per participant and per screen-share; RNNoise noise suppression; a live connection inspector for bitrate, ping, packet loss, and jitter.
|
||||||
|
- Screen sharing up to 4K/120fps within admin-set bounds.
|
||||||
|
- Server-to-server federation: independent Backspace instances peer with each other for cross-instance friends, DMs, calls, and presence, each server independently owned, requests HMAC-authenticated. This is a custom Backspace-to-Backspace protocol, not Matrix or ActivityPub.
|
||||||
|
- Role-based permissions with per-category and per-channel overrides, group DMs and calls, moderation with audit trails, and full-text search.
|
||||||
|
|
||||||
|
## Honest scope
|
||||||
|
|
||||||
|
- It does not have Discord's ecosystem or bot library.
|
||||||
|
- It is not an open, cross-ecosystem federation standard like Matrix; it federates Backspace instances with each other.
|
||||||
|
- Native iOS and Android apps are planned; today the mobile path is an installable PWA.
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- Source code and documentation: https://github.com/TheZwiss/backspace
|
||||||
|
- Comparison with Discord, Revolt, Spacebar, Matrix, and Mumble: https://github.com/TheZwiss/backspace/blob/main/docs/comparison.md
|
||||||
|
- License: https://github.com/TheZwiss/backspace/blob/main/LICENSE
|
||||||
|
After Width: | Height: | Size: 222 KiB |