Compare commits
56
Commits
v1.0.0
...
b92a0d837e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b92a0d837e | ||
|
|
d7da0ff203 | ||
|
|
bfe62d7078 | ||
|
|
37407a5ecd | ||
|
|
cad3867027 | ||
|
|
20526e1bc8 | ||
|
|
c70b0095a9 | ||
|
|
08db5374cb | ||
|
|
6c7cbee808 | ||
|
|
8456b8f976 | ||
|
|
747f1b9c5c | ||
|
|
89467d6f93 | ||
|
|
8e13aaa057 | ||
|
|
aa5052ba8a | ||
|
|
97989fbfb4 | ||
|
|
bf836e208b | ||
|
|
53a36cd11a | ||
|
|
d2d6ce9756 | ||
|
|
7d1895308d | ||
|
|
0b3aa42a09 | ||
|
|
6d81b63d86 | ||
|
|
c4929a8b1b | ||
|
|
0ec7ddba81 | ||
|
|
3100965c30 | ||
|
|
9d2eeb0963 | ||
|
|
fd8659a964 | ||
|
|
7457146846 | ||
|
|
321428ba1f | ||
|
|
cf4172e81a | ||
|
|
e2d09c0d52 | ||
|
|
cb524675cc | ||
|
|
4758ca46fa | ||
|
|
21d783e257 | ||
|
|
3f75ff6e36 | ||
|
|
74ae929ab4 | ||
|
|
807afba45a | ||
|
|
1e6c7c6042 | ||
|
|
43cab41e60 | ||
|
|
d76e06a023 | ||
|
|
c79bf91398 | ||
|
|
94fe73522d | ||
|
|
180228f2d1 | ||
|
|
c7d88481ad | ||
|
|
9b6d1b18eb | ||
|
|
628e4dec3e | ||
|
|
0727d5a3b3 | ||
|
|
3513a3dde9 | ||
|
|
95b545d8b2 | ||
|
|
85e1975fa5 | ||
|
|
26bb0be7af | ||
|
|
e27e52f267 | ||
|
|
531b496618 | ||
|
|
e7f41b5609 | ||
|
|
24accc3647 | ||
|
|
028005d487 | ||
|
|
9d3f72be75 |
+43
-1
@@ -4,19 +4,54 @@
|
||||
# Your server's public domain name (required)
|
||||
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 ─────────────────────────────────────────────────
|
||||
# Production/Docker listen port (Caddy reverse-proxies to it). Local development
|
||||
# ignores this and uses 3005 — the Vite dev proxy target — set by `pnpm dev`.
|
||||
PORT=3000
|
||||
HOST=0.0.0.0
|
||||
|
||||
# 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=
|
||||
|
||||
# Registration — set to false to close signups after initial setup
|
||||
REGISTRATION_OPEN=true
|
||||
|
||||
# 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
|
||||
|
||||
# ─── 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.
|
||||
# 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 ───────────────────────────────────
|
||||
# To enable voice/video, fill in all three values below and add:
|
||||
# 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,30 @@
|
||||
<!-- 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) — ticking this box is not the
|
||||
signature. After opening this PR, post a separate comment containing exactly:
|
||||
`I have read the CLA Document and I hereby sign the CLA`
|
||||
|
||||
## Notes for reviewers
|
||||
|
||||
<!-- Anything that helps review: screenshots for UI, migration notes, edge cases, follow-ups. -->
|
||||
@@ -0,0 +1,44 @@
|
||||
# Dependabot keeps dependencies and CI actions patched. Three ecosystems:
|
||||
# - npm → the pnpm workspace (Dependabot reads pnpm-lock.yaml v9)
|
||||
# - github-actions → action version bumps (feeds the SHA-pin comments)
|
||||
# - docker → the Dockerfile base image (FROM node:20-slim)
|
||||
#
|
||||
# NOTE (intentional): there is NO docker entry for docker-compose.yml. It sits
|
||||
# at the same "/" directory (a second docker entry would collide on
|
||||
# ecosystem+directory), and Dependabot's docker ecosystem parses Dockerfiles,
|
||||
# not `image:` refs in compose. The pinned caddy / livekit-server compose images
|
||||
# are updated MANUALLY — see the maintainer checklist in
|
||||
# docs/systems/security-scanning.md.
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: npm
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 10
|
||||
groups:
|
||||
# One grouped PR for routine minor/patch bumps to cut PR noise.
|
||||
npm-minor-patch:
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
ignore:
|
||||
# uiohook-napi is pinned by an exact-version pnpm patch
|
||||
# (patches/uiohook-napi@1.5.5.patch). A bump makes the patch path stop
|
||||
# matching, breaking `pnpm install --frozen-lockfile` in CI and both
|
||||
# Docker stages until the patch is regenerated. Bump it by hand.
|
||||
- dependency-name: uiohook-napi
|
||||
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
groups:
|
||||
github-actions:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
- package-ecosystem: docker
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
@@ -0,0 +1,92 @@
|
||||
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: Harden the runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
with:
|
||||
version: 10.34.3
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
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."
|
||||
@@ -2,8 +2,16 @@ name: CLA Assistant
|
||||
|
||||
# Requires every contributor to sign the project Contributor License Agreement
|
||||
# (CLA.md) before their pull request can be merged. Signatures are stored in
|
||||
# this repository at signatures/cla.json — no external service or database is
|
||||
# used. A contributor signs by commenting the exact sentence configured below.
|
||||
# this repository at signatures/cla.json on the `cla-signatures` branch — no
|
||||
# external service or database is used. A contributor signs by commenting the
|
||||
# exact sentence configured below.
|
||||
#
|
||||
# Why not `main`: the action appends each signature as a direct commit, which
|
||||
# the "Require CI on main" ruleset rejects ("Repository rule violations found"),
|
||||
# leaving the signature unrecorded and the check permanently red. Keeping the
|
||||
# store on its own branch lets the bot append without granting any actor a
|
||||
# bypass on main. That branch has its own ruleset blocking deletion and
|
||||
# force-pushes, so the record cannot be rewritten.
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
@@ -29,7 +37,7 @@ jobs:
|
||||
(github.event.comment.body == 'recheck' ||
|
||||
github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') ||
|
||||
github.event_name == 'pull_request_target'
|
||||
uses: contributor-assistant/github-action@v2.6.1
|
||||
uses: contributor-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
@@ -37,7 +45,7 @@ jobs:
|
||||
path-to-signatures: 'signatures/cla.json'
|
||||
# The CLA document contributors are agreeing to.
|
||||
path-to-document: 'https://github.com/TheZwiss/backspace/blob/main/CLA.md'
|
||||
branch: 'main'
|
||||
branch: 'cla-signatures'
|
||||
# Accounts that never need to sign (maintainer + automation).
|
||||
allowlist: 'TheZwiss,dependabot[bot],github-actions[bot]'
|
||||
# The exact phrase a contributor comments to sign.
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
name: CodeQL
|
||||
|
||||
# Static application security testing for all TS/JS. Uses build-mode: none — no
|
||||
# compile needed, which sidesteps the monorepo/native-module build entirely.
|
||||
# Default (code-scanning) query suite; security-extended is deferred (triage tax).
|
||||
# CodeQL uploads alerts to the Security tab but does NOT fail the PR by itself —
|
||||
# blocking is a repo setting (code-scanning merge protection), documented in the
|
||||
# maintainer checklist in docs/systems/security-scanning.md.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: '27 3 * * 1' # weekly Monday 03:27 UTC
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: codeql-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze (javascript-typescript)
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write # upload SARIF to code scanning
|
||||
actions: read
|
||||
steps:
|
||||
- name: Harden the runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@02c5e83432fe5497fd85b873b6c9f16a8578e1d9 # v3.37.0
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
build-mode: none
|
||||
- name: Perform CodeQL analysis
|
||||
uses: github/codeql-action/analyze@02c5e83432fe5497fd85b873b6c9f16a8578e1d9 # v3.37.0
|
||||
with:
|
||||
category: "/language:javascript-typescript"
|
||||
@@ -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@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
- uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
|
||||
- uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
|
||||
with:
|
||||
path: ./site
|
||||
- id: deployment
|
||||
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
|
||||
@@ -0,0 +1,134 @@
|
||||
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
|
||||
security-events: write
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
|
||||
# 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@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Derive image tags and labels
|
||||
id: docker_meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
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 }}
|
||||
|
||||
# Build a single-arch amd64 image and LOAD it into the runner's docker
|
||||
# daemon so Trivy can scan the exact artifact before anything is published.
|
||||
# A multi-arch manifest cannot be --load'ed, so scanning must happen on a
|
||||
# single-arch build first; the multi-arch push below reuses these layers
|
||||
# from the buildx cache, so this is cheap.
|
||||
- name: Build amd64 image for scanning
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
load: true
|
||||
push: false
|
||||
tags: backspace:scan
|
||||
build-args: |
|
||||
BACKSPACE_COMMIT=${{ steps.meta_commit.outputs.commit }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Trivy image scan (report-only)
|
||||
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||
continue-on-error: true # report-only; enforcement flipped on in Plan E
|
||||
with:
|
||||
scan-type: image
|
||||
image-ref: backspace:scan
|
||||
ignore-unfixed: true
|
||||
format: sarif
|
||||
output: trivy-image.sarif
|
||||
severity: HIGH,CRITICAL
|
||||
|
||||
- name: Upload Trivy image SARIF
|
||||
if: always()
|
||||
continue-on-error: true # a scanner/SARIF-emit flake must never skip the publish below
|
||||
uses: github/codeql-action/upload-sarif@02c5e83432fe5497fd85b873b6c9f16a8578e1d9 # v3.37.0
|
||||
with:
|
||||
sarif_file: trivy-image.sarif
|
||||
category: trivy-image
|
||||
|
||||
# Publish the multi-arch image. Reuses the amd64 layers built above via the
|
||||
# gha cache. Attaches an SBOM and SLSA provenance attestation to the image.
|
||||
- name: Build and push (linux/amd64, linux/arm64)
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
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 }}
|
||||
sbom: true
|
||||
provenance: true
|
||||
# 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
|
||||
@@ -34,8 +34,14 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- name: Harden the runner
|
||||
if: runner.os == 'Linux'
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
|
||||
- name: Install Linux build dependencies
|
||||
if: runner.os == 'Linux'
|
||||
@@ -65,12 +71,12 @@ jobs:
|
||||
sudo gem install --no-document fpm
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
with:
|
||||
version: 10
|
||||
version: 10.34.3
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
with:
|
||||
node-version: 20
|
||||
cache: pnpm
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
name: OpenSSF Scorecard
|
||||
|
||||
# Scores the repo's security posture (branch protection, pinned deps, token
|
||||
# permissions, etc.) and publishes to the OpenSSF public API so a badge can be
|
||||
# shown (badge is added in Plan E). REQUIRES the canonical repo to be PUBLIC —
|
||||
# see the maintainer checklist in docs/systems/security-scanning.md.
|
||||
|
||||
on:
|
||||
branch_protection_rule:
|
||||
schedule:
|
||||
- cron: '18 4 * * 2' # weekly Tuesday 04:18 UTC
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
analysis:
|
||||
name: Scorecard analysis
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
security-events: write # upload SARIF
|
||||
id-token: write # publish_results OIDC attestation
|
||||
steps:
|
||||
- name: Harden the runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Run Scorecard
|
||||
uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
|
||||
with:
|
||||
results_file: results.sarif
|
||||
results_format: sarif
|
||||
publish_results: true
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: SARIF file
|
||||
path: results.sarif
|
||||
retention-days: 5
|
||||
- name: Upload SARIF to code scanning
|
||||
uses: github/codeql-action/upload-sarif@02c5e83432fe5497fd85b873b6c9f16a8578e1d9 # v3.37.0
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
@@ -0,0 +1,123 @@
|
||||
name: Security
|
||||
|
||||
# Report-only in this plan: every scanner is non-blocking and uploads SARIF to
|
||||
# the Security tab. Enforcement (fail on fixable HIGH/CRITICAL, block on secrets)
|
||||
# is flipped on in Plan E after the remediation pass.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: '32 5 * * 1' # weekly Monday 05:32 UTC
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: security-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
gitleaks:
|
||||
name: Secret scan (gitleaks)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden the runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout (full history)
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
fetch-depth: 0 # gitleaks scans the whole git history, not just the diff
|
||||
- name: Run gitleaks
|
||||
uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2.3.9
|
||||
continue-on-error: true # report-only; enforcement flipped on in Plan E
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
osv-scanner:
|
||||
name: Dependency scan (OSV-Scanner)
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write # upload SARIF to code scanning
|
||||
steps:
|
||||
- name: Harden the runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
- name: Run OSV-Scanner
|
||||
uses: google/osv-scanner-action/osv-scanner-action@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8
|
||||
continue-on-error: true # report-only; enforcement flipped on in Plan E
|
||||
with:
|
||||
scan-args: |-
|
||||
--lockfile=./pnpm-lock.yaml
|
||||
--format=sarif
|
||||
--output=osv-results.sarif
|
||||
- name: Upload OSV SARIF
|
||||
if: always()
|
||||
uses: github/codeql-action/upload-sarif@02c5e83432fe5497fd85b873b6c9f16a8578e1d9 # v3.37.0
|
||||
with:
|
||||
sarif_file: osv-results.sarif
|
||||
category: osv-scanner
|
||||
|
||||
trivy-config:
|
||||
name: IaC/config scan (Trivy)
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
steps:
|
||||
- name: Harden the runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
- name: Trivy config scan (Dockerfile + docker-compose)
|
||||
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||
continue-on-error: true # report-only; enforcement flipped on in Plan E
|
||||
with:
|
||||
scan-type: config
|
||||
scan-ref: .
|
||||
format: sarif
|
||||
output: trivy-config.sarif
|
||||
- name: Upload Trivy config SARIF
|
||||
if: always()
|
||||
uses: github/codeql-action/upload-sarif@02c5e83432fe5497fd85b873b6c9f16a8578e1d9 # v3.37.0
|
||||
with:
|
||||
sarif_file: trivy-config.sarif
|
||||
category: trivy-config
|
||||
|
||||
trivy-license:
|
||||
name: License compliance scan (Trivy)
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
steps:
|
||||
- name: Harden the runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
- name: Trivy license scan
|
||||
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||
continue-on-error: true # report-only; enforcement flipped on in Plan E
|
||||
with:
|
||||
scan-type: fs
|
||||
scan-ref: .
|
||||
scanners: license
|
||||
format: sarif
|
||||
output: trivy-license.sarif
|
||||
- name: Upload Trivy license SARIF
|
||||
if: always()
|
||||
uses: github/codeql-action/upload-sarif@02c5e83432fe5497fd85b873b6c9f16a8578e1d9 # v3.37.0
|
||||
with:
|
||||
sarif_file: trivy-license.sarif
|
||||
category: trivy-license
|
||||
@@ -34,6 +34,12 @@ id_ed25519*
|
||||
# Generated deployment config (contains secrets)
|
||||
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/
|
||||
demo-pictures/
|
||||
|
||||
|
||||
@@ -19,8 +19,8 @@ submit.
|
||||
|
||||
**"Contribution"** means any original work of authorship, including any
|
||||
modification of or addition to an existing work, that You intentionally submit
|
||||
to the Project in any form — including source code, object code, documentation,
|
||||
configuration, designs, or assets — through any means, including pull requests,
|
||||
to the Project in any form (including source code, object code, documentation,
|
||||
configuration, designs, or assets) through any means, including pull requests,
|
||||
patches, issues with attached code, or electronic communication, but excluding
|
||||
any communication You conspicuously mark in writing as "Not a Contribution".
|
||||
|
||||
@@ -36,7 +36,7 @@ fully sublicensable, and transferable license to use, reproduce, modify, prepare
|
||||
derivative works of, publicly display, publicly perform, distribute, relicense
|
||||
(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
|
||||
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.
|
||||
|
||||
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,
|
||||
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
|
||||
the Maintainer equivalent rights — assign such rights to the Maintainer, so that
|
||||
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 obtains, as nearly as possible, the same rights as the exclusive
|
||||
license in Section 2.
|
||||
|
||||
## 4. Patent License
|
||||
|
||||
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
|
||||
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
|
||||
Project.
|
||||
|
||||
|
||||
@@ -160,6 +160,7 @@ Before modifying any subsystem, read its spec from `docs/systems/`. After making
|
||||
| [message-list.md](docs/systems/message-list.md) | Auto-scroll model, position memory (session-only), embed renderer dimension contract, known limitations | Touching MessageList.tsx, scroll behavior, embed renderers, position restore |
|
||||
| [deployment.md](docs/systems/deployment.md) | Hosting pipeline: Docker/Caddy build, admin bootstrap, DB backup/restore, image pinning, env vars | Any deploy, backup/restore, or hosting change |
|
||||
| [activity-presence.md](docs/systems/activity-presence.md) | Presence states, rich activities, activity types/priorities, broadcast pipeline, visibility control, ActivityCard/Panel | Presence, rich activities, activity display, status management |
|
||||
| [security-scanning.md](docs/systems/security-scanning.md) | CI security pipeline: Dependabot, CodeQL SAST, gitleaks, OSV-Scanner, Trivy (config/license; image scan in a later plan), OpenSSF Scorecard, SHA-pinning, harden-runner, tiered enforcement policy, maintainer settings checklist | Any CI security work, adding/changing scanners, enabling enforcement, supply-chain hardening |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+14
-7
@@ -2,7 +2,7 @@
|
||||
|
||||
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
|
||||
sizes are welcome — bug reports, fixes, features, documentation, and design.
|
||||
sizes are welcome: bug reports, fixes, features, documentation, and design.
|
||||
|
||||
## 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
|
||||
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
|
||||
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.
|
||||
@@ -36,11 +36,13 @@ Signing is automatic and takes one comment:
|
||||
> I have read the CLA Document and I hereby sign the CLA
|
||||
|
||||
4. The bot records your signature against your GitHub username. You only sign
|
||||
once — it covers all of your future contributions.
|
||||
once, and it covers all of your future contributions.
|
||||
|
||||
## 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
|
||||
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`.
|
||||
|
||||
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
|
||||
configuration. Text, federation, uploads, and everything else run fully without
|
||||
it.
|
||||
@@ -57,7 +64,7 @@ it.
|
||||
## Coding standards
|
||||
|
||||
- **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
|
||||
boundaries rather than introducing new ones.
|
||||
- **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
|
||||
behavior, and your environment (deployment method, browser/desktop, and whether
|
||||
federation or voice is involved). For security issues, please do **not** open a
|
||||
public issue — see [`SECURITY.md`](SECURITY.md).
|
||||
public issue. See [`SECURITY.md`](SECURITY.md).
|
||||
|
||||
## License
|
||||
|
||||
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.
|
||||
|
||||
+32
-6
@@ -5,7 +5,7 @@
|
||||
# Stage 1: Install dependencies and build frontend
|
||||
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
|
||||
|
||||
@@ -21,6 +21,15 @@ COPY packages/web/package.json packages/web/
|
||||
# Copy patches (referenced by pnpm-lock.yaml)
|
||||
COPY patches/ patches/
|
||||
|
||||
# better-sqlite3 publishes no prebuilt binary for Node 20 (ABI 115) — its
|
||||
# releases cover ABI 127/137/141/147 only — so prebuild-install falls back to
|
||||
# compiling with node-gyp, which needs python3/make/g++. node:20-slim ships
|
||||
# none of them. Builder stage only: the runtime stage copies the compiled
|
||||
# .node and stays slim.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 make g++ \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install dependencies
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
@@ -36,11 +45,14 @@ RUN pnpm --filter @backspace/web build
|
||||
# Stage 2: Production 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
|
||||
# Runtime deps only: ffmpeg (media processing) + gosu (drop to non-root in the
|
||||
# entrypoint). sharp is N-API (ABI-independent) and loads a prebuilt binary;
|
||||
# better-sqlite3 no longer ships one for Node 20, so it is compiled below with
|
||||
# a toolchain that is purged in the same layer.
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends python3 make g++ ffmpeg && \
|
||||
apt-get install -y --no-install-recommends ffmpeg gosu && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
@@ -57,8 +69,16 @@ COPY packages/web/package.json packages/web/
|
||||
# Copy patches (referenced by pnpm-lock.yaml)
|
||||
COPY patches/ patches/
|
||||
|
||||
# Install production dependencies only (tsx is in server dependencies)
|
||||
RUN pnpm install --prod --frozen-lockfile
|
||||
# Install production dependencies only (tsx is in server dependencies).
|
||||
# better-sqlite3 compiles from source here (no Node 20 prebuilt), so the C
|
||||
# toolchain is installed, used and purged inside this single layer — the final
|
||||
# image ships no compiler.
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends python3 make g++ && \
|
||||
pnpm install --prod --frozen-lockfile && \
|
||||
apt-get purge -y python3 make g++ && \
|
||||
apt-get autoremove -y && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy shared source (needed at runtime since server imports types directly)
|
||||
COPY packages/shared/ packages/shared/
|
||||
@@ -72,6 +92,11 @@ COPY --from=builder /app/packages/web/dist packages/web/dist
|
||||
# Create data directories
|
||||
RUN mkdir -p /app/data/uploads
|
||||
|
||||
# Non-root hardening: copy the privilege-dropping entrypoint. It chowns the
|
||||
# data volume as root, then execs the CMD as the unprivileged `node` user.
|
||||
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
# Set environment defaults
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
@@ -94,4 +119,5 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=5 \
|
||||
|
||||
# Run the server using tsx from the server package directory
|
||||
WORKDIR /app/packages/server
|
||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||
CMD ["node", "--import", "tsx/esm", "src/index.ts"]
|
||||
|
||||
@@ -4,52 +4,52 @@
|
||||
|
||||
# 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)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](https://nodejs.org/)
|
||||
[](https://nodejs.org/)
|
||||
[](#project-status)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
Backspace is a Discord-style chat platform you run on your own hardware. Spaces,
|
||||
channels, roles, voice and video, screen sharing, direct messages, friends, file
|
||||
sharing, and message search — plus **server-to-server federation**, so
|
||||
independent Backspace instances can talk to each other while each stays under
|
||||
its own control.
|
||||
Backspace is a self-hosted, open-source Discord alternative: a Discord-style chat
|
||||
platform you run on your own hardware. Spaces, channels, roles, voice and video,
|
||||
screen sharing, direct messages, friends, file sharing, and message search. On top
|
||||
of that, **server-to-server federation** lets independent Backspace instances talk
|
||||
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
|
||||
commercial license is available if the AGPL doesn't fit your use. See
|
||||
[License](#license) for the details.
|
||||
|
||||
> **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
|
||||
|
||||
Self-hosted chat usually forces a trade-off: gaming-grade voice and video, *or* a
|
||||
polished Discord-style experience, *or* federation between independent servers —
|
||||
rarely all three, and rarely with the fine-grained media controls people expect.
|
||||
polished Discord-style experience, *or* federation between independent servers.
|
||||
Rarely all three, and rarely with the fine-grained media controls people expect.
|
||||
|
||||
Backspace does all three at once:
|
||||
|
||||
- **Voice & video with a real control surface.** Not just "it has screen share":
|
||||
choose resolution, frame rate, codec (VP9 or hardware H.264), and bitrate; set
|
||||
independent 0–200% volume for every person *and* every screen-share; RNNoise
|
||||
noise suppression; a live connection inspector (bitrate, codec, ping, 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
|
||||
- **Voice and video with a real control surface.** This goes past a screen-share
|
||||
button. Choose resolution, frame rate, codec (VP9 or hardware H.264), and
|
||||
bitrate; set independent 0-200% volume for every person and every screen-share;
|
||||
RNNoise noise suppression; a live connection inspector (bitrate, codec, ping,
|
||||
packet loss, jitter); and a per-tile badge showing each stream's measured
|
||||
resolution and frame-rate. Screen sharing goes up to 4K/120fps within admin-set
|
||||
bounds.
|
||||
- **Federation, not a walled garden.** Run your own instance and peer it with
|
||||
others: cross-instance friends, DMs, calls, and presence — each server
|
||||
independently owned, requests HMAC-authenticated.
|
||||
- **A complete, polished platform — not a demo.** Role-based permissions with
|
||||
per-category and per-channel overrides, friends and group DMs, inline playable
|
||||
media, moderation with audit trails, search, a desktop app, and an installable
|
||||
mobile PWA — all in the warm, calm "Aether Drift" interface.
|
||||
others for cross-instance friends, DMs, calls, and presence. Each server stays
|
||||
independently owned, and requests are HMAC-authenticated.
|
||||
- **A complete platform, not a demo.** Role-based permissions with per-category
|
||||
and per-channel overrides, friends and group DMs, inline playable media,
|
||||
moderation with audit trails, search, a desktop app, and an installable mobile
|
||||
PWA, all in the warm, calm "Aether Drift" interface.
|
||||
|
||||
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" />
|
||||
|
||||
<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>
|
||||
|
||||
@@ -67,31 +67,31 @@ You own the server, the data, and the network it federates into.
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<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 width="50%" valign="top">
|
||||
<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>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<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 width="50%" valign="top">
|
||||
<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>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<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 width="50%" valign="top">
|
||||
<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>
|
||||
</tr>
|
||||
</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
|
||||
- Rich link embeds (YouTube, Vimeo, Spotify, and generic OpenGraph) with SSRF-protected scraping, plus GIF search (Klipy)
|
||||
- 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/)):
|
||||
- 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
|
||||
- Independent 0–200% volume for every participant *and* every screen-share
|
||||
**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
|
||||
- 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
|
||||
- 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
|
||||
- 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
|
||||
- Picture-in-Picture for voice and video
|
||||
|
||||
### Organization
|
||||
- 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
|
||||
- Space discovery (public, request-to-join, and private)
|
||||
- Shareable invite codes
|
||||
@@ -132,8 +132,8 @@ You own the server, the data, and the network it federates into.
|
||||
- Mutual friends and mutual spaces
|
||||
- User profiles with banner, bio, and accent color
|
||||
- Presence and rich activities (playing, listening, watching, streaming, custom)
|
||||
- Manual status — Online, Idle, or Do Not Disturb — with a custom status message
|
||||
- Privacy controls — toggle discoverability and activity-status sharing
|
||||
- Manual status (Online, Idle, or Do Not Disturb) with a custom status message
|
||||
- Privacy controls: toggle discoverability and activity-status sharing
|
||||
|
||||
### Moderation
|
||||
- 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
|
||||
- Multi-instance peering with HMAC-signed server-to-server requests
|
||||
- Federated identity resolution (`username@instance`)
|
||||
- Cross-instance DMs — messages, reactions, and membership relay
|
||||
- Cross-instance DMs: messages, reactions, and membership relay
|
||||
- Cross-instance friends and presence
|
||||
- File replication with size validation
|
||||
- Background workers for outbox delivery, file download, peer health, and cleanup
|
||||
@@ -152,26 +152,38 @@ You own the server, the data, and the network it federates into.
|
||||
### Platform
|
||||
- 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
|
||||
- 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
|
||||
- Electron desktop app (Windows, macOS, Linux) with global keybinds (push-to-talk, mute, deafen) and activity detection
|
||||
- Native desktop notifications and unread badge counts
|
||||
- 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)
|
||||
- Account management — password change and account deletion with safeguards
|
||||
- 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
|
||||
|
||||
## 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
|
||||
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
|
||||
|
||||
- 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
|
||||
HTTPS certificates automatically.
|
||||
- The ability to open the firewall ports in step 2.
|
||||
- A **domain name** for your instance. In the default All-in-One mode it must
|
||||
point at the host's public IP (Caddy obtains HTTPS certificates for it
|
||||
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
|
||||
|
||||
@@ -191,34 +203,34 @@ The installer walks you through everything interactively:
|
||||
|
||||
### 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 |
|
||||
|------|-------|------|---------|
|
||||
| `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) |
|
||||
| `3478` | UDP | If voice enabled | TURN — NAT traversal for WebRTC |
|
||||
| `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) |
|
||||
| `3478` | UDP | If voice enabled | TURN. NAT traversal for WebRTC |
|
||||
| `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) |
|
||||
|
||||
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 —
|
||||
it's reverse-proxied through Caddy on `443`, so you do **not** forward it.
|
||||
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.
|
||||
|
||||
> **Do this together with DNS, ideally before (or right after) running the
|
||||
> 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
|
||||
> **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.
|
||||
|
||||
### 3. Create your admin account
|
||||
|
||||
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`
|
||||
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
|
||||
internally, not that the certificate was issued.)
|
||||
|
||||
@@ -232,7 +244,7 @@ backup/restore and image-pinning guide.
|
||||
### Manual setup (advanced, optional)
|
||||
|
||||
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
|
||||
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` |
|
||||
| `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
|
||||
|
||||
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
|
||||
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
|
||||
```
|
||||
|
||||
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:
|
||||
|
||||
```bash
|
||||
@@ -287,19 +521,22 @@ The most important:
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|----------------------|----------|-------------|-------------|
|
||||
| `DOMAIN` | yes | — | Public domain name of your instance |
|
||||
| `JWT_SECRET` | yes | — | Auth signing secret, **min 32 chars** (`openssl rand -hex 32`) |
|
||||
| `DOMAIN` | yes | none | Public domain name of your instance |
|
||||
| `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) |
|
||||
| `HOST` | no | `0.0.0.0` | Bind address |
|
||||
| `REGISTRATION_OPEN` | no | `true` | Set `false` to close signups after setup |
|
||||
| `MAX_UPLOAD_SIZE` | no | `104857600` | Max upload size in bytes (100 MB) |
|
||||
| `LIVEKIT_URL` / `LIVEKIT_API_KEY` / `LIVEKIT_API_SECRET` | no | — | Enable voice/video |
|
||||
| `COMPOSE_PROFILES` | no | — | Set to `voice` to start the bundled LiveKit service |
|
||||
| `MAX_UPLOAD_SIZE` | no | `104857600` | Max upload size in bytes (100 MB; 90 MB in `tunnel` mode) |
|
||||
| `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 |
|
||||
| `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, 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
|
||||
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`,
|
||||
`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,
|
||||
search — works fully; only voice/video channels won't connect.
|
||||
Without LiveKit configured, everything else works fully (text, federation, DMs,
|
||||
uploads, search); only voice/video channels won't connect.
|
||||
|
||||
## Federation
|
||||
|
||||
@@ -336,11 +573,11 @@ Grab the installer for your platform from the
|
||||
|
||||
| Platform | File | Notes |
|
||||
|----------|------|-------|
|
||||
| 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. |
|
||||
| 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, 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. |
|
||||
|
||||
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`).
|
||||
|
||||
### Building from source
|
||||
@@ -357,14 +594,14 @@ Cross-platform builds are produced for Windows, macOS, and Linux. See
|
||||
|
||||
## 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
|
||||
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
|
||||
icon, no browser chrome, and an offline message queue that flushes when you
|
||||
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
|
||||
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/
|
||||
shared/ — Shared types, permission bits, constants
|
||||
server/ — Fastify API + WebSocket server, Drizzle/SQLite, federation
|
||||
web/ — React 18 SPA (Vite, Tailwind, Zustand)
|
||||
desktop/ — Electron wrapper
|
||||
shared/ - Shared types, permission bits, constants
|
||||
server/ - Fastify API + WebSocket server, Drizzle/SQLite, federation
|
||||
web/ - React 18 SPA (Vite, Tailwind, Zustand)
|
||||
desktop/ - Electron wrapper
|
||||
```
|
||||
|
||||
| Layer | Technology |
|
||||
@@ -392,26 +629,61 @@ packages/
|
||||
| Deployment | Docker Compose + Caddy (auto-HTTPS) |
|
||||
|
||||
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
|
||||
are the reference for how Backspace works**; start there if you want to
|
||||
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
|
||||
|
||||
Contributions are welcome. Please read [`CONTRIBUTING.md`](CONTRIBUTING.md)
|
||||
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
|
||||
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
|
||||
wrote in your own other projects.
|
||||
|
||||
## Security
|
||||
|
||||
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
|
||||
disclosure.
|
||||
|
||||
@@ -422,23 +694,23 @@ Backspace is **free and open source software**, licensed under the
|
||||
|
||||
In plain terms:
|
||||
|
||||
- ✅ 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.
|
||||
- ⚠️ 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 —
|
||||
- Yes: self-host, run, study, and modify it, including commercially and inside a business.
|
||||
- Yes: redistribute it and your changes under the same AGPL-3.0 license.
|
||||
- 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:
|
||||
set `BACKSPACE_SOURCE_URL` to your fork so the in-app "Source code" link points
|
||||
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
|
||||
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
|
||||
[`LICENSE-COMMERCIAL.md`](LICENSE-COMMERCIAL.md).
|
||||
|
||||
> **Our open-source commitment.** Every released version of Backspace is, and
|
||||
> 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
|
||||
> the open-source edition is relicensed under non-free terms, the community stays
|
||||
> free to fork the last AGPL release.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 222 KiB |
@@ -32,6 +32,16 @@ PI_PATH="~/backspace"
|
||||
BETA_HOST="orbit.ddns.net"
|
||||
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 ──────────────────────────────────────────
|
||||
|
||||
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:
|
||||
# ── Backspace application server ──────────────────────────
|
||||
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:
|
||||
context: .
|
||||
args:
|
||||
@@ -27,6 +35,12 @@ services:
|
||||
- NODE_ENV=production
|
||||
- DB_PATH=/app/data/backspace.db
|
||||
- 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:
|
||||
- internal
|
||||
healthcheck:
|
||||
|
||||
Executable
+18
@@ -0,0 +1,18 @@
|
||||
#!/bin/sh
|
||||
# Runs as root: make the (bind-mounted, host-owned) data dir writable by the
|
||||
# non-root `node` user, then drop privileges via gosu and exec the CMD. This
|
||||
# lets the container run as uid 1000 while still owning ./data on hosts where
|
||||
# the bind mount was created by a different uid.
|
||||
#
|
||||
# - Idempotent AND cheap: only chown entries not already node-owned, so after
|
||||
# the first boot this is near-instant. A plain `chown -R` over a large
|
||||
# uploads/ tree on slow Pi/SD storage would delay startup on EVERY restart.
|
||||
# - Non-fatal: on a bind mount that rejects chown (some CIFS/NFS backings),
|
||||
# warn and continue rather than crash-looping under `restart: unless-stopped`
|
||||
# (the old root container booted fine on such mounts).
|
||||
set -e
|
||||
mkdir -p /app/data/uploads
|
||||
chown node:node /app/data /app/data/uploads 2>/dev/null || true
|
||||
find /app/data ! -user node -exec chown node:node {} + 2>/dev/null || \
|
||||
echo "docker-entrypoint: warning: could not chown /app/data; continuing (ensure it is writable by uid 1000)"
|
||||
exec gosu node "$@"
|
||||
@@ -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,79 @@
|
||||
# Roadmap — fork Resenha
|
||||
|
||||
Plano de features próprias desta instância. Arquivo exclusivo do fork
|
||||
(nome com sufixo para não colidir com arquivos do upstream em merges).
|
||||
|
||||
Escrito em português por ser documento de planejamento do dono do fork; o
|
||||
código e os commits seguem em inglês, como o resto do repositório.
|
||||
|
||||
## Entregue
|
||||
|
||||
| Feature | Commit | Nota |
|
||||
|---|---|---|
|
||||
| Ir para a call clicando no nome do canal | `c70b0095` | Exigiu o `voiceStore` passar a guardar o espaço da call — antes ele não sabia onde a call estava assim que o usuário navegava para outro servidor |
|
||||
| Botão de GIF redesenhado | `20526e1b` | Contorno vazado com letras cheias, no lugar do bloco sólido |
|
||||
| Explorador de GIF no banner | `20526e1b` | Sem upload: banner já aceita URL absoluta no cliente e no servidor |
|
||||
| Teste de microfone com retorno | `bfe62d70` | `AudioManager.startMicTest/stopMicTest`; devolve o microfone ao parar, com duas travas independentes |
|
||||
| Bloco de atividade no perfil | `d7da0ff2` | `ProfileActivity`; inclui correção de validação de assets no servidor |
|
||||
| Preview de perfil nos participantes da call | (ver git log) | O popout já existia e era aberto de 11 lugares; **nenhum era de voz**. Ligado nas linhas da lista de voz e no nome dos tiles da grade |
|
||||
|
||||
## Já existia no código (verificado, não construir de novo)
|
||||
|
||||
- **Animação de digitação** — `TypingIndicator.tsx`, três pontos `animate-bounce`
|
||||
escalonados em 0/150/300ms.
|
||||
- **Sons de call/stream** — `SoundController.tsx`, montado no `AppLayout`:
|
||||
stream started/ended, alguém entra/sai da tela, entra/sai da call, câmera,
|
||||
mute, ringing. Os `.ogg` estão em `web/public/sounds/`.
|
||||
- **Preview de perfil ancorado** — `uiStore.openUserProfile(user, anchor, placement)`
|
||||
guarda `userProfilePopout`; popout posicionado no desktop, tela cheia no
|
||||
mobile. Já era aberto por mensagens, menções, avatares, lista de membros,
|
||||
DMs e painel de atividade. O que faltava era só a voz — agora ligado.
|
||||
Continua faltando o bloco de atividade do print do Discord, que é a #8.
|
||||
|
||||
Se qualquer um dos dois não se manifestar em uso, o trabalho é **depuração**,
|
||||
não implementação.
|
||||
|
||||
## Pendente — pedido original
|
||||
|
||||
Ordem sugerida: as pequenas primeiro, as grandes uma de cada vez.
|
||||
|
||||
| # | Feature | Tamanho | Observação técnica |
|
||||
|---|---|---|---|
|
||||
| 6 | Favoritar GIFs + categorias | Grande | Precisa de tabela, migração drizzle e API para sincronizar entre dispositivos, como no Discord |
|
||||
| 8 | **Produtor** de atividade do Spotify | Grande | O consumo está pronto (`ProfileActivity` + pipeline completo). Falta algo que *gere* a atividade com faixa e artista — ver abaixo |
|
||||
| 9 | Registro de auditoria | Grande | Schema + ganchos em cada mutação do servidor + interface |
|
||||
|
||||
## Pendente — ideias aprovadas
|
||||
|
||||
| Feature | Tamanho | Observação técnica |
|
||||
|---|---|---|
|
||||
| Soundboard | Média | `AudioManager` já carrega e toca `.ogg` sob demanda; falta upload por espaço, permissão e disparo na sala LiveKit |
|
||||
| Estatísticas do grupo | Grande | Horas em call, quem mais falou, ranking. **Depende do mesmo registro de eventos da auditoria (#9)** |
|
||||
| Watch party | Grande | O screen share do LiveKit já existe; falta sincronizar posição de reprodução entre participantes |
|
||||
| Emojis e stickers do grupo | Média | `UPLOAD_DIR` e o pipeline de upload já existem; falta tabela por espaço e resolução no render de mensagem |
|
||||
|
||||
## O que falta para o Spotify (#8)
|
||||
|
||||
O caminho de consumo está inteiro: tipo, store, WebSocket, validação no
|
||||
servidor, relay de presença e agora o bloco no perfil. **Falta um produtor.**
|
||||
|
||||
Três opções, com custos bem diferentes:
|
||||
|
||||
1. **Entrada no dicionário do detector** (`activityDetector.ts` lê um JSON de
|
||||
processos, e `listening` já é um tipo válido). Custo quase zero, mas dá
|
||||
apenas "Listening to Spotify" — sem faixa nem artista — e **só no app
|
||||
Electron**.
|
||||
2. **Ler o título da janela do Spotify** no processo main do Electron. O título
|
||||
é "Artista - Faixa", então preenche `details` e `state`. Ainda só desktop, e
|
||||
sem capa nem duração.
|
||||
3. **Spotify Web API com OAuth.** É a única que cobre quem usa pelo navegador —
|
||||
que é a maioria do grupo — e a única que traz capa e progresso.
|
||||
**Bloqueio:** exige registrar um app no dashboard do Spotify e obter
|
||||
client id/secret. Isso é ação sua; eu não consigo fazer.
|
||||
|
||||
## Dependência que vale respeitar
|
||||
|
||||
**Auditoria (#9) e Estatísticas compartilham o mesmo mecanismo**: uma tabela de
|
||||
eventos append-only no servidor. Construir a auditoria primeiro e as
|
||||
estatísticas como leitura agregada dessa mesma tabela evita escrever dois
|
||||
sistemas de registro paralelos que divergem com o tempo.
|
||||
@@ -0,0 +1,645 @@
|
||||
# Plan A — Scanning Pipeline & Supply-Chain Hardening (report-only) Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Stand up the full automated security-scanning pipeline (Dependabot, CodeQL SAST, secret scanning, dependency CVEs, IaC/license scanning, OpenSSF Scorecard) plus supply-chain hardening (SHA-pinned actions, harden-runner, least-privilege permissions) on GitHub Actions — all **report-only/advisory**, so the PR that adds it stays green and mergeable.
|
||||
|
||||
**Architecture:** Four new files under `.github/` (one Dependabot config + three workflows) each with a single scan responsibility, plus a hardening sweep across the four existing workflows. Every scanner uploads SARIF to the GitHub Security tab and is non-blocking in this plan; enforcement (fail-the-build) is flipped on in a later plan (Plan E) after the remediation pass. This is the foundation the rest of the initiative builds on and, on its own, answers the "no security scanning" objection with visibly-running scanners.
|
||||
|
||||
**Tech Stack:** GitHub Actions (YAML), GitHub Dependabot, CodeQL (`javascript-typescript`, build-mode `none`), gitleaks, OSV-Scanner (reads `pnpm-lock.yaml` v9), Aqua Trivy (config + license), OpenSSF Scorecard, StepSecurity harden-runner. Local validators: `actionlint`, `pinact` (SHA-pinning).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **Report-only in this plan.** Every scanner must be non-blocking (`continue-on-error: true` at step level, or advisory SARIF upload). Enforcement is flipped on in Plan E — do NOT make any scanner fail the build here. Each non-blocking step carries a comment: `# report-only; enforcement flipped on in Plan E`.
|
||||
- **SHA-pin every action.** All `uses:` refs across ALL workflows (new and existing) pin to a full 40-char commit SHA with a trailing `# vX.Y.Z` version comment. No `@v5`/`@main` tag refs may remain after Task 5.
|
||||
- **harden-runner is Linux-only.** `step-security/harden-runner` runs only on Ubuntu runners. In any matrix that includes macOS/Windows (i.e. `release.yml`), guard it with `if: runner.os == 'Linux'`.
|
||||
- **`egress-policy: audit`** for every harden-runner step (never `block` in this plan — multi-arch buildx/QEMU/gha-cache make many egress calls).
|
||||
- **Commit identity:** the repo's local git config already uses `Jannis Braun <151788261+TheZwiss@users.noreply.github.com>` — use a plain `git commit`. NEVER override author/committer email with `-c user.email=...`, and never commit as `alxtrading94@gmail.com`.
|
||||
- **No new runtime dependencies.** This plan touches only `.github/` and docs; it must not modify `package.json` dependency lists or any application/runtime code.
|
||||
- **Node 20 / pnpm 10.34.3** are the project's pinned toolchain — any workflow that installs deps mirrors `ci.yml` (`pnpm/action-setup` @ 10.34.3, `actions/setup-node` node 20).
|
||||
- **Branch:** all work lands on `security-scanning-hardening` (already checked out).
|
||||
- **Action versions:** the YAML below uses each action's current major tag. If an action's latest major differs at implementation time, check its README and adjust the tag — then Task 5 pins whatever tag you used to its SHA. A wrong tag surfaces as an `actionlint` error or a red PR check; fix and re-run.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Dependabot config + local validators
|
||||
|
||||
**Files:**
|
||||
- Create: `.github/dependabot.yml`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing (first task).
|
||||
- Produces: `.github/dependabot.yml` — the Dependabot v2 config later documented by Task 6. No code symbols.
|
||||
|
||||
- [ ] **Step 1: Install the local validators**
|
||||
|
||||
`actionlint` validates workflow YAML; `pinact` will SHA-pin actions in Task 5. On the macOS dev host:
|
||||
|
||||
Run:
|
||||
```bash
|
||||
brew install actionlint pinact
|
||||
actionlint --version && pinact --version
|
||||
```
|
||||
Expected: both print a version. (Fallbacks if Homebrew lacks them: `go install github.com/rhysd/actionlint/cmd/actionlint@latest` and `go install github.com/suzuki-shunsuke/pinact/cmd/pinact@latest`, or run actionlint via Docker `docker run --rm -v "$(pwd):/repo" --workdir /repo rhysd/actionlint:latest -color`.)
|
||||
|
||||
- [ ] **Step 2: Write `.github/dependabot.yml`**
|
||||
|
||||
```yaml
|
||||
# Dependabot keeps dependencies and CI actions patched. Three ecosystems:
|
||||
# - npm → the pnpm workspace (Dependabot reads pnpm-lock.yaml v9)
|
||||
# - github-actions → action version bumps (feeds the SHA-pin comments)
|
||||
# - docker → the Dockerfile base image (FROM node:20-slim)
|
||||
#
|
||||
# NOTE (intentional): there is NO docker entry for docker-compose.yml. It sits
|
||||
# at the same "/" directory (a second docker entry would collide on
|
||||
# ecosystem+directory), and Dependabot's docker ecosystem parses Dockerfiles,
|
||||
# not `image:` refs in compose. The pinned caddy / livekit-server compose images
|
||||
# are updated MANUALLY — see the maintainer checklist in
|
||||
# docs/systems/security-scanning.md.
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: npm
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 10
|
||||
groups:
|
||||
# One grouped PR for routine minor/patch bumps to cut PR noise.
|
||||
npm-minor-patch:
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
ignore:
|
||||
# uiohook-napi is pinned by an exact-version pnpm patch
|
||||
# (patches/uiohook-napi@1.5.5.patch). A bump makes the patch path stop
|
||||
# matching, breaking `pnpm install --frozen-lockfile` in CI and both
|
||||
# Docker stages until the patch is regenerated. Bump it by hand.
|
||||
- dependency-name: uiohook-napi
|
||||
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
groups:
|
||||
github-actions:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
- package-ecosystem: docker
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Validate YAML syntax**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
python3 -c "import yaml,sys; yaml.safe_load(open('.github/dependabot.yml')); print('dependabot.yml: valid YAML')"
|
||||
```
|
||||
Expected: `dependabot.yml: valid YAML` (no traceback). (The full schema is validated by GitHub after push — Task 7 confirms it in the repo's Insights → Dependency graph → Dependabot.)
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add .github/dependabot.yml
|
||||
git commit -m "ci(security): add Dependabot config (npm + actions + docker)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: `security.yml` — secret, dependency, IaC & license scanning (report-only)
|
||||
|
||||
**Files:**
|
||||
- Create: `.github/workflows/security.yml`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `pnpm-lock.yaml` (OSV lockfile scan), repo tree (gitleaks history, Trivy config/license).
|
||||
- Produces: workflow `Security` with jobs `gitleaks`, `osv-scanner`, `trivy-config`, `trivy-license`; each uploads a SARIF category (`gitleaks`, `osv-scanner`, `trivy-config`, `trivy-license`). Task 6 documents these; Task 5 pins their actions.
|
||||
|
||||
- [ ] **Step 1: Write `.github/workflows/security.yml`**
|
||||
|
||||
```yaml
|
||||
name: Security
|
||||
|
||||
# Report-only in this plan: every scanner is non-blocking and uploads SARIF to
|
||||
# the Security tab. Enforcement (fail on fixable HIGH/CRITICAL, block on secrets)
|
||||
# is flipped on in Plan E after the remediation pass.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: '32 5 * * 1' # weekly Monday 05:32 UTC
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: security-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
gitleaks:
|
||||
name: Secret scan (gitleaks)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden the runner
|
||||
uses: step-security/harden-runner@v2
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout (full history)
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0 # gitleaks scans the whole git history, not just the diff
|
||||
- name: Run gitleaks
|
||||
uses: gitleaks/gitleaks-action@v2
|
||||
continue-on-error: true # report-only; enforcement flipped on in Plan E
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
osv-scanner:
|
||||
name: Dependency scan (OSV-Scanner)
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write # upload SARIF to code scanning
|
||||
steps:
|
||||
- name: Harden the runner
|
||||
uses: step-security/harden-runner@v2
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
- name: Run OSV-Scanner
|
||||
uses: google/osv-scanner-action@v2
|
||||
continue-on-error: true # report-only; enforcement flipped on in Plan E
|
||||
with:
|
||||
scan-args: |-
|
||||
--lockfile=./pnpm-lock.yaml
|
||||
--format=sarif
|
||||
--output=osv-results.sarif
|
||||
- name: Upload OSV SARIF
|
||||
if: always()
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
with:
|
||||
sarif_file: osv-results.sarif
|
||||
category: osv-scanner
|
||||
|
||||
trivy-config:
|
||||
name: IaC/config scan (Trivy)
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
steps:
|
||||
- name: Harden the runner
|
||||
uses: step-security/harden-runner@v2
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
- name: Trivy config scan (Dockerfile + docker-compose)
|
||||
uses: aquasecurity/trivy-action@0.28.0
|
||||
continue-on-error: true # report-only; enforcement flipped on in Plan E
|
||||
with:
|
||||
scan-type: config
|
||||
scan-ref: .
|
||||
format: sarif
|
||||
output: trivy-config.sarif
|
||||
- name: Upload Trivy config SARIF
|
||||
if: always()
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
with:
|
||||
sarif_file: trivy-config.sarif
|
||||
category: trivy-config
|
||||
|
||||
trivy-license:
|
||||
name: License compliance scan (Trivy)
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
steps:
|
||||
- name: Harden the runner
|
||||
uses: step-security/harden-runner@v2
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
- name: Trivy license scan
|
||||
uses: aquasecurity/trivy-action@0.28.0
|
||||
continue-on-error: true # report-only; enforcement flipped on in Plan E
|
||||
with:
|
||||
scan-type: fs
|
||||
scan-ref: .
|
||||
scanners: license
|
||||
format: sarif
|
||||
output: trivy-license.sarif
|
||||
- name: Upload Trivy license SARIF
|
||||
if: always()
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
with:
|
||||
sarif_file: trivy-license.sarif
|
||||
category: trivy-license
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Validate with actionlint**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
actionlint .github/workflows/security.yml
|
||||
```
|
||||
Expected: no output (exit 0). If actionlint flags an unknown input for an action, check that action's README and correct it. (Note: actionlint does not fetch action inputs, so most such errors are shellcheck/expression issues — fix those.)
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add .github/workflows/security.yml
|
||||
git commit -m "ci(security): add report-only security scan workflow (gitleaks, OSV, Trivy)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: `codeql.yml` — CodeQL SAST (report-only)
|
||||
|
||||
**Files:**
|
||||
- Create: `.github/workflows/codeql.yml`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: repo TypeScript/JavaScript source (analyzed with `build-mode: none`).
|
||||
- Produces: workflow `CodeQL` with job `analyze`, category `/language:javascript-typescript`. Findings land in the Security tab. Task 6 documents it; Task 5 pins its actions.
|
||||
|
||||
- [ ] **Step 1: Write `.github/workflows/codeql.yml`**
|
||||
|
||||
```yaml
|
||||
name: CodeQL
|
||||
|
||||
# Static application security testing for all TS/JS. Uses build-mode: none — no
|
||||
# compile needed, which sidesteps the monorepo/native-module build entirely.
|
||||
# Default (code-scanning) query suite; security-extended is deferred (triage tax).
|
||||
# CodeQL uploads alerts to the Security tab but does NOT fail the PR by itself —
|
||||
# blocking is a repo setting (code-scanning merge protection), documented in the
|
||||
# maintainer checklist in docs/systems/security-scanning.md.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: '27 3 * * 1' # weekly Monday 03:27 UTC
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: codeql-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze (javascript-typescript)
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write # upload SARIF to code scanning
|
||||
actions: read
|
||||
steps:
|
||||
- name: Harden the runner
|
||||
uses: step-security/harden-runner@v2
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
build-mode: none
|
||||
- name: Perform CodeQL analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
with:
|
||||
category: "/language:javascript-typescript"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Validate with actionlint**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
actionlint .github/workflows/codeql.yml
|
||||
```
|
||||
Expected: no output (exit 0).
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add .github/workflows/codeql.yml
|
||||
git commit -m "ci(security): add CodeQL SAST workflow (javascript-typescript)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: `scorecard.yml` — OpenSSF Scorecard (report-only)
|
||||
|
||||
**Files:**
|
||||
- Create: `.github/workflows/scorecard.yml`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: the whole repo + workflow metadata (Scorecard evaluates repo posture).
|
||||
- Produces: workflow `OpenSSF Scorecard` with job `analysis`; publishes results (feeds the public badge added in Plan E) and uploads SARIF. Task 5 pins its actions.
|
||||
|
||||
- [ ] **Step 1: Write `.github/workflows/scorecard.yml`**
|
||||
|
||||
```yaml
|
||||
name: OpenSSF Scorecard
|
||||
|
||||
# Scores the repo's security posture (branch protection, pinned deps, token
|
||||
# permissions, etc.) and publishes to the OpenSSF public API so a badge can be
|
||||
# shown (badge is added in Plan E). REQUIRES the canonical repo to be PUBLIC —
|
||||
# see the maintainer checklist in docs/systems/security-scanning.md.
|
||||
|
||||
on:
|
||||
branch_protection_rule:
|
||||
schedule:
|
||||
- cron: '18 4 * * 2' # weekly Tuesday 04:18 UTC
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
analysis:
|
||||
name: Scorecard analysis
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
security-events: write # upload SARIF
|
||||
id-token: write # publish_results OIDC attestation
|
||||
steps:
|
||||
- name: Harden the runner
|
||||
uses: step-security/harden-runner@v2
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Run Scorecard
|
||||
uses: ossf/scorecard-action@v2
|
||||
with:
|
||||
results_file: results.sarif
|
||||
results_format: sarif
|
||||
publish_results: true
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: SARIF file
|
||||
path: results.sarif
|
||||
retention-days: 5
|
||||
- name: Upload SARIF to code scanning
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Validate with actionlint**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
actionlint .github/workflows/scorecard.yml
|
||||
```
|
||||
Expected: no output (exit 0).
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add .github/workflows/scorecard.yml
|
||||
git commit -m "ci(security): add OpenSSF Scorecard workflow"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Harden existing workflows + SHA-pin every action
|
||||
|
||||
**Files:**
|
||||
- Modify: `.github/workflows/ci.yml` (add harden-runner step)
|
||||
- Modify: `.github/workflows/release.yml` (add Linux-guarded harden-runner step)
|
||||
- Modify: `.github/workflows/security.yml`, `codeql.yml`, `scorecard.yml`, `ci.yml`, `release.yml`, `cla.yml`, `deploy-pages.yml`, `docker-publish.yml` (SHA-pin all `uses:`)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: all workflow files from Tasks 2-4 plus the four pre-existing ones.
|
||||
- Produces: every `uses:` pinned to `@<40-char-sha> # vX.Y.Z`; harden-runner (audit) on the two build workflows. No code symbols.
|
||||
|
||||
- [ ] **Step 1: Add harden-runner to `ci.yml`**
|
||||
|
||||
In `.github/workflows/ci.yml`, insert as the FIRST step of the `build-and-test` job (before `Checkout`):
|
||||
|
||||
```yaml
|
||||
- name: Harden the runner
|
||||
uses: step-security/harden-runner@v2
|
||||
with:
|
||||
egress-policy: audit
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add Linux-guarded harden-runner to `release.yml`**
|
||||
|
||||
In `.github/workflows/release.yml`, insert as the FIRST step of the `build` matrix job (before `Checkout`). It MUST be guarded — the matrix includes macOS and Windows, where harden-runner does not run:
|
||||
|
||||
```yaml
|
||||
- name: Harden the runner
|
||||
if: runner.os == 'Linux'
|
||||
uses: step-security/harden-runner@v2
|
||||
with:
|
||||
egress-policy: audit
|
||||
```
|
||||
|
||||
- [ ] **Step 3: SHA-pin every action across all workflows**
|
||||
|
||||
Run `pinact` from the repo root — it rewrites each `uses: owner/repo@vX` to `uses: owner/repo@<sha> # vX` in place:
|
||||
|
||||
```bash
|
||||
pinact run
|
||||
```
|
||||
|
||||
Manual fallback (if `pinact` is unavailable) — resolve each tag to its commit SHA with `gh` and edit by hand. `repos/{repo}/commits/{ref}` dereferences both lightweight and annotated tags to the commit:
|
||||
|
||||
```bash
|
||||
# Example for one action; repeat for every distinct uses: ref.
|
||||
gh api repos/actions/checkout/commits/v5 --jq '.sha'
|
||||
# → paste as: uses: actions/checkout@<sha> # v5
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify no unpinned action refs remain**
|
||||
|
||||
Run (flags any `uses:` ref NOT pinned to a 40-hex-char SHA — catches both `@v5` and non-`v` semver tags like Trivy's `@0.28.0`):
|
||||
```bash
|
||||
grep -rnE 'uses: +[^ ]+@' .github/workflows/ | grep -vE '@[0-9a-f]{40}' && echo "UNPINNED REFS FOUND (fix above)" || echo "All actions pinned to SHA"
|
||||
```
|
||||
Expected: `All actions pinned to SHA` (the second grep exits non-zero when nothing is unpinned, so the `||` branch prints). A properly pinned line contains `@<40-hex> # vX.Y.Z` and is filtered out; any surviving line is an unpinned ref to fix.
|
||||
|
||||
- [ ] **Step 5: Re-validate all workflows**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
actionlint
|
||||
```
|
||||
Expected: no output (exit 0) — actionlint scans every file in `.github/workflows/`.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add .github/workflows/
|
||||
git commit -m "ci(security): SHA-pin all actions and add harden-runner (audit)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Document the pipeline
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/systems/security-scanning.md`
|
||||
- Modify: `CLAUDE.md` (add a subsystem-table row)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: the workflows/config from Tasks 1-5 (documents them).
|
||||
- Produces: the `security-scanning.md` spec + maintainer checklist referenced by every workflow comment; a CLAUDE.md table row. No code symbols.
|
||||
|
||||
- [ ] **Step 1: Write `docs/systems/security-scanning.md`**
|
||||
|
||||
```markdown
|
||||
# Security Scanning & Supply-Chain Assurance
|
||||
|
||||
Automated, continuous scanning wired into GitHub Actions. This document is the
|
||||
reference for what runs, where results go, and the one-time settings a maintainer
|
||||
must enable. **Current state: report-only** — scanners surface findings in the
|
||||
Security tab but do not block merges yet. Enforcement (blocking) is turned on in a
|
||||
later change once the remediation pass has cleared the backlog.
|
||||
|
||||
## Workflows
|
||||
|
||||
| File | Purpose | Trigger | Result |
|
||||
|------|---------|---------|--------|
|
||||
| `.github/dependabot.yml` | Dependency + action + base-image update PRs | weekly | PRs |
|
||||
| `.github/workflows/codeql.yml` | CodeQL SAST (`javascript-typescript`, build-mode none) | PR + push main + weekly | Security tab |
|
||||
| `.github/workflows/security.yml` | gitleaks (secrets, full history), OSV-Scanner (deps), Trivy config (IaC), Trivy license | PR + push main + weekly | Security tab |
|
||||
| `.github/workflows/scorecard.yml` | OpenSSF Scorecard (repo posture) | push main + weekly | Security tab + public badge |
|
||||
|
||||
## Tiered policy (target, enforced in a later change)
|
||||
|
||||
- **Always block:** gitleaks secret hit; OSV/Trivy fixable HIGH/CRITICAL; Trivy
|
||||
disallowed license.
|
||||
- **Advisory (SARIF → Security tab):** CodeQL alerts; OSV/Trivy unfixable or
|
||||
medium/low; Scorecard.
|
||||
|
||||
Code-level gates (OSV, Trivy, gitleaks) block via workflow exit codes. CodeQL
|
||||
merge-blocking, Dependabot alerts, and native secret-scanning are GitHub *settings*
|
||||
— see the checklist below.
|
||||
|
||||
## Supply-chain hardening
|
||||
|
||||
- Every action is pinned to a full commit SHA (`# vX.Y.Z` comment) — resists
|
||||
tag-move attacks and satisfies Scorecard's Pinned-Dependencies check.
|
||||
- `step-security/harden-runner` (egress-policy `audit`) on Linux jobs.
|
||||
- Least-privilege `permissions:` per workflow/job.
|
||||
- SBOM + SLSA provenance are attached to the published container image (added with
|
||||
the image-scan work).
|
||||
|
||||
## Maintainer checklist (one-time GitHub settings — NOT code)
|
||||
|
||||
- [ ] Repository must be **public** (required for the Scorecard badge/publish and
|
||||
the CodeQL free tier).
|
||||
- [ ] Settings → Code security: enable **Dependabot alerts** and **Dependabot
|
||||
security updates**.
|
||||
- [ ] Settings → Code security: enable **Secret scanning** + **Push protection**.
|
||||
- [ ] Settings → Code security: enable **CodeQL / code-scanning merge protection**
|
||||
so high-severity alerts block PRs (the code-level gates do the rest).
|
||||
- [ ] Branch protection on `main`: require the CI + security status checks to pass.
|
||||
- [ ] **Manual image bumps:** Dependabot does not track `docker-compose.yml`
|
||||
`image:` pins — update `caddy` and `livekit/livekit-server` by hand when new
|
||||
releases ship. (Renovate, which parses compose, is an optional future
|
||||
alternative.)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the CLAUDE.md subsystem-table row**
|
||||
|
||||
In `CLAUDE.md`, inside the "Subsystem Documentation" table (the block of `| File | Contents | Read when... |` rows), add:
|
||||
|
||||
```markdown
|
||||
| [security-scanning.md](docs/systems/security-scanning.md) | CI security pipeline: Dependabot, CodeQL SAST, gitleaks, OSV-Scanner, Trivy (config/image/license), OpenSSF Scorecard, SHA-pinning, harden-runner, tiered enforcement policy, maintainer settings checklist | Any CI security work, adding/changing scanners, enabling enforcement, supply-chain hardening |
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify the doc links resolve**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
test -f docs/systems/security-scanning.md && grep -q 'security-scanning.md' CLAUDE.md && echo "doc + CLAUDE.md row present"
|
||||
```
|
||||
Expected: `doc + CLAUDE.md row present`.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/systems/security-scanning.md CLAUDE.md
|
||||
git commit -m "docs(security): document the scanning pipeline + maintainer checklist"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Open the PR and verify the pipeline runs
|
||||
|
||||
**Files:** none (verification only).
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: everything from Tasks 1-6, pushed to GitHub.
|
||||
- Produces: a PR with all scanners running green/advisory — the acceptance gate for Plan A.
|
||||
|
||||
- [ ] **Step 1: Push the branch**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
git push -u origin security-scanning-hardening
|
||||
```
|
||||
Expected: branch pushed; GitHub prints a PR-create URL.
|
||||
|
||||
- [ ] **Step 2: Open a PR**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
gh pr create --fill --base main --head security-scanning-hardening \
|
||||
--title "Security scanning pipeline (report-only)" \
|
||||
--body "Adds Dependabot, CodeQL, gitleaks, OSV-Scanner, Trivy (config/license), OpenSSF Scorecard, SHA-pinned actions, and harden-runner. All scanners are report-only; enforcement is flipped on in a later change. See docs/systems/security-scanning.md."
|
||||
```
|
||||
Expected: prints the PR URL.
|
||||
|
||||
- [ ] **Step 3: Watch the checks**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
gh pr checks --watch
|
||||
```
|
||||
Expected: `CI / Build & test` passes; `CodeQL`, `Security` (gitleaks/osv/trivy jobs), and `OpenSSF Scorecard` all complete. Because every scanner is `continue-on-error`/advisory, **no scanner may report a failing (red) required check** — a scanner surfacing findings is fine, but the job itself should not fail the PR. If a job fails for a non-finding reason (bad action input, missing permission), fix the workflow and push.
|
||||
|
||||
- [ ] **Step 4: Confirm SARIF + Dependabot registration**
|
||||
|
||||
Verify in the GitHub UI (or note as maintainer follow-up if Actions/security features aren't enabled yet):
|
||||
- Security → Code scanning: alerts appear under categories `codeql`, `osv-scanner`, `trivy-config`, `trivy-license`, and Scorecard.
|
||||
- Insights → Dependency graph → Dependabot: the three ecosystems (npm, github-actions, docker) are listed as configured.
|
||||
|
||||
Run (CLI cross-check of code-scanning analyses, if the repo is public with Actions enabled):
|
||||
```bash
|
||||
gh api repos/:owner/:repo/code-scanning/analyses --jq '[.[].category] | unique' 2>/dev/null || echo "code-scanning API not available yet (enable in Settings)"
|
||||
```
|
||||
Expected: a list including the scanner categories, or the fallback message (then it's a maintainer-settings follow-up, not a plan defect).
|
||||
|
||||
- [ ] **Step 5: Record verification outcome**
|
||||
|
||||
No commit. Note in the PR description (or a comment) which checks passed and any settings follow-ups (from the Task 6 maintainer checklist) still pending. Plan A is complete when the PR is green with all four scanners running advisory.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Notes
|
||||
|
||||
- **Spec coverage (WS1 + supply-chain):** Dependabot (Task 1) ✓; CodeQL (Task 3) ✓; gitleaks + OSV + Trivy config + Trivy license (Task 2) ✓; Scorecard (Task 4) ✓; SHA-pinning + harden-runner (audit) + least-priv permissions across all workflows (Task 5) ✓; docs + maintainer checklist + CLAUDE.md row (Task 6) ✓; report-only sequencing honored throughout (Global Constraints + per-step comments) ✓.
|
||||
- **Deferred by design (other plans, not gaps):** container image scan + SBOM/provenance + docker-publish restructure → Plan B; helmet/CSP/CORS + DAST → Plan C; Electron hardening → Plan D; remediation of findings + enforcement flip + README badges → Plan E. Task 5's SHA-pin sweep does include `docker-publish.yml` (harmless; Plan B re-pins as it restructures).
|
||||
- **Enforcement stays OFF here** — every scanner is `continue-on-error`/advisory; no `fail-on`/severity gate is set in this plan. The maintainer-settings toggles (CodeQL merge protection, push protection, branch protection) are documented, not enabled in code.
|
||||
@@ -0,0 +1,360 @@
|
||||
# Plan B — Container Hardening & Real Image Scanning Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Harden the published container image — run it as a non-root user, slim its runtime attack surface, and scan the actual amd64 image for OS/library CVEs before publishing — without breaking existing self-hosters or the multi-arch (amd64+arm64) GHCR publish.
|
||||
|
||||
**Architecture:** Two edits to the runtime layer (`Dockerfile` + a new `docker-entrypoint.sh`) plus a restructure of `docker-publish.yml` so a single-arch amd64 image is built and Trivy-scanned before the multi-arch push. The image scan is **report-only** here (matching Plan A's sequencing); Plan E flips it to blocking. SBOM + SLSA provenance are attached at push time.
|
||||
|
||||
**Tech Stack:** Docker multi-stage build (`node:20-slim`), Docker Buildx + QEMU, GitHub Actions, Aqua Trivy (image + SARIF), `gosu` for privilege drop, better-sqlite3 (prebuilt binary), tsx (runtime TS loader).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **This plan builds on Plan A's branch** (`security/scanning-pipeline`); the workflows here are already SHA-pinned. Work branch: `security/container-hardening`.
|
||||
- **Do not break existing self-hosters.** The `./data:/app/data` bind mount (`docker-compose.yml:31`) is host-owned; the container must still read/write it after `docker pull` + restart. The non-root switch is handled by an entrypoint that chowns `/app/data` **as root** then drops to the `node` user via `gosu` — so there is **no static `USER` line** (a static `USER` would run the entrypoint unprivileged and make the chown impossible).
|
||||
- **Keep `ffmpeg`** (real runtime dependency) and **keep `tsx`** (the `CMD` runs TS via `tsx/esm`). Only `python3 make g++` may leave the runtime stage.
|
||||
- **better-sqlite3 must still load.** It is expected to install via its prebuilt binary on `node:20-slim` (glibc) for both amd64 and arm64. If a task's build shows it compiling (needs the toolchain), use the documented fallback (keep the toolchain, OR copy the built module from the builder stage) and report it — do not ship a broken image.
|
||||
- **Image scan is report-only in this plan** (`exit-code: '0'` + `continue-on-error: true`, comment `# report-only; enforcement flipped on in Plan E`). Do NOT make it fail the publish here.
|
||||
- **trivy-action pinned to `ed142fd0673e97e23eac54620cfb913e5ce36c25` (# v0.36.0)** — v0.28.0's nested `setup-trivy@v0.2.1` ref is broken (see memory `ci-security-action-gotchas`). SHA-pin any other new action with a `# vX.Y.Z` comment.
|
||||
- **Both build paths must keep working:** the GHCR prebuilt-image pull (`docker-compose.yml` `image:`) AND the from-source `docker compose up --build` fallback.
|
||||
- **Commit identity:** plain `git commit` (local config = `Jannis Braun <151788261+TheZwiss@users.noreply.github.com>`). NEVER `-c user.email`; never the alxtrading94 email.
|
||||
- **Node 20 / pnpm 10.34.3** are the pinned toolchain.
|
||||
- **Docker daemon must be running** for Tasks 1 and 2 verification (`docker build` / `buildx --load`). If it is not up, STOP and report — do not mark a Dockerfile task done without a real build+boot.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Harden the runtime image (non-root via gosu, drop build toolchain)
|
||||
|
||||
**Files:**
|
||||
- Create: `docker-entrypoint.sh`
|
||||
- Modify: `Dockerfile` (runtime stage, lines 37-97)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: the existing builder stage output (`/app/packages/web/dist`).
|
||||
- Produces: an image that runs `node --import tsx/esm src/index.ts` as the non-root `node` user (uid 1000) with a writable `/app/data`. No code symbols.
|
||||
|
||||
- [ ] **Step 1: Write the entrypoint script**
|
||||
|
||||
Create `docker-entrypoint.sh` at the repo root:
|
||||
|
||||
```sh
|
||||
#!/bin/sh
|
||||
# Runs as root: make the (bind-mounted, host-owned) data dir writable by the
|
||||
# non-root `node` user, then drop privileges via gosu and exec the CMD. This
|
||||
# lets the container run as uid 1000 while still owning ./data on hosts where
|
||||
# the bind mount was created by a different uid.
|
||||
#
|
||||
# - Idempotent AND cheap: only chown entries not already node-owned, so after
|
||||
# the first boot this is near-instant. A plain `chown -R` over a large
|
||||
# uploads/ tree on slow Pi/SD storage would delay startup on EVERY restart.
|
||||
# - Non-fatal: on a bind mount that rejects chown (some CIFS/NFS backings),
|
||||
# warn and continue rather than crash-looping under `restart: unless-stopped`
|
||||
# (the old root container booted fine on such mounts).
|
||||
set -e
|
||||
mkdir -p /app/data/uploads
|
||||
chown node:node /app/data /app/data/uploads 2>/dev/null || true
|
||||
find /app/data ! -user node -exec chown node:node {} + 2>/dev/null || \
|
||||
echo "docker-entrypoint: warning: could not chown /app/data; continuing (ensure it is writable by uid 1000)"
|
||||
exec gosu node "$@"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Modify the runtime stage's apt-get line**
|
||||
|
||||
In `Dockerfile`, replace the runtime-stage package install (currently line 42-44):
|
||||
|
||||
```dockerfile
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends python3 make g++ ffmpeg && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
```
|
||||
|
||||
with (drop the C toolchain; keep ffmpeg; add gosu for the privilege drop):
|
||||
|
||||
```dockerfile
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends ffmpeg gosu && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Wire the entrypoint + keep the CMD**
|
||||
|
||||
In `Dockerfile`, immediately AFTER the `RUN mkdir -p /app/data/uploads` line (currently line 73) add the entrypoint copy:
|
||||
|
||||
```dockerfile
|
||||
# Non-root hardening: copy the privilege-dropping entrypoint. It chowns the
|
||||
# data volume as root, then execs the CMD as the unprivileged `node` user.
|
||||
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
```
|
||||
|
||||
Then, at the END of the file, replace the final two lines (currently line 96-97):
|
||||
|
||||
```dockerfile
|
||||
WORKDIR /app/packages/server
|
||||
CMD ["node", "--import", "tsx/esm", "src/index.ts"]
|
||||
```
|
||||
|
||||
with (add the ENTRYPOINT between WORKDIR and CMD; do NOT add a `USER` line):
|
||||
|
||||
```dockerfile
|
||||
WORKDIR /app/packages/server
|
||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||
CMD ["node", "--import", "tsx/esm", "src/index.ts"]
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build the native (arm64) image + confirm neither arch needs the toolchain**
|
||||
|
||||
The runtime stage runs its own `pnpm install --prod` (Dockerfile:61), so better-sqlite3/sharp install **per arch**. This host is Apple Silicon (arm64), which is ALSO the Raspberry Pi's arch — the main self-host target — so build the real image **natively for arm64** (fast; a `--platform linux/amd64` build here would emulate the whole Vite build via Rosetta and likely time out). This build both produces the image for the Step 5 boot test AND exercises the arm64 runtime install:
|
||||
```bash
|
||||
docker buildx build --platform linux/arm64 --load -t backspace:hardening-test --build-arg BACKSPACE_COMMIT=test .
|
||||
```
|
||||
Watch the better-sqlite3 output: it must use a prebuilt binary (`prebuild-install`), NOT `node-gyp`/compilation. This build may take a few minutes (pnpm install + Vite) — give it an ample timeout or run it in the background so it isn't killed mid-build.
|
||||
|
||||
Then verify the OTHER arch (amd64) toolchain drop with a lightweight, emulated native-module check (no Vite build, so it's quick even under emulation) on `node:20-slim` amd64, which — like the hardened runtime stage — has no `python3/make/g++`:
|
||||
```bash
|
||||
BSQL=$(grep -A1 'better-sqlite3@' pnpm-lock.yaml | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1); echo "testing better-sqlite3@$BSQL"
|
||||
docker run --rm --platform linux/amd64 node:20-slim sh -c "
|
||||
cd /tmp && npm init -y >/dev/null 2>&1 &&
|
||||
npm install --no-audit --no-fund better-sqlite3@$BSQL sharp@0.33.5 2>&1 | grep -iE 'prebuild-install|prebuilt|node-gyp|gyp ERR|rebuild' | head -20;
|
||||
node -e \"require('better-sqlite3')(':memory:').close(); require('sharp'); console.log('amd64 native modules OK (no toolchain)')\"
|
||||
"
|
||||
```
|
||||
Expected: the native arm64 image builds (better-sqlite3 prebuilt), AND the amd64 test prints `amd64 native modules OK (no toolchain)`. If EITHER arch tries to compile, or the amd64 test errors, STOP — apply the fallback (re-add the toolchain to the runtime apt-get line, OR build the module in the builder stage and `COPY --from=builder`) and report which arch failed, which fallback you used, and why. Do not proceed on a one-arch pass.
|
||||
|
||||
- [ ] **Step 5: Boot the container and verify non-root + data volume + DB**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
mkdir -p /tmp/bkspace-data
|
||||
docker run -d --name bkspace-htest -e JWT_SECRET=testsecret_at_least_32_chars_long_xx -p 3999:3000 -v /tmp/bkspace-data:/app/data backspace:hardening-test
|
||||
sleep 12
|
||||
echo "--- health ---"; curl -fsS http://localhost:3999/api/health && echo " OK"
|
||||
# IMPORTANT: check PID 1 (the actual server), NOT `docker exec ... id`. `docker exec`
|
||||
# spawns a NEW process as the image's configured USER (root, since there is no USER
|
||||
# line), so `exec ... id` prints uid=0 even when the gosu drop worked. /proc/1/status
|
||||
# is the real server process's identity.
|
||||
echo "--- server (PID 1) runs as node/uid 1000, not root ---"; docker exec bkspace-htest sh -c "grep '^Uid:' /proc/1/status"
|
||||
echo "--- data dir written + owned by node ---"; docker exec bkspace-htest sh -c 'ls -ld /app/data /app/data/uploads'
|
||||
echo "--- better-sqlite3 loaded (DB file exists) ---"; docker exec bkspace-htest sh -c 'ls -la /app/data/*.db 2>/dev/null || echo NO_DB'
|
||||
echo "--- sharp (native, toolchain-sensitive) loads ---"; docker exec bkspace-htest node -e "require('sharp'); console.log('sharp OK')"
|
||||
echo "--- boot logs clean (no EACCES / permission errors from running non-root) ---"; docker logs bkspace-htest 2>&1 | grep -iE 'EACCES|permission denied|EPERM' && echo "PERMISSION ERRORS FOUND" || echo "logs clean"
|
||||
```
|
||||
Expected: `/api/health` returns ok; `Uid:` line shows `1000 1000 1000 1000` (server runs non-root); `/app/data` + `/app/data/uploads` exist and are `node`-owned; a `.db` file was created (better-sqlite3 loaded and wrote); `sharp OK` prints (the OTHER native module survived the toolchain drop); logs show no permission errors. If any fails, fix before proceeding.
|
||||
|
||||
- [ ] **Step 6: Tear down the test container**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
docker rm -f bkspace-htest; rm -rf /tmp/bkspace-data
|
||||
docker rmi backspace:hardening-test 2>/dev/null || true
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add docker-entrypoint.sh Dockerfile
|
||||
git commit -m "fix(docker): run container as non-root (gosu) and drop build toolchain from runtime"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Scan the image before publishing (restructure docker-publish.yml)
|
||||
|
||||
**Files:**
|
||||
- Modify: `.github/workflows/docker-publish.yml`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: the hardened `Dockerfile` from Task 1.
|
||||
- Produces: a publish workflow that builds amd64 → Trivy-scans it (report-only) → pushes multi-arch with SBOM + provenance. No code symbols.
|
||||
|
||||
- [ ] **Step 1: Restructure the build/scan/push steps**
|
||||
|
||||
In `.github/workflows/docker-publish.yml`, add `security-events: write` to the top-level `permissions` block (it currently has `contents: read` + `packages: write`):
|
||||
|
||||
```yaml
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
security-events: write
|
||||
```
|
||||
|
||||
Then replace the single `Build and push (linux/amd64, linux/arm64)` step (currently lines 79-92) with the build → scan → push sequence:
|
||||
|
||||
```yaml
|
||||
# Build a single-arch amd64 image and LOAD it into the runner's docker
|
||||
# daemon so Trivy can scan the exact artifact before anything is published.
|
||||
# A multi-arch manifest cannot be --load'ed, so scanning must happen on a
|
||||
# single-arch build first; the multi-arch push below reuses these layers
|
||||
# from the buildx cache, so this is cheap.
|
||||
- name: Build amd64 image for scanning
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
load: true
|
||||
push: false
|
||||
tags: backspace:scan
|
||||
build-args: |
|
||||
BACKSPACE_COMMIT=${{ steps.meta_commit.outputs.commit }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Trivy image scan (report-only)
|
||||
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||
continue-on-error: true # report-only; enforcement flipped on in Plan E
|
||||
with:
|
||||
scan-type: image
|
||||
image-ref: backspace:scan
|
||||
ignore-unfixed: true
|
||||
format: sarif
|
||||
output: trivy-image.sarif
|
||||
severity: HIGH,CRITICAL
|
||||
|
||||
- name: Upload Trivy image SARIF
|
||||
if: always()
|
||||
uses: github/codeql-action/upload-sarif@02c5e83432fe5497fd85b873b6c9f16a8578e1d9 # v3.37.0
|
||||
with:
|
||||
sarif_file: trivy-image.sarif
|
||||
category: trivy-image
|
||||
|
||||
# Publish the multi-arch image. Reuses the amd64 layers built above via the
|
||||
# gha cache. Attaches an SBOM and SLSA provenance attestation to the image.
|
||||
- name: Build and push (linux/amd64, linux/arm64)
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
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 }}
|
||||
sbom: true
|
||||
provenance: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Validate the workflow**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
actionlint .github/workflows/docker-publish.yml
|
||||
```
|
||||
Expected: exit 0, no output.
|
||||
|
||||
- [ ] **Step 3: Confirm all actions still SHA-pinned**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
grep -rnE 'uses: +[^ ]+@' .github/workflows/docker-publish.yml | grep -vE '@[0-9a-f]{40}' && echo "UNPINNED FOUND" || echo "All actions pinned to SHA"
|
||||
```
|
||||
Expected: `All actions pinned to SHA`.
|
||||
|
||||
- [ ] **Step 4: Locally reproduce the build→load→scan path**
|
||||
|
||||
This proves the new build→load→scan logic works without publishing anything (requires Docker daemon + local Trivy: `brew install trivy` if absent). Build native (arm64) here to avoid emulation — the scan mechanism is arch-independent; CI scans the amd64 image natively on GitHub's runners:
|
||||
```bash
|
||||
docker buildx build --platform linux/arm64 --load -t backspace:scan --build-arg BACKSPACE_COMMIT=test .
|
||||
trivy image --severity HIGH,CRITICAL --ignore-unfixed backspace:scan | tail -25
|
||||
docker rmi backspace:scan
|
||||
```
|
||||
Expected: the image builds + loads, and Trivy scans it and prints a summary (findings are fine — the scan is report-only; we just need it to RUN). Note accurately in the report: only the **amd64** image is Trivy-scanned; the published **arm64** image ships unscanned (acceptable for this plan). The multi-arch push + SBOM/provenance path cannot be exercised without publishing — it is verified by review + a maintainer `workflow_dispatch` run; the amd64 layers are gha-cache reused on the push, but the **arm64 layers build cold** there (so the push is not "free").
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add .github/workflows/docker-publish.yml
|
||||
git commit -m "ci(docker): scan the amd64 image before publish; attach SBOM + provenance"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Document the container hardening
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/systems/deployment.md`
|
||||
- Modify: `docs/systems/security-scanning.md`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: the changes from Tasks 1-2.
|
||||
- Produces: an upgrade/migration note + an updated pipeline reference. No code symbols.
|
||||
|
||||
- [ ] **Step 1: Add a container-hardening + migration note to deployment.md AND correct now-false ownership statements**
|
||||
|
||||
Read `docs/systems/deployment.md` first to match its structure. Then:
|
||||
|
||||
(a) Add a subsection (place it near the Docker/image content) with this content:
|
||||
|
||||
```markdown
|
||||
### Container hardening (non-root)
|
||||
|
||||
The runtime image runs as the unprivileged `node` user (uid 1000), not root. On
|
||||
container start, `docker-entrypoint.sh` runs as root only long enough to `chown`
|
||||
the `./data` bind mount to `node` (only entries not already node-owned, so it is
|
||||
near-instant after the first boot), then drops privileges via `gosu` and execs the
|
||||
server. The build toolchain (`python3`/`make`/`g++`) is not installed in the
|
||||
runtime stage — `better-sqlite3` and `sharp` load from prebuilt binaries — which
|
||||
shrinks the runtime attack surface. `ffmpeg` remains (a real runtime dependency).
|
||||
|
||||
The published image carries an SBOM and SLSA provenance attestation, and the
|
||||
amd64 image is scanned by Trivy before publish (report-only). Note: only the
|
||||
amd64 image is scanned; the arm64 image is published unscanned.
|
||||
|
||||
**Minimum Docker version:** the attestation-bearing multi-arch image requires a
|
||||
reasonably modern Docker to `pull` cleanly (Docker Engine 24+ recommended).
|
||||
Very old daemons (≤ 20.10) may mishandle the `unknown/unknown` attestation
|
||||
manifests. New installs via `install.sh` (get.docker.com) are fine.
|
||||
|
||||
**Upgrade note for existing self-hosters:** on the first start of the hardened
|
||||
image, the contents of your host `./data` directory are chowned to uid 1000. This
|
||||
is expected and idempotent. If you previously accessed `./data` on the host as a
|
||||
different user, adjust host-side access accordingly. `./restore.sh` continues to
|
||||
work — it swaps files inside a throwaway root container, and root can rewrite the
|
||||
now uid-1000-owned files.
|
||||
```
|
||||
|
||||
(b) Correct the two statements that this change makes false (the data dir is no
|
||||
longer root-owned):
|
||||
- The seed-admin line (around `deployment.md:170`): change
|
||||
`writes it to `data/seed-admin-rotated.txt` (mode `0600`, root-owned via the bind-mount)`
|
||||
→ `... (mode `0600`, owned by the container's runtime user uid 1000 via the bind-mount)`.
|
||||
- The Restore intro (around `deployment.md:252`): change
|
||||
`Because `data/` (including `backspace.db` and `data/backups/`) is **container-owned (root)** via the bind-mount`
|
||||
→ `... is **container-owned (uid 1000)** via the bind-mount`. (The throwaway
|
||||
root `alpine` container still performs the swap — root can rewrite uid-1000
|
||||
files — so the mechanism description after it stays correct.)
|
||||
|
||||
**Maintainer release-gate (record it, do not action it here):** before the first
|
||||
`v*` tag that ships this image, do a real `docker compose pull && docker compose
|
||||
up -d` on both an amd64 host and the arm64 Pi to confirm the attestation-bearing
|
||||
image pulls on the actual deployment Docker versions.
|
||||
|
||||
- [ ] **Step 2: Reflect the image scan in security-scanning.md**
|
||||
|
||||
In `docs/systems/security-scanning.md`, update the supply-chain line about SBOM/provenance (currently "**will be** attached ... not yet live") to reflect that image scanning + SBOM + provenance now exist in `docker-publish.yml` (report-only image scan; SBOM + provenance attached at push). Add `docker-publish.yml` to the workflow table with trigger "tag push / manual" and result "image scan (report-only) + SBOM + provenance".
|
||||
|
||||
- [ ] **Step 3: Verify the docs reference reality**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
grep -q 'non-root' docs/systems/deployment.md && grep -q 'provenance' docs/systems/security-scanning.md && echo "docs updated"
|
||||
```
|
||||
Expected: `docs updated`.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/systems/deployment.md docs/systems/security-scanning.md
|
||||
git commit -m "docs(docker): document non-root runtime, data-volume migration, and image scan"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Notes
|
||||
|
||||
- **Spec coverage (WS2):** non-root USER via gosu (Task 1) ✓; slim runtime / drop toolchain with prebuilt-binary verification + fallback (Task 1) ✓; keep ffmpeg + tsx (Task 1 / constraints) ✓; bind-mount chown migration (Task 1 entrypoint + Task 3 doc) ✓; restructure to single-arch load → scan → multi-arch push (Task 2) ✓; SBOM + provenance (Task 2) ✓; image scan report-only, flips in Plan E (Task 2 + constraints) ✓.
|
||||
- **Deferred by design:** flipping the image scan to blocking → Plan E. Desktop, web/CSP/CORS → Plans C/D.
|
||||
- **Risk-managed:** the toolchain removal is verified by a real build that must show better-sqlite3 using a prebuilt binary; a fallback is defined if it compiles. The non-root switch is verified by asserting `uid=1000` at runtime and a successful `./data` write + DB creation. Multi-arch push + SBOM/provenance is review-plus-workflow_dispatch verified (cannot be exercised without publishing).
|
||||
- **Enforcement stays OFF** — image scan is `continue-on-error` + `severity`-limited to HIGH/CRITICAL for the report; no build-failing gate added here.
|
||||
- **Adversarial pre-execution review folded in:** the non-root verification now checks `/proc/1/status` (not `docker exec … id`, which spawns a new root process and would false-fail); BOTH arches are built to verify the toolchain drop (arm64 is the release-hard-fail path); the entrypoint chown is idempotent-cheap + non-fatal (Pi/CIFS safety); a `sharp` native-load smoke check + EACCES log scan were added; now-false `deployment.md` ownership statements are corrected; and an SBOM/provenance min-Docker floor + a maintainer pull-test release-gate are documented (attestation-bearing images can trip very old Docker on the `pull` path).
|
||||
@@ -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) |
|
||||
```
|
||||
@@ -0,0 +1,428 @@
|
||||
# Security Scanning & Hardening Initiative — Design
|
||||
|
||||
**Date:** 2026-07-10
|
||||
**Status:** Approved (design); pending implementation plan
|
||||
**Author:** Lead Developer (Backspace)
|
||||
|
||||
---
|
||||
|
||||
## 1. Motivation
|
||||
|
||||
A prospective self-hoster declined to run Backspace with the objection:
|
||||
|
||||
> "Security testing: You've made a web app. I am not installing a new webapp that
|
||||
> is expected to touch the internet without some level of security scanning."
|
||||
|
||||
The objection is valid. Investigation of the current state shows Backspace has
|
||||
solid security **engineering** but no security **assurance infrastructure**:
|
||||
|
||||
**Already present (good):**
|
||||
- `SECURITY.md` with a private vulnerability-disclosure policy.
|
||||
- Real defensive code: SSRF protection (`packages/server/src/utils/ssrf.ts` — DNS
|
||||
resolution, private-IP blocking, per-redirect-hop re-validation), HMAC-signed
|
||||
federation with replay-nonce prevention, sliding-window rate limiters, JWT +
|
||||
bcrypt, input validation.
|
||||
- Good secrets hygiene: `.deploy.local` untracked; thorough `.gitignore`
|
||||
(`.env*`, `*.pem`, `*.key`, `data/`, `*.db`).
|
||||
- CI (`ci.yml`) running typecheck + build + full test suite.
|
||||
|
||||
**Absent (the gap):**
|
||||
- No SAST, no dependency/CVE scanning, no secret scanning, no container image
|
||||
scanning, no supply-chain hardening, no Dependabot.
|
||||
- No visible, verifiable evidence a stranger can audit before trusting the app.
|
||||
- **No browser-facing hardening** the objection actually cares about: no security
|
||||
response headers (`@fastify/helmet` absent; bare `Caddyfile`), CORS reflects any
|
||||
origin with credentials, unsigned desktop autoupdate, no Electron fuses/asar
|
||||
integrity, no license compliance gate for a dual-licensed (AGPL + commercial)
|
||||
project.
|
||||
|
||||
**Root cause (per the No-Band-Aids principle):** the fix is not a one-off scan. It
|
||||
is a permanent, automated, and *visible* scanning pipeline wired into CI/CD, plus
|
||||
remediation of the browser/desktop hardening gaps that continuous scanning would
|
||||
be embarrassing to leave open.
|
||||
|
||||
---
|
||||
|
||||
## 2. Goals & Non-Goals
|
||||
|
||||
### Goals
|
||||
1. Continuous, automated scanning on every change: SAST, dependency CVEs, secrets,
|
||||
container image, license compliance, supply-chain posture.
|
||||
2. Tiered enforcement: high-confidence, fixable issues **block merge**; the rest are
|
||||
advisory in the GitHub Security tab. Never wall off merges on unfixable upstream
|
||||
CVEs.
|
||||
3. Close the browser-facing and desktop-facing hardening gaps (headers, CORS,
|
||||
Electron integrity).
|
||||
4. Publish verifiable evidence: badges, an OpenSSF Scorecard, SBOM/provenance, and
|
||||
documentation a stranger can read without repo access.
|
||||
5. Land the whole thing without leaving CI spuriously red: scanners report-only →
|
||||
remediate → flip enforcement.
|
||||
|
||||
### Non-Goals (explicitly out of scope for this initiative)
|
||||
- **npm provenance / package signing** — every workspace is `"private": true`;
|
||||
nothing is published to npm. N/A.
|
||||
- **Purchasing desktop code-signing certificates** — a procurement action (Apple
|
||||
Developer ~$99/yr, a Windows code-signing cert) that cannot be done in code. We
|
||||
implement the *code-level* Electron hardening and *document* the signing steps
|
||||
and certs to buy; we do not fake signing.
|
||||
- **Full fuzzing harness for federation input** — valuable but multi-week; deferred.
|
||||
A handful of targeted negative/property tests on `validateExternalUrl` and S2S
|
||||
JSON parsing is in scope; a standing fuzz harness is not.
|
||||
- **TLS/cipher configuration** — Caddy already auto-provisions HTTPS with modern
|
||||
defaults; we add security *headers*, not a TLS overhaul.
|
||||
|
||||
---
|
||||
|
||||
## 3. Tool Selection & Rationale
|
||||
|
||||
Where a "GitHub-native" option and a "committed-workflow" option overlap, we prefer
|
||||
**committed workflow files** — a self-hoster auditing the repo can read a `.yml`
|
||||
file; they cannot read repo settings. Settings-only toggles are documented as
|
||||
required manual steps, never claimed as code.
|
||||
|
||||
| Scan class | Choice | Rationale |
|
||||
|---|---|---|
|
||||
| SAST | **CodeQL** (committed advanced workflow, default `security` suite to start) | Free for public repos, best TS/JS coverage, `none` build mode sidesteps monorepo/native-module build complexity. `security-extended` deferred to avoid a day-one triage tax. |
|
||||
| Dependency CVEs | **OSV-Scanner** (blocking CI gate) **+ Dependabot** (auto-upgrade PRs) | OSV-Scanner parses `pnpm-lock.yaml` v9 directly and can fail the build; Dependabot alerts are advisory-only. Two distinct roles, no overlap. **Trivy is NOT used for dependency CVEs** (avoids double-noise). |
|
||||
| Secrets | **gitleaks** (committed, full history + PR diff) **+** documented native push-protection | gitleaks is the verifiable, blocking, history-aware gate; native push-protection is the complementary pre-commit net for the future. |
|
||||
| Container image | **Trivy** (image scan, blocking) | SARIF output, `ignore-unfixed: true` for tiered policy, scans the exact GHCR image users pull. |
|
||||
| IaC/config | **Trivy config** (Dockerfile, docker-compose) | Note: Trivy does **not** lint the `Caddyfile`; the reverse-proxy hardening is done by hand (§6.3). |
|
||||
| License compliance | **Trivy `--scanners license`** with an allowlist | Dual-licensed AGPL + commercial → a copyleft-incompatible transitive dep is a legal defect. Reuses the Trivy we already run. |
|
||||
| Supply chain | **SHA-pinned actions + harden-runner (audit) + SBOM + SLSA provenance + OpenSSF Scorecard** | Answers "can I trust the build?" and produces a public Scorecard badge. |
|
||||
| Dynamic (DAST) | **ZAP baseline** against an ephemeral `docker compose up` (advisory) | Catches missing headers + CORS reflection continuously; the one dynamic check for a "webapp exposed to the internet." |
|
||||
|
||||
---
|
||||
|
||||
## 4. Architecture — Component Layout
|
||||
|
||||
Each workflow file has one clear purpose (mirrors the codebase's module-boundary
|
||||
principle).
|
||||
|
||||
```
|
||||
.github/
|
||||
dependabot.yml NEW — pnpm(npm) + github-actions + docker(Dockerfile only)
|
||||
workflows/
|
||||
codeql.yml NEW — CodeQL SAST (PR + push main + weekly)
|
||||
security.yml NEW — gitleaks + OSV-Scanner + Trivy config + Trivy license
|
||||
scorecard.yml NEW — OpenSSF Scorecard (push main + weekly) → Security tab + badge
|
||||
dast.yml NEW — ZAP baseline vs ephemeral compose stack (advisory)
|
||||
docker-publish.yml EDIT — restructure for real image scanning + SBOM + provenance
|
||||
ci.yml EDIT — harden-runner (audit), tighten permissions
|
||||
release.yml EDIT — harden-runner (audit), tighten permissions
|
||||
cla.yml EDIT
|
||||
deploy-pages.yml EDIT
|
||||
```
|
||||
|
||||
**SHA-pinning applies to EVERY workflow** — the four edited above, `docker-publish.yml`,
|
||||
and all four new ones (`codeql`/`security`/`scorecard`/`dast`). Pin every `uses:` to a
|
||||
full commit SHA with a trailing `# vX.Y.Z` comment. (OpenSSF Scorecard's
|
||||
Pinned-Dependencies check and tag-move attack resistance both require this repo-wide.)
|
||||
|
||||
```
|
||||
Dockerfile EDIT — non-root USER, slim runtime, copy pruned node_modules
|
||||
Caddyfile EDIT — security response headers
|
||||
packages/server/src/index.ts EDIT — @fastify/helmet + CSP; tighten CORS
|
||||
packages/server/package.json EDIT — add @fastify/helmet
|
||||
packages/web/index.html EDIT — CSP meta (defense in depth for static shell)
|
||||
packages/desktop/src/main.ts EDIT — will-navigate deny handler
|
||||
packages/desktop/electron-builder.yml EDIT — @electron/fuses / asar integrity
|
||||
packages/desktop/package.json EDIT — add @electron/fuses
|
||||
README.md EDIT — badges + "Security & supply chain" section
|
||||
SECURITY.md EDIT — "Security testing & assurance" section
|
||||
docs/systems/security-scanning.md NEW — full pipeline spec
|
||||
docs/systems/desktop-security.md NEW — Electron hardening + signing procurement
|
||||
CLAUDE.md EDIT — add subsystem-table rows
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Policy Engine (tiered enforcement)
|
||||
|
||||
| Finding | Action |
|
||||
|---|---|
|
||||
| gitleaks secret hit | **Block** (always) |
|
||||
| OSV-Scanner — fixable HIGH/CRITICAL | **Block** |
|
||||
| Trivy image — fixable HIGH/CRITICAL (`ignore-unfixed: true`) | **Block** |
|
||||
| Trivy license — disallowed license | **Block** |
|
||||
| CodeQL — any alert | Advisory (SARIF → Security tab) |
|
||||
| OSV/Trivy — unfixable, or medium/low | Advisory (SARIF → Security tab) |
|
||||
| ZAP baseline (DAST) | Advisory (report artifact) |
|
||||
| Scorecard | Advisory (score badge + Security tab) |
|
||||
|
||||
**Enforcement honesty — two mechanisms, kept separate:**
|
||||
- **Code-enforced (auditable in the `.yml`):** OSV-Scanner, Trivy, and gitleaks
|
||||
block via workflow exit codes.
|
||||
- **Settings-enforced (documented one-time toggles, NOT claimed as code):** CodeQL
|
||||
merge-blocking (code-scanning merge protection), Dependabot alerts, native
|
||||
secret-scanning + push protection, and branch protection "require status checks."
|
||||
These live in `docs/systems/security-scanning.md` as a maintainer checklist.
|
||||
|
||||
---
|
||||
|
||||
## 6. Workstreams (bounded, independently reviewable)
|
||||
|
||||
Sequencing rule: **WS1 scanners land report-only → WS5 remediation → flip WS1/WS2
|
||||
enforcement to blocking.** WS3/WS4 are otherwise independent and can land in
|
||||
parallel. Two cross-workstream dependencies to respect: **(a)** WS3 (CSP/CORS
|
||||
validation) and WS6's DAST job share the same **two-instance + LiveKit ephemeral
|
||||
test rig** — build it once, reuse it; **(b)** WS6's badges + maintainer checklist
|
||||
document state produced by WS1/WS2/WS5, so its final copy is written *last* (the
|
||||
workflow files can be scaffolded earlier).
|
||||
|
||||
### WS1 — Scanning & supply-chain pipeline (report-only first)
|
||||
- `.github/dependabot.yml`:
|
||||
- `package-ecosystem: npm` at `/` (Dependabot handles pnpm workspaces), weekly,
|
||||
grouped minor/patch.
|
||||
- **`ignore` `uiohook-napi`** — it is pinned by an exact-version patch
|
||||
(`patches/uiohook-napi@1.5.5.patch`); an unmatched bump breaks
|
||||
`pnpm install --frozen-lockfile` in CI and both Docker stages. Also treat
|
||||
`onlyBuiltDependencies` (`better-sqlite3`, `esbuild`, `electron`, `sharp`)
|
||||
bumps with care (grouped, expect native-rebuild churn).
|
||||
- `package-ecosystem: github-actions` at `/`.
|
||||
- `package-ecosystem: docker` at `/` — tracks the **Dockerfile `FROM`** only.
|
||||
**No compose entry:** `docker-compose.yml` sits at the same `/` directory (a
|
||||
second docker entry there would collide on ecosystem+directory), and Dependabot's
|
||||
docker ecosystem parses Dockerfiles, **not** `image:` refs in compose. The pinned
|
||||
`caddy:2.11.1-alpine` / `livekit/livekit-server:v1.9.11` compose images are
|
||||
therefore updated **manually** — added as a line item to the maintainer checklist
|
||||
in `docs/systems/security-scanning.md`. (Renovate, which does parse compose, is
|
||||
noted there as an optional future alternative.)
|
||||
- `codeql.yml`: languages `javascript-typescript`, default `security` queries,
|
||||
triggers PR + push `main` + weekly cron. SARIF uploaded.
|
||||
- `security.yml`:
|
||||
- **gitleaks** — full history + PR diff, SARIF, **block** on hit.
|
||||
- **OSV-Scanner** — reads `pnpm-lock.yaml`; report-only initially, then block on
|
||||
fixable HIGH/CRITICAL after WS5.
|
||||
- **Trivy config** — Dockerfile + docker-compose misconfig, SARIF, advisory.
|
||||
- **Trivy license** — `--scanners license` against the dependency tree with an
|
||||
allowlist (permissive + AGPL-compatible); block on disallowed.
|
||||
- `scorecard.yml`: `ossf/scorecard-action`, push `main` + weekly, publish results +
|
||||
badge.
|
||||
- Harden **all** workflows (new and existing, incl. `docker-publish.yml`): pin every
|
||||
`uses:` to a full commit SHA (retain a `# vX.Y.Z` comment); add
|
||||
`step-security/harden-runner` in **`egress-policy: audit`** (not block — multi-arch
|
||||
buildx + QEMU + gha cache make many egress calls); tighten job-level `permissions`
|
||||
to least privilege.
|
||||
|
||||
### WS2 — Container hardening & real image scanning
|
||||
- **Restructure `docker-publish.yml`** (the current single multi-arch `build-push`
|
||||
cannot be scanned before publish):
|
||||
1. Build **single-arch `linux/amd64`** with `load: true`.
|
||||
2. **Trivy image scan** (`ignore-unfixed: true`, block on fixable HIGH/CRITICAL),
|
||||
SARIF uploaded.
|
||||
3. On pass, the multi-arch (`amd64,arm64`) `build-push` with `push: true`,
|
||||
`sbom: true`, `provenance: true`. (Buildx cache makes the second build cheap.)
|
||||
- Trivy authenticates to GHCR with the same `GITHUB_TOKEN` used for login (the
|
||||
package may be private until manually flipped public).
|
||||
- **Dockerfile hardening:**
|
||||
- **Prune mechanics (precise):** the builder runs a *full* `pnpm install
|
||||
--frozen-lockfile` (Dockerfile:25) whose `node_modules` is a symlinked `.pnpm`
|
||||
virtual store — a plain `COPY --from=builder node_modules` is **not**
|
||||
self-contained. Use `pnpm --filter @backspace/server deploy --prod
|
||||
/app/deploy` in the builder to produce a dereferenced/hoisted prod tree, then
|
||||
`COPY --from=builder /app/deploy` into the runtime stage. This replaces the
|
||||
runtime stage's own `pnpm install --prod`, letting `python3 make g++` be dropped
|
||||
from runtime. **Keep `ffmpeg`** (real runtime dep) and **keep `tsx`** as a prod
|
||||
dependency (the CMD runs TS via `tsx/esm`). Verify `better-sqlite3`'s prebuilt
|
||||
binary and `tsx` are present in the copied tree for **both** target arches.
|
||||
- **Non-root + bind-mount chown (reconciled — the two are mutually exclusive if
|
||||
done naively):** `docker-compose.yml:31` bind-mounts host-owned `./data:/app/data`.
|
||||
Chowning it requires **root**, so we do **not** hard-set a `USER` line (that would
|
||||
run the entrypoint as non-root and make the chown impossible). Instead: install
|
||||
`gosu` (or `su-exec`), add an `ENTRYPOINT` that (a) idempotently `chown`s
|
||||
`/app/data` to a fixed non-root UID, then (b) `exec gosu <uid> "$@"` to drop
|
||||
privileges — so the process runs non-root while the volume stays writable. The
|
||||
`ENTRYPOINT` must `exec "$@"` to preserve the existing `WORKDIR
|
||||
/app/packages/server` + `CMD ["node","--import","tsx/esm","src/index.ts"]`
|
||||
(Dockerfile:96-97). Ship a documented upgrade note; must not break existing
|
||||
self-hosters on `docker pull` + restart.
|
||||
|
||||
### WS3 — Web/server hardening
|
||||
|
||||
**Reality check (from review):** this app renders *arbitrary user-supplied content*
|
||||
and is *federated*, so a restrictive `img-src`/`media-src`/`connect-src` is
|
||||
infeasible. A CSP here realistically constrains `script-src` / `object-src` /
|
||||
`base-uri` / `frame-ancestors` / `form-action` (the XSS/clickjacking-relevant
|
||||
directives) and stays permissive on content origins. Concretely:
|
||||
- **`img-src` / `media-src` must be broad** (`https: data: blob:`): link-embed OG
|
||||
images (`VideoEmbed.tsx`, `RichEmbed.tsx`) come from *any* linked site, and GIF
|
||||
previews load directly from Klipy's CDN (`routes/gif.ts` returns `file.url`
|
||||
unproxied — the CDN host differs from `api.klipy.com`).
|
||||
- **`connect-src` must include the LiveKit `wss://` origin, which is operator
|
||||
config** (`routes/livekit.ts` returns `config.livekit.url` = `LIVEKIT_URL`
|
||||
verbatim) — so the **CSP must be generated at runtime from config**, not a static
|
||||
string. Federation (`getApiForOrigin` in `exploreStore`/`socialStore`/`spaceStore`)
|
||||
fetches/opens WS to peers discovered at runtime → `connect-src` must also allow
|
||||
`https: wss:` (peers aren't enumerable at build time).
|
||||
- **`frame-src` needs an explicit provider allowlist** — YouTube, Vimeo, Spotify
|
||||
embed origins — or the embed iframes break (default `frame-src 'self'` blocks them).
|
||||
|
||||
Steps:
|
||||
- Add `@fastify/helmet`. Build the CSP **dynamically** from `config.livekit.url` +
|
||||
the embed-provider list; ship it **report-only first**, validate against real flows
|
||||
(chat, **cross-instance federation**, embed render, upload, and a **real voice
|
||||
join**) with zero violations, then flip to enforcing.
|
||||
- `packages/web/index.html`: CSP `<meta>` (defense-in-depth) — script/object/base
|
||||
directives only; do not duplicate the dynamic connect/img rules there.
|
||||
- `Caddyfile`: `Strict-Transport-Security`, `X-Content-Type-Options: nosniff`,
|
||||
`Referrer-Policy`, and clickjacking protection via CSP `frame-ancestors` (prefer
|
||||
over `X-Frame-Options`). **Ownership split (documented to avoid conflicts):** Caddy
|
||||
owns HSTS + nosniff + Referrer-Policy; the app (helmet) owns the CSP. Don't set CSP
|
||||
in two places.
|
||||
- **CORS (`packages/server/src/index.ts:46-48`)** — replace `origin: true` with a
|
||||
**dynamic `origin` callback backed by the live federation-peer registry**, NOT a
|
||||
static `DOMAIN`-derived list. Two breakages a static list would cause, both must be
|
||||
handled:
|
||||
- **Federated browser uploads:** browsers make cross-origin tus POST/HEAD/PATCH/
|
||||
DELETE to peer `/api/files/*` (see the existing CORS-block comment at
|
||||
`index.ts:50-64`); peers are DB-backed and added after boot → the callback must
|
||||
consult the live registry, not a boot-time snapshot.
|
||||
- **Desktop instance picker:** `packages/desktop/resources/instance-picker.html`
|
||||
does a renderer `fetch('<url>/api/instance/info')` from a `file://` document
|
||||
(Origin `null`). Keep `/api/instance/info` **CORS-open** (or move that probe to a
|
||||
main-process fetch) so the picker doesn't report instances as unreachable.
|
||||
- **Federation note:** S2S endpoints authenticate by HMAC and receive no browser
|
||||
`Origin`; verify they are unaffected by the two-instance federation integration
|
||||
suite.
|
||||
- **Rollout (phased, like the scanners):** CSP report-only → observe → enforce; CORS
|
||||
gets a **"log-and-allow" observation phase** (log rejected origins without blocking)
|
||||
before switching to reject. **Test-rig dependency:** validating CSP + CORS here
|
||||
needs the **two-instance + LiveKit** harness (shared with the DAST env, §WS6/G2),
|
||||
which is heavier than a single-instance boot — call this out when scheduling.
|
||||
|
||||
### WS4 — Desktop/Electron hardening
|
||||
- **Fuses without breaking the existing hook:** `electron-builder.yml:20` already
|
||||
declares `afterPack: ./scripts/afterPack.js` (it strips host-compiled
|
||||
`uiohook-napi` artifacts + cross-platform prebuilds), and electron-builder allows
|
||||
**only one** `afterPack`. So do **not** add a second hook. Prefer electron-builder's
|
||||
top-level **`electronFuses:`** config key (cleanest, no collision); if a fuse isn't
|
||||
expressible there, call `@electron/fuses` `flipFuses()` **inside** the existing
|
||||
`scripts/afterPack.js`. Fuses: disable `RunAsNode` + `EnableNodeCliInspectArguments`,
|
||||
enable `OnlyLoadAppFromAsar`. **Asar-integrity caveat:** it interacts with the
|
||||
existing `asarUnpack: **/*.node` (lines 17-18) and the afterPack that mutates
|
||||
`app.asar.unpacked` — integrity hashes must be computed *after* those mutations, and
|
||||
because builds are unsigned (`release.yml:93`) macOS integrity **enforcement** is
|
||||
limited; document this in `desktop-security.md` rather than over-claiming.
|
||||
- **`will-navigate` deny handler** in `main.ts`: block foreign top-level navigations
|
||||
while allowing the initial `https://` instance load and the `file://` picker.
|
||||
Clarification (mechanism): the app is client-routed (history API →
|
||||
`did-navigate-in-page`), cross-instance switching uses main-process `loadURL`, and
|
||||
`/join/*` deep-links are handled by `setWindowOpenHandler` (`main.ts:454`) — none of
|
||||
these are `will-navigate`, so the deny handler is safe and `setWindowOpenHandler`
|
||||
stays untouched.
|
||||
- `docs/systems/desktop-security.md`: document the current webPreferences posture
|
||||
(contextIsolation on, nodeIntegration off, sandbox on — `main.ts:356-360`), the
|
||||
fuses/asar posture and its unsigned-macOS limits, and — because `release.yml:93`
|
||||
sets `CSC_IDENTITY_AUTO_DISCOVERY: false` (unsigned) — the exact signing +
|
||||
notarization steps and certificates to procure. Flag unsigned autoupdate as a known
|
||||
gap until signing is wired up.
|
||||
|
||||
### WS5 — Remediation (after WS1 lands report-only)
|
||||
- Run OSV-Scanner + Trivy + CodeQL; triage. Fix real HIGH/CRITICAL: direct upgrades,
|
||||
`pnpm.overrides` for transitive pins where no direct upgrade exists, code fixes for
|
||||
true-positive SAST findings. Dismiss false positives **with written justification**
|
||||
(`.trivyignore` / inline).
|
||||
- **SSRF hardening (fix, then test — not just test):** the string-prefix
|
||||
`isPrivateIp` (`utils/ssrf.ts:3-16`) is genuinely bypassable — `::ffff:127.0.0.1`
|
||||
matches no branch and returns `false` (SSRF to loopback via an attacker AAAA
|
||||
record), and there is no `100.64.0.0/10` (CGNAT) or `::` handling. **Harden
|
||||
`isPrivateIp`**: normalize IPv4-mapped IPv6, reject CGNAT and `::`/unspecified, and
|
||||
normalize decimal/octal/hex hostname encodings — *then* add the negative/property
|
||||
tests for `validateExternalUrl` covering those vectors. The residual DNS-rebind
|
||||
TOCTOU is already documented (`ssrf.ts:58-61`) and stays out of scope (noted, not
|
||||
fixed).
|
||||
- **Then flip WS1/WS2 enforcement to blocking.**
|
||||
|
||||
### WS6 — Visible evidence, DAST & docs
|
||||
- **`dast.yml` (ZAP baseline):** stands up an ephemeral instance and runs ZAP
|
||||
baseline (advisory). **CI env override required** — the production compose won't
|
||||
come up unmodified: Caddy uses `{$DOMAIN}` + ACME auto-HTTPS (hangs in CI without
|
||||
public DNS), `backspace` requires `JWT_SECRET`, livekit is profile-gated. Use a CI
|
||||
compose override that sets a test `JWT_SECRET`/`DOMAIN` and **points ZAP directly at
|
||||
the `backspace` container `:3000`, bypassing Caddy** (or Caddy `internal`/local
|
||||
TLS). This is the same two-instance-capable rig WS3 needs for CSP/CORS validation.
|
||||
- README: CodeQL, OpenSSF Scorecard, and security-policy badges; a "Security &
|
||||
supply chain" section describing what runs on every change and where results are
|
||||
published.
|
||||
- SECURITY.md: add a "Security testing & assurance" section enumerating the pipeline.
|
||||
- `docs/systems/security-scanning.md`: full spec of every workflow, the tiered
|
||||
policy, and the maintainer settings checklist (§5) — including the **repo-must-be-
|
||||
public precondition** (Scorecard `publish_results` + badge and CodeQL free tier both
|
||||
require a public canonical repo) and the manual `caddy`/`livekit` compose-image
|
||||
update reminder (from WS1/F1).
|
||||
- CLAUDE.md: add subsystem-table rows for `security-scanning.md` and
|
||||
`desktop-security.md` (required by the Documentation Rule — this is structural CI
|
||||
and architecture).
|
||||
- **Finalize WS6 last:** badges + the maintainer checklist document state that only
|
||||
exists once WS1/WS2/WS5 land, so write the final copy after those are green (the
|
||||
workflow *files* can be scaffolded earlier).
|
||||
|
||||
---
|
||||
|
||||
## 7. Testing Strategy
|
||||
|
||||
- **`actionlint`** on every new/edited workflow.
|
||||
- **Real PR-branch run** watching each check go green (or advisory) as intended.
|
||||
- **Canary proof of blocking:** on a throwaway branch, introduce a fake secret and a
|
||||
known-vulnerable dependency; confirm gitleaks and OSV-Scanner actually **fail** the
|
||||
build; revert.
|
||||
- **WS2:** `docker build` locally for amd64 + container boots + `/api/health`
|
||||
responds, before and after the Dockerfile changes; confirm the process runs
|
||||
**non-root** (via gosu step-down) yet still writes the host-owned `./data` bind
|
||||
mount; confirm `tsx` + `better-sqlite3` prebuilt are present in the pruned tree and
|
||||
the arm64 image still builds; confirm existing self-hosters survive `pull` + restart.
|
||||
- **WS3 (needs the two-instance + LiveKit rig):** security headers present
|
||||
(curl/DevTools); **zero CSP violations** across chat, **cross-instance federation**,
|
||||
embed render (YouTube/Vimeo/Spotify + generic OG image), GIF, upload, and a **real
|
||||
voice join**; CORS callback permits the app origin **and dynamically-registered
|
||||
peers** (federated upload), keeps `/api/instance/info` open to the `file://` picker
|
||||
(Origin `null`), and rejects an unknown origin; two-instance federation S2S suite
|
||||
still green.
|
||||
- **WS4:** desktop app boots with fuses/asar-integrity applied and the existing
|
||||
`afterPack` native-module cleanup intact; `will-navigate` blocks a foreign top-level
|
||||
URL while the initial instance load, the `file://` picker, and `/join/*` deep-links
|
||||
(via `setWindowOpenHandler`) still work.
|
||||
- **DAST:** ZAP baseline runs against the CI compose override (bypassing Caddy) and
|
||||
produces a report artifact.
|
||||
- **Full suite** (`pnpm -r test`) green throughout; existing federation/voice suites
|
||||
unaffected.
|
||||
|
||||
---
|
||||
|
||||
## 8. Risks & Mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| Enforcement day-one paints CI permanently red (May-2024 lockfile has fixable highs) | Report-only → remediate (WS5) → flip blocking. |
|
||||
| Multi-arch image "scan" is theater / arm64 unscanned | WS2 restructure: single-arch load+scan → then multi-arch push. |
|
||||
| Non-root USER breaks `./data` bind-mount for existing self-hosters | Run entrypoint as root → chown → `exec gosu <uid>` step-down (no static `USER`); documented upgrade note; tested before/after. |
|
||||
| Dependabot breaks CI via `uiohook-napi` patch / native rebuilds | `ignore` the patched dep; group `onlyBuiltDependencies`. |
|
||||
| CSP too strict for a federated, arbitrary-content app | CSP built **dynamically** from `config.livekit.url` + peer registry; `img/media/connect` permissive; constrain only script/object/base/frame-ancestors; report-only → enforce. |
|
||||
| CORS allowlist breaks federated uploads + desktop `file://` picker | Dynamic `origin` callback backed by the **live peer registry**; keep `/api/instance/info` CORS-open; "log-and-allow" phase before rejecting. |
|
||||
| Electron fuses overwrite the existing `afterPack` (native-module cleanup) | Use top-level `electronFuses:` key or call `flipFuses()` inside the existing `scripts/afterPack.js`; compute asar-integrity hashes after afterPack mutations. |
|
||||
| pnpm symlinked `.pnpm` store makes a plain `node_modules` copy non-self-contained | Use `pnpm --filter @backspace/server deploy --prod`; verify `tsx` + `better-sqlite3` prebuilt land per-arch; keep `ffmpeg`; boot test. |
|
||||
| DAST/compose won't come up in CI (ACME/DOMAIN/JWT_SECRET) | CI compose override with test env; point ZAP at `backspace:3000`, bypass Caddy. |
|
||||
| harden-runner block mode false-positives the Docker build | Start in `audit`; graduate to block only on lightweight jobs. |
|
||||
| Scorecard badge / CodeQL free tier assume a public repo | Documented as an explicit precondition in the maintainer checklist. |
|
||||
|
||||
---
|
||||
|
||||
## 9. Definition of Done
|
||||
|
||||
- All new workflows present, `actionlint`-clean, and green on a real PR.
|
||||
- Blocking gates proven by canary (secret + vuln), then reverted.
|
||||
- Security tab populated (CodeQL, Scorecard, advisory Trivy/OSV) with no open
|
||||
fixable HIGH/CRITICAL after WS5.
|
||||
- helmet + CSP + Caddyfile headers live with no CSP violations in normal use; CORS
|
||||
allowlisted; federation suite green.
|
||||
- Electron fuses + `will-navigate` live; desktop boots and deep-links work; existing
|
||||
`afterPack` native-module cleanup intact.
|
||||
- `isPrivateIp` hardened (IPv4-mapped IPv6 / CGNAT / `::` / alt-encodings) with
|
||||
passing negative tests.
|
||||
- Container image scanned before publish; SBOM + provenance attached; Dockerfile
|
||||
runs non-root (gosu step-down) with a working data volume.
|
||||
- README badges + Security section; SECURITY.md expanded;
|
||||
`docs/systems/security-scanning.md` + `docs/systems/desktop-security.md` written;
|
||||
CLAUDE.md subsystem table updated.
|
||||
- Maintainer settings checklist documented (CodeQL merge protection, Dependabot
|
||||
alerts, push protection, branch protection).
|
||||
+89
-13
@@ -4,9 +4,11 @@ Operator- and contributor-facing reference for hosting Backspace: the Docker bui
|
||||
|
||||
Source files:
|
||||
- `Dockerfile` -- multi-stage build (builder → runtime)
|
||||
- `docker-compose.yml` -- `backspace` + `caddy` (+ optional `livekit`) services, healthcheck
|
||||
- `Caddyfile` -- reverse proxy / auto-HTTPS config
|
||||
- `install.sh` -- interactive first-time setup
|
||||
- `docker-compose.yml` -- base stack: `backspace` + `caddy` (+ optional `livekit`) services, healthcheck
|
||||
- `docker-compose.proxy.yml` -- proxy/tunnel overlay: publishes the app on `127.0.0.1:APP_PORT` and drops Caddy
|
||||
- `.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
|
||||
- `backup.sh` / `restore.sh` -- manual snapshot + restore tooling (host side)
|
||||
- `packages/server/src/config.ts` -- `config.backup.*` env parsing
|
||||
@@ -25,14 +27,42 @@ Source files:
|
||||
|
||||
## 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
|
||||
|
||||
`Dockerfile` has two stages:
|
||||
|
||||
1. **`builder`** (`node:20-slim`) — enables pnpm via corepack, installs the full workspace with `pnpm install --frozen-lockfile`, copies `shared`/`server`/`web` source, and runs `pnpm --filter @backspace/web build` to produce the static frontend (`packages/web/dist`).
|
||||
2. **`runtime`** (`node:20-slim`) — installs the native toolchain for `better-sqlite3` plus `ffmpeg` (`python3 make g++ ffmpeg`), installs production-only deps with `pnpm install --prod --frozen-lockfile` (`tsx` is a server runtime dependency), copies `shared` + `server` source and the prebuilt `web/dist`, creates `/app/data/uploads`, and starts the server with `node --import tsx/esm src/index.ts` from `/app/packages/server`.
|
||||
2. **`runtime`** (`node:20-slim`) — installs `ffmpeg` (media) + `gosu` (privilege drop) only — **no C toolchain**, since `better-sqlite3`/`sharp` load prebuilt binaries — installs production-only deps with `pnpm install --prod --frozen-lockfile` (`tsx` is a server runtime dependency), copies `shared` + `server` source and the prebuilt `web/dist`, creates `/app/data/uploads`, and runs the server **as the non-root `node` user** via `docker-entrypoint.sh` (which chowns `/app/data` as root, then `exec gosu node`) with `node --import tsx/esm src/index.ts` from `/app/packages/server`.
|
||||
|
||||
The server is run through `tsx` (no separate transpile step); TypeScript is executed directly at runtime.
|
||||
|
||||
@@ -43,6 +73,42 @@ The server is run through `tsx` (no separate transpile step); TypeScript is exec
|
||||
|
||||
Empty/unset → `config.commit` is `null` (local dev, tarball install, or git unavailable). The source URL itself is `config.sourceCodeUrl` (env `BACKSPACE_SOURCE_URL`, default upstream) — operators running a modified build MUST set it to their fork.
|
||||
|
||||
### Container hardening (non-root)
|
||||
|
||||
The runtime image runs as the unprivileged `node` user (uid 1000), not root. On
|
||||
container start, `docker-entrypoint.sh` runs as root only long enough to `chown`
|
||||
the `./data` bind mount to `node` (only entries not already node-owned, so it is
|
||||
near-instant after the first boot), then drops privileges via `gosu` and execs the
|
||||
server. The build toolchain (`python3`/`make`/`g++`) is not installed in the
|
||||
runtime stage — `better-sqlite3` and `sharp` load from prebuilt binaries — which
|
||||
shrinks the runtime attack surface. `ffmpeg` remains (a real runtime dependency).
|
||||
|
||||
The published image carries an SBOM and SLSA provenance attestation, and the
|
||||
amd64 image is scanned by Trivy before publish (report-only). Note: only the
|
||||
amd64 image is scanned; the arm64 image is published unscanned.
|
||||
|
||||
**Minimum Docker version:** the attestation-bearing multi-arch image requires a
|
||||
reasonably modern Docker to `pull` cleanly (Docker Engine 24+ recommended).
|
||||
Very old daemons (≤ 20.10) may mishandle the `unknown/unknown` attestation
|
||||
manifests. New installs via `install.sh` (get.docker.com) are fine.
|
||||
|
||||
**Upgrade note for existing self-hosters:** on the first start of the hardened
|
||||
image, the contents of your host `./data` directory are chowned to uid 1000. This
|
||||
is expected and idempotent. On an instance with a large `uploads/` tree on slow
|
||||
storage (e.g. a Pi on SD), the **first** restart after upgrade may take noticeably
|
||||
longer as this one-time chown runs before the server starts; subsequent boots only
|
||||
touch not-yet-node-owned entries and are near-instant. If you previously accessed
|
||||
`./data` on the host as a different user, adjust host-side access accordingly. `./restore.sh` continues to
|
||||
work — it swaps files inside a throwaway root container, and root can rewrite the
|
||||
now uid-1000-owned files.
|
||||
|
||||
**Release-gate (maintainer):** before the first `v*` tag that ships this image,
|
||||
do a real `docker compose pull && docker compose up -d` on both an amd64 host and
|
||||
the arm64 Pi to confirm the attestation-bearing image pulls cleanly on the actual
|
||||
deployment Docker versions, and that the container boots non-root with a writable
|
||||
`./data` on real Linux (the macOS Docker Desktop bind-mount ownership display is
|
||||
not representative of Linux behaviour).
|
||||
|
||||
### Run: `docker compose up -d --build`
|
||||
|
||||
`docker-compose.yml` defines:
|
||||
@@ -65,7 +131,13 @@ Caddy provisions and renews TLS certificates automatically for `DOMAIN`; the per
|
||||
|
||||
### 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:
|
||||
|
||||
@@ -131,7 +203,7 @@ Behavior (`packages/server/src/scripts/remediate-seed-admin.ts`):
|
||||
- **Targets only the local seed admin** — `username = 'admin'` with `home_instance IS NULL` and `is_admin = 1`. Replicated/federated users are never touched.
|
||||
- **Rotates only `admin123`.** It verifies the current hash against `admin123`; if the password has already been changed, it is a **no-op** ("nothing to do"). It is fully idempotent — safe to run repeatedly.
|
||||
- **Never deletes** the account (the default-space ownership constraint above).
|
||||
- On rotation it generates a 24-character random password, updates the hash, prints the new password to stdout, **and** writes it to `data/seed-admin-rotated.txt` (mode `0600`, root-owned via the bind-mount). **Store the password somewhere safe, then delete `data/seed-admin-rotated.txt`.**
|
||||
- On rotation it generates a 24-character random password, updates the hash, prints the new password to stdout, **and** writes it to `data/seed-admin-rotated.txt` (mode `0600`, **root-owned** — the script runs via `docker exec`, which bypasses the entrypoint's gosu drop and runs as root, so this file is uid 0 until the next container restart re-chowns it). **Store the password somewhere safe, then delete `data/seed-admin-rotated.txt`** (a non-root host user may need `sudo`).
|
||||
|
||||
> **Note — sessions are not invalidated.** Rotation changes the stored password hash only; it does **not** revoke existing JWTs. An already-logged-in admin session survives until the token expires (`JWT_EXPIRES_IN`, default 30 days). Rotation closes off *future* logins with the old password; it does not eject a currently active session. If you must terminate live sessions immediately, rotate `JWT_SECRET` (which invalidates **all** tokens instance-wide) and restart.
|
||||
|
||||
@@ -214,7 +286,7 @@ They do **not** protect against **hardware loss** (disk failure, the box being d
|
||||
|
||||
## 4. Restore
|
||||
|
||||
Restores are driven by `./restore.sh` from the host. Because `data/` (including `backspace.db` and `data/backups/`) is **container-owned (root)** via the bind-mount, the host user cannot rewrite those files directly — so the actual swap runs inside a throwaway root `alpine` container that mounts `data/`.
|
||||
Restores are driven by `./restore.sh` from the host. Because `data/` (including `backspace.db` and `data/backups/`) is **container-owned (uid 1000)** via the bind-mount, the host user cannot rewrite those files directly — so the actual swap runs inside a throwaway root `alpine` container that mounts `data/`.
|
||||
|
||||
### List snapshots
|
||||
|
||||
@@ -247,14 +319,16 @@ The pre-restore copy means a mistaken restore is itself undoable: the previous D
|
||||
|
||||
## 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 |
|
||||
|---------|--------------|
|
||||
| `caddy` | `caddy:2.11.1-alpine` |
|
||||
| `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:
|
||||
|
||||
@@ -270,7 +344,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:
|
||||
|
||||
- **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.
|
||||
- **`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 +354,12 @@ These are accepted constraints of the current deploy model, documented so operat
|
||||
|
||||
| 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 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` |
|
||||
| Take a manual snapshot | `./backup.sh` |
|
||||
| List snapshots | `./restore.sh` |
|
||||
|
||||
@@ -199,6 +199,39 @@ interface AvatarStackProps {
|
||||
|
||||
**Hooks-in-loop safety:** each rendered slot is its own `<AvatarTile>` component so `useCanonicalUserView` is called exactly once per slot, never inside a variable-length `.map()`.
|
||||
|
||||
### Avatar vs ProfileAvatar
|
||||
|
||||
Two components, one deliberate split:
|
||||
|
||||
| Component | Role |
|
||||
|---|---|
|
||||
| `Avatar` (`ui/Avatar.tsx`) | Purely presentational. Takes `user` for the gradient, avatar colour, `homeUserId` and status dot. Clicking it does nothing unless the caller passes `onClick`. |
|
||||
| `ProfileAvatar` (`ui/ProfileAvatar.tsx`) | `Avatar` plus the profile card. Opens `UserProfilePopout` anchored to its own box, stops propagation so it wins over an enclosing row handler, and stays inert while `user` is undefined. |
|
||||
|
||||
**Rule:** an avatar is only a profile trigger when it is a `ProfileAvatar`. Never re-add an implicit "open the profile if a `user` prop is present" branch to `Avatar` — passing `user` is how *every* avatar gets its colour, so that branch silently turns the picture inside the profile card, the settings preview, the avatar-upload button and every row in a modal into a trigger. It also made the card re-anchor to its own picture and walk across the screen on repeated clicks (issue #37).
|
||||
|
||||
Use `ProfileAvatar` when the avatar is the primary way to reach that person's profile and nothing else owns the click. Use `Avatar` when an enclosing row, button or list item already handles clicks, or when the avatar depicts the surface it already sits on.
|
||||
|
||||
**Escalation chain.** Clicking a face always moves one step deeper, never sideways and never nowhere:
|
||||
|
||||
| Surface | Picture click |
|
||||
|---|---|
|
||||
| Member tile / row / message author | Opens the preview card (`UserProfilePopout`) |
|
||||
| Preview card | Opens the full profile modal (`UserProfileModal`) and closes the card |
|
||||
| Full profile modal | Nothing — this is the terminus |
|
||||
|
||||
The middle step matters: an inert picture on the preview card is a dead end that forces the user down to the *View Full Profile* link. What it must never do is reopen the card itself — that is the drift bug from issue #37.
|
||||
|
||||
### Floating placement
|
||||
|
||||
Every floating surface places itself with `computeFloatingPosition` (`hooks/useFloatingPosition.ts`): preferred side → flip when it would overflow → clamp into the viewport, with an 8px viewport padding.
|
||||
|
||||
- Components with a live anchor element use the `useFloatingPosition` hook (tooltips, mention/search popovers, voice popovers).
|
||||
- Components opened from a store keep the anchor's **rect** instead of an element — `uiStore.openUserProfile(user, anchor, placement)` stores `AnchorRect` + `Placement`, and `UserProfilePopout` measures itself and places off that. `pointAnchor(x, y)` builds a zero-size rect for the rare caller with no anchor element.
|
||||
- `align: 'start'` lines the surface's leading edge up with the anchor; the default centres it on the anchor.
|
||||
|
||||
**Callers never compute coordinates.** A surface that is handed a finished `{ top, left }` cannot account for its own measured size, and any caller-side constant (an assumed card height, a hardcoded sidebar width) drifts the moment the content or the layout changes.
|
||||
|
||||
**Tile geometry contract.** Each `AvatarTile` renders at `size × size` with a 2px border (`box-sizing: border-box` from Tailwind preflight), so its content area is `(size − 4) × (size − 4)`. The inner `Avatar` is sized to that content area (`size − 2 · TILE_BORDER_WIDTH`) and centered geometrically on the tile via `flex items-center justify-center`, **not** by inline-flow placement. Both corrections are required: sizing the Avatar to the outer dimensions overflows the padding box and gets clipped off-center (visible disc remains centered, but the avatar's contents — image crop, initials gradient + letter — anchor at the padding-edge top-left and visibly drift toward the lower-right of the visible disc); relying on `Avatar`'s `inline-flex` placement makes the Avatar drift vertically by whatever the inherited `line-height` adds, independent of border. `TILE_BORDER_WIDTH` is exported from `AvatarStack.tsx` as the single source of truth for the `border-2` width and must be updated in lockstep with any future change to that class.
|
||||
|
||||
**Border tiers:** the surface tier the stack sits on determines the tile border color (so the tiles cleanly separate from the panel they overlap). `channel` → `border-surface-channel` (sidebar); `chat` → `border-surface-chat` (chat area / welcome header / chat header); `modal` → `border-surface-elevated` (modal hero, mobile info-screen hero — there is no `surface-modal` token in `tailwind.config.js`).
|
||||
|
||||
@@ -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.
|
||||
|
||||
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/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)
|
||||
|
||||
@@ -45,6 +45,15 @@ Storage: Bigint decimal strings in SQLite TEXT columns (bigint not JSON-safe).
|
||||
### Step 1: Owner/Admin Check
|
||||
- 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)
|
||||
- Start with @everyone role permissions (role where `id === spaceId`)
|
||||
- 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 |
|
||||
| `stringToPermissions(str)` | Decimal string → bigint (supports legacy JSON array format) |
|
||||
| `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 |
|
||||
| `getMember/isMember/isSpaceOwner` | Membership checks |
|
||||
| `isDmMember/isBanned` | DM/ban checks |
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# Security Scanning & Supply-Chain Assurance
|
||||
|
||||
Automated, continuous scanning wired into GitHub Actions. This document is the
|
||||
reference for what runs, where results go, and the one-time settings a maintainer
|
||||
must enable. **Current state: report-only** — scanners surface findings in the
|
||||
Security tab but do not block merges yet. Enforcement (blocking) is turned on in a
|
||||
later change once the remediation pass has cleared the backlog.
|
||||
|
||||
## Workflows
|
||||
|
||||
| File | Purpose | Trigger | Result |
|
||||
|------|---------|---------|--------|
|
||||
| `.github/dependabot.yml` | Dependency + action + base-image update PRs | weekly | PRs |
|
||||
| `.github/workflows/codeql.yml` | CodeQL SAST (`javascript-typescript`, build-mode none) | PR + push main + weekly | Security tab |
|
||||
| `.github/workflows/security.yml` | gitleaks (secrets, full history), OSV-Scanner (deps), Trivy config (IaC), Trivy license | PR + push main + weekly | Security tab |
|
||||
| `.github/workflows/scorecard.yml` | OpenSSF Scorecard (repo posture) | push main + weekly + on branch-protection change | Security tab + public badge |
|
||||
| `.github/workflows/docker-publish.yml` | Image scan (Trivy) + SBOM + provenance for the published container | tag push / manual | image scan (report-only) + SBOM + provenance |
|
||||
|
||||
> **gitleaks findings** surface in the workflow's job log and PR summary — the
|
||||
> `gitleaks` job does not upload SARIF, so secret hits do **not** appear under
|
||||
> Security → Code scanning (unlike the OSV / Trivy / CodeQL / Scorecard jobs).
|
||||
|
||||
## Tiered policy (target, enforced in a later change)
|
||||
|
||||
- **Always block:** gitleaks secret hit; OSV/Trivy fixable HIGH/CRITICAL; Trivy
|
||||
disallowed license.
|
||||
- **Advisory (SARIF → Security tab):** CodeQL alerts; OSV/Trivy unfixable or
|
||||
medium/low; Scorecard.
|
||||
|
||||
Code-level gates (OSV, Trivy, gitleaks) block via workflow exit codes. CodeQL
|
||||
merge-blocking, Dependabot alerts, and native secret-scanning are GitHub *settings*
|
||||
— see the checklist below.
|
||||
|
||||
## Supply-chain hardening
|
||||
|
||||
- Every action is pinned to a full commit SHA (`# vX.Y.Z` comment) — resists
|
||||
tag-move attacks and satisfies Scorecard's Pinned-Dependencies check.
|
||||
- `step-security/harden-runner` (egress-policy `audit`) on Linux jobs.
|
||||
- Least-privilege `permissions:` per workflow/job.
|
||||
- SBOM + SLSA provenance are attached to the published container image at push
|
||||
(`.github/workflows/docker-publish.yml`), alongside a report-only Trivy scan
|
||||
of the amd64 image (the arm64 image ships unscanned; enforcement is turned
|
||||
on in a later plan).
|
||||
|
||||
## Maintainer checklist (one-time GitHub settings — NOT code)
|
||||
|
||||
- [ ] Repository must be **public** (required for the Scorecard badge/publish and
|
||||
the CodeQL free tier).
|
||||
- [ ] Settings → Code security: enable **Dependabot alerts** and **Dependabot
|
||||
security updates**.
|
||||
- [ ] Settings → Code security: enable **Secret scanning** + **Push protection**.
|
||||
- [ ] Settings → Code security: enable **CodeQL / code-scanning merge protection**
|
||||
so high-severity alerts block PRs (the code-level gates do the rest).
|
||||
- [ ] Branch protection on `main`: require the CI + security status checks to pass.
|
||||
- [ ] **Manual image bumps:** Dependabot does not track `docker-compose.yml`
|
||||
`image:` pins — update `caddy` and `livekit/livekit-server` by hand when new
|
||||
releases ship. (Renovate, which parses compose, is an optional future
|
||||
alternative.)
|
||||
@@ -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).
|
||||
|
||||
**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 }`
|
||||
|
||||
### 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/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:**
|
||||
1. Insert `space_members` row
|
||||
|
||||
+634
-138
@@ -2,13 +2,31 @@
|
||||
# ============================================================
|
||||
# Backspace — Production Installer
|
||||
# ============================================================
|
||||
# Sets up Backspace with Caddy (auto-HTTPS) and optional
|
||||
# LiveKit (voice/video) on a Linux server.
|
||||
# Sets up Backspace on a Linux server in one of three deployment modes,
|
||||
# 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:
|
||||
# ./install.sh Interactive setup
|
||||
# ./install.sh Interactive setup (auto-detects the mode)
|
||||
#
|
||||
# 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
|
||||
@@ -97,56 +115,84 @@ if ! $COMPOSE version &>/dev/null 2>&1; then
|
||||
fi
|
||||
success "Docker Compose $($COMPOSE version --short 2>/dev/null || echo 'installed')"
|
||||
|
||||
# Check if ports 80/443 are available
|
||||
check_port() {
|
||||
local port=$1
|
||||
if ss -tlnp 2>/dev/null | grep -qE ":${port}\b"; then
|
||||
local proc
|
||||
proc=$(ss -tlnp 2>/dev/null | grep -E ":${port}\b" | grep -oP 'users:\(\("\K[^"]+' | head -1 || echo "unknown")
|
||||
error "Port $port is already in use by: $proc"
|
||||
error "Free port $port before running this script."
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
# ── Port helpers ────────────────────────────────────────────
|
||||
# A host port can be held in two ways this installer must detect:
|
||||
# 1. A normal listening socket (host process) → visible via `ss -tln`.
|
||||
# 2. A Docker-published port. With the userland proxy *disabled*
|
||||
# (`userland-proxy: false`, common on tuned hosts), Docker DNATs the port
|
||||
# with iptables and there is NO listening socket for `ss` to see. So we must
|
||||
# also consult `docker ps` — otherwise a box whose Caddy already owns 80/443
|
||||
# via iptables would be misreported as "ports free".
|
||||
|
||||
# Does any running container publish this host port? (matches "…:<port>->" in the
|
||||
# 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)
|
||||
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q '^caddy$'; then
|
||||
port_ok=true
|
||||
check_port 80 || port_ok=false
|
||||
check_port 443 || port_ok=false
|
||||
if [[ "$port_ok" == false ]]; then
|
||||
exit 1
|
||||
# Same, but ignore Backspace's own container (used when re-running on a host that
|
||||
# already runs this instance — its published port must not count as a conflict).
|
||||
docker_publishes_port_other() {
|
||||
$DOCKER ps --format '{{.Names}} {{.Ports}}' 2>/dev/null \
|
||||
| grep -vE '^backspace ' | grep -qE ":${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
|
||||
success "Ports 80 and 443 are available"
|
||||
else
|
||||
success "Caddy is already running (upgrade mode)"
|
||||
docker_publishes_port "$port"
|
||||
}
|
||||
|
||||
# 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
|
||||
|
||||
# Disk space check
|
||||
available_kb=$(df -k . 2>/dev/null | tail -1 | awk '{print $4}' || true)
|
||||
if [[ -n "$available_kb" ]] && (( available_kb < 3000000 )); then
|
||||
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
|
||||
success "Disk space OK"
|
||||
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 ─────────────────────────────────
|
||||
|
||||
step "Configuration"
|
||||
|
||||
# Detect existing installation
|
||||
EXISTING_ENV=false
|
||||
if [[ -f .env ]]; then
|
||||
EXISTING_ENV=true
|
||||
info "Existing .env detected — secrets will be preserved."
|
||||
info "Existing .env detected — secrets and settings will be preserved."
|
||||
fi
|
||||
|
||||
# Helper: read existing .env value (|| true prevents set -e from killing
|
||||
@@ -157,17 +203,131 @@ env_val() {
|
||||
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 ──────────────────────────────────────────────────
|
||||
# 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)
|
||||
if [[ -n "${DOMAIN:-}" ]]; then
|
||||
# Non-interactive: DOMAIN set via environment
|
||||
:
|
||||
elif [[ -n "$existing_domain" ]]; then
|
||||
read -rp "Domain [$existing_domain]: " DOMAIN
|
||||
read -rp "Domain [$existing_domain]: " DOMAIN || DOMAIN=""
|
||||
DOMAIN="${DOMAIN:-$existing_domain}"
|
||||
else
|
||||
read -rp "Domain (e.g., chat.example.com): " DOMAIN
|
||||
read -rp "Domain (e.g., chat.example.com): " DOMAIN || DOMAIN=""
|
||||
fi
|
||||
|
||||
if [[ -z "${DOMAIN:-}" ]]; then
|
||||
@@ -175,42 +335,93 @@ if [[ -z "${DOMAIN:-}" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# DNS verification
|
||||
info "Verifying DNS for ${DOMAIN}..."
|
||||
# DNS verification is meaningful for All-in-One (Caddy must reach this host to
|
||||
# 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=""
|
||||
if command -v dig &>/dev/null; then
|
||||
resolved_ip=$(dig +short "$DOMAIN" A 2>/dev/null | tail -1 || true)
|
||||
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)
|
||||
fi
|
||||
|
||||
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
|
||||
warn "Could not resolve ${DOMAIN}. Ensure DNS is configured before Caddy can issue certificates."
|
||||
elif [[ -n "$my_ip" && "$resolved_ip" != "$my_ip" ]]; then
|
||||
warn "${DOMAIN} resolves to ${resolved_ip}, but this server appears to be ${my_ip}"
|
||||
warn "Let's Encrypt certificate issuance may fail if DNS doesn't point here."
|
||||
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 "")
|
||||
if [[ -z "$resolved_ip" ]]; then
|
||||
warn "Could not resolve ${DOMAIN}. Ensure DNS is configured before Caddy can issue certificates."
|
||||
elif [[ -n "$my_ip" && "$resolved_ip" != "$my_ip" ]]; then
|
||||
warn "${DOMAIN} resolves to ${resolved_ip}, but this server appears to be ${my_ip}"
|
||||
warn "Let's Encrypt certificate issuance may fail if DNS doesn't point here."
|
||||
else
|
||||
success "${DOMAIN} resolves to ${resolved_ip:-verified}"
|
||||
fi
|
||||
else
|
||||
success "${DOMAIN} resolves to ${resolved_ip:-verified}"
|
||||
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 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)
|
||||
if [[ -n "${ENABLE_VOICE:-}" ]]; then
|
||||
# Non-interactive
|
||||
if [[ "$DEPLOY_MODE" == "tunnel" ]]; then
|
||||
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
|
||||
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)
|
||||
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)
|
||||
fi
|
||||
ENABLE_VOICE="${ENABLE_VOICE:-true}"
|
||||
@@ -219,10 +430,26 @@ ENABLE_VOICE="${ENABLE_VOICE:-true}"
|
||||
|
||||
existing_name=$(env_val INSTANCE_NAME)
|
||||
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}}"
|
||||
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 ───────────────────────────────
|
||||
|
||||
step "Generating configuration"
|
||||
@@ -259,12 +486,31 @@ step "Writing configuration files"
|
||||
|
||||
# ── .env ────────────────────────────────────────────────────
|
||||
|
||||
cat > .env << EOF
|
||||
{
|
||||
cat << EOF
|
||||
# Backspace Configuration
|
||||
# Generated by install.sh on $(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
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
|
||||
PORT=3000
|
||||
HOST=0.0.0.0
|
||||
@@ -275,12 +521,12 @@ JWT_SECRET=${JWT_SECRET}
|
||||
# Registration
|
||||
REGISTRATION_OPEN=true
|
||||
|
||||
# Max upload size in bytes (100MB)
|
||||
MAX_UPLOAD_SIZE=104857600
|
||||
# Max upload size in bytes
|
||||
MAX_UPLOAD_SIZE=${MAX_UPLOAD_SIZE}
|
||||
EOF
|
||||
|
||||
if [[ "$ENABLE_VOICE" == true ]]; then
|
||||
cat >> .env << EOF
|
||||
if [[ "$ENABLE_VOICE" == true ]]; then
|
||||
cat << EOF
|
||||
|
||||
# LiveKit Voice/Video
|
||||
LIVEKIT_URL=wss://${DOMAIN}/livekit
|
||||
@@ -290,8 +536,8 @@ LIVEKIT_API_SECRET=${LIVEKIT_API_SECRET}
|
||||
# Activate the LiveKit service in Docker Compose
|
||||
COMPOSE_PROFILES=voice
|
||||
EOF
|
||||
else
|
||||
cat >> .env << EOF
|
||||
else
|
||||
cat << EOF
|
||||
|
||||
# LiveKit Voice/Video (disabled)
|
||||
# To enable: fill in credentials and add COMPOSE_PROFILES=voice
|
||||
@@ -299,7 +545,8 @@ else
|
||||
# LIVEKIT_API_KEY=
|
||||
# LIVEKIT_API_SECRET=
|
||||
EOF
|
||||
fi
|
||||
fi
|
||||
} > .env
|
||||
|
||||
success ".env"
|
||||
|
||||
@@ -382,14 +629,43 @@ step "Deploying Backspace"
|
||||
# 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
|
||||
# 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 '')"
|
||||
if [[ -n "$BUILD_COMMIT" ]]; then
|
||||
info "Building Backspace image from commit ${BUILD_COMMIT} (this may take a few minutes on first run)..."
|
||||
|
||||
build_from_source() {
|
||||
if [[ -n "$BUILD_COMMIT" ]]; then
|
||||
info "Building Backspace image from commit ${BUILD_COMMIT} (first build takes a few minutes)..."
|
||||
else
|
||||
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
|
||||
info "Building Backspace image (this may take a few minutes on first run)..."
|
||||
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
|
||||
$COMPOSE build --quiet --build-arg BACKSPACE_COMMIT="$BUILD_COMMIT"
|
||||
|
||||
info "Starting services..."
|
||||
$COMPOSE up -d
|
||||
@@ -427,92 +703,312 @@ 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)"
|
||||
fi
|
||||
|
||||
# ── Phase 7.5: Verify HTTPS reachability ───────────────────
|
||||
# The internal healthcheck only proves the app is up *inside* Docker. What the
|
||||
# operator actually cares about is whether https://DOMAIN works — which needs
|
||||
# Caddy to have obtained a publicly-trusted TLS certificate, and that only
|
||||
# happens once DNS points here AND ports 80/443 are reachable from the internet.
|
||||
#
|
||||
# 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.
|
||||
# ── Phase 7.5: Post-deploy reachability check ──────────────
|
||||
# What's verifiable differs by mode:
|
||||
# allinone → prove https://DOMAIN works end-to-end (Caddy has a valid,
|
||||
# publicly-trusted certificate AND the app answers over it).
|
||||
# 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.
|
||||
|
||||
https_status="skipped"
|
||||
if [[ "$healthy" == true ]]; then
|
||||
step "Verifying HTTPS"
|
||||
https_status="pending"
|
||||
app_reachable="unknown"
|
||||
|
||||
info "Checking for a valid TLS certificate on ${DOMAIN} (Caddy issues it on first start)..."
|
||||
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
|
||||
https_status="live"
|
||||
break
|
||||
if [[ "$DEPLOY_MODE" == "allinone" ]]; then
|
||||
if [[ "$healthy" == true ]]; then
|
||||
step "Verifying HTTPS"
|
||||
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)..."
|
||||
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
|
||||
https_status="live"
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
if [[ "$https_status" == "live" ]]; then
|
||||
success "HTTPS is live — a valid TLS certificate is installed and Backspace is serving over it."
|
||||
else
|
||||
warn "HTTPS is not live yet — Caddy hasn't obtained a publicly-trusted certificate."
|
||||
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
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [[ "$https_status" == "live" ]]; then
|
||||
success "HTTPS is live — a valid TLS certificate is installed and Backspace is serving over it."
|
||||
else
|
||||
warn "HTTPS is not live yet — Caddy hasn't obtained a publicly-trusted certificate."
|
||||
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 ───────────────────────────────────────
|
||||
|
||||
step "Backspace is running"
|
||||
|
||||
echo -e " ${BOLD}URL:${NC} https://${DOMAIN}"
|
||||
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)"
|
||||
case "$https_status" in
|
||||
live) echo -e " ${BOLD}HTTPS:${NC} ${GREEN}Live${NC}" ;;
|
||||
pending) echo -e " ${BOLD}HTTPS:${NC} ${YELLOW}Not live yet${NC}" ;;
|
||||
esac
|
||||
echo ""
|
||||
|
||||
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}This is normal right after install; it comes up automatically once BOTH are true:${NC}"
|
||||
echo " 1. ${DOMAIN} resolves to THIS host's public IP"
|
||||
echo " 2. Ports 80 and 443 are open and forwarded to this host from the internet"
|
||||
echo -e " Watch progress: ${BOLD}docker compose logs -f caddy${NC}"
|
||||
if [[ "$DEPLOY_MODE" == "allinone" ]]; then
|
||||
case "$https_status" in
|
||||
live) echo -e " ${BOLD}HTTPS:${NC} ${GREEN}Live${NC}" ;;
|
||||
pending) echo -e " ${BOLD}HTTPS:${NC} ${YELLOW}Not live yet${NC}" ;;
|
||||
esac
|
||||
echo ""
|
||||
echo -e " ${YELLOW}Then open https://${DOMAIN} and create the first account — it becomes the instance admin.${NC}"
|
||||
else
|
||||
echo -e " ${YELLOW}Open https://${DOMAIN} and create the first account — it becomes the instance admin.${NC}"
|
||||
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}This is normal right after install; it comes up automatically once BOTH are true:${NC}"
|
||||
echo " 1. ${DOMAIN} resolves to THIS host's public IP"
|
||||
echo " 2. Ports 80 and 443 are open and forwarded to this host from the internet"
|
||||
echo -e " Watch progress: ${BOLD}docker compose logs -f caddy${NC}"
|
||||
echo ""
|
||||
echo -e " ${YELLOW}Then open https://${DOMAIN} and create the first account — it becomes the instance admin.${NC}"
|
||||
else
|
||||
echo -e " ${YELLOW}Open https://${DOMAIN} and create the first account — it becomes the instance admin.${NC}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
# Best-effort primary LAN IP (the address a router would port-forward to).
|
||||
LAN_IP=$(ip route get 1.1.1.1 2>/dev/null | grep -oP 'src \K[0-9.]+' | head -1 || true)
|
||||
echo -e " ${BOLD}Ports to open${NC} — on this host's firewall (ufw / firewalld / cloud"
|
||||
echo -e " security group)${BOLD} and,${NC} if the host is behind a router, also"
|
||||
echo -e " port-forward them to this host${LAN_IP:+ (${LAN_IP})}:"
|
||||
echo ""
|
||||
echo " 80/TCP HTTP — cert challenge + HTTP→HTTPS redirect (required)"
|
||||
echo " 443/TCP HTTPS — web app, API, WebSocket, LiveKit signal (required)"
|
||||
if [[ "$ENABLE_VOICE" == true ]]; then
|
||||
echo " 3478/UDP TURN — WebRTC NAT traversal (voice)"
|
||||
echo " 7881/TCP WebRTC TCP fallback (voice)"
|
||||
echo " 50000-60000/UDP WebRTC media — voice / video / screen-share (voice)"
|
||||
fi
|
||||
echo ""
|
||||
echo -e " ${YELLOW}80 and 443 must be reachable from the internet before HTTPS can come up.${NC}"
|
||||
if [[ "$ENABLE_VOICE" == true ]]; then
|
||||
echo -e " ${YELLOW}Voice/video won't connect until the voice ports above are reachable too.${NC}"
|
||||
fi
|
||||
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}"
|
||||
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 up -d --build # Rebuild after code changes"
|
||||
|
||||
echo ""
|
||||
# Best-effort primary LAN IP (the address a router would port-forward to).
|
||||
LAN_IP=$(ip route get 1.1.1.1 2>/dev/null | grep -oP 'src \K[0-9.]+' | head -1 || true)
|
||||
echo -e " ${BOLD}Ports to open${NC} — on this host's firewall (ufw / firewalld / cloud"
|
||||
echo -e " security group)${BOLD} and,${NC} if the host is behind a router, also"
|
||||
echo -e " port-forward them to this host${LAN_IP:+ (${LAN_IP})}:"
|
||||
echo ""
|
||||
echo " 80/TCP HTTP — cert challenge + HTTP→HTTPS redirect (required)"
|
||||
echo " 443/TCP HTTPS — web app, API, WebSocket, LiveKit signal (required)"
|
||||
if [[ "$ENABLE_VOICE" == true ]]; then
|
||||
echo " 3478/UDP TURN — WebRTC NAT traversal (voice)"
|
||||
echo " 7881/TCP WebRTC TCP fallback (voice)"
|
||||
echo " 50000-60000/UDP WebRTC media — voice / video / screen-share (voice)"
|
||||
fi
|
||||
echo ""
|
||||
echo -e " ${YELLOW}80 and 443 must be reachable from the internet before HTTPS can come up.${NC}"
|
||||
if [[ "$ENABLE_VOICE" == true ]]; then
|
||||
echo -e " ${YELLOW}Voice/video won't connect until the voice ports above are reachable too.${NC}"
|
||||
fi
|
||||
echo -e " LiveKit's own signaling port (7880) stays internal — do ${BOLD}not${NC} forward it."
|
||||
|
||||
echo " docker compose pull && docker compose up -d # Update to the latest prebuilt image"
|
||||
echo ""
|
||||
|
||||
+5
-2
@@ -13,11 +13,13 @@
|
||||
"scripts": {
|
||||
"dev:server": "pnpm --filter @backspace/server 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:server": "pnpm --filter @backspace/server build",
|
||||
"build:web": "pnpm --filter @backspace/web build",
|
||||
"build": "pnpm --filter @backspace/shared build && pnpm --filter @backspace/server build && pnpm --filter @backspace/web build",
|
||||
"typecheck": "pnpm --filter @backspace/shared build && pnpm -r typecheck",
|
||||
"test": "pnpm -r test",
|
||||
"dev:desktop": "pnpm --filter @backspace/desktop dev",
|
||||
"build:desktop": "pnpm --filter @backspace/desktop build",
|
||||
"gen-icons": "node scripts/gen-icons.mjs"
|
||||
@@ -38,8 +40,9 @@
|
||||
"uiohook-napi@1.5.5": "patches/uiohook-napi@1.5.5.patch"
|
||||
}
|
||||
},
|
||||
"packageManager": "pnpm@10.34.3",
|
||||
"engines": {
|
||||
"node": ">=20.0.0",
|
||||
"pnpm": ">=8.0.0"
|
||||
"pnpm": ">=10.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"clean": "rm -rf dist dist-electron",
|
||||
"test": "vitest run",
|
||||
"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": {
|
||||
"electron-updater": "^6.3.0",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"author": "Jannis Braun",
|
||||
"type": "module",
|
||||
"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",
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit",
|
||||
@@ -25,7 +25,7 @@
|
||||
"@tus/file-store": "^1.5.1",
|
||||
"@tus/server": "^1.10.2",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"better-sqlite3": "^11.3.0",
|
||||
"better-sqlite3": "^12.11.1",
|
||||
"cheerio": "^1.0.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"drizzle-orm": "^0.33.0",
|
||||
@@ -41,6 +41,7 @@
|
||||
"@types/jsonwebtoken": "^9.0.6",
|
||||
"@types/node": "^20.14.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"cross-env": "^7.0.3",
|
||||
"drizzle-kit": "^0.24.0",
|
||||
"typescript": "^5.4.0",
|
||||
"vitest": "^4.0.18",
|
||||
|
||||
@@ -200,6 +200,57 @@ describe('POST /api/dm/space-invite', () => {
|
||||
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 () => {
|
||||
(getLocalInviteSnapshot as unknown as ReturnType<typeof vi.fn>).mockReturnValueOnce({
|
||||
spaceId: 'S1',
|
||||
|
||||
@@ -2393,6 +2393,19 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
|
||||
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).
|
||||
const dmChannelId = ensureOneOnOneDmChannel(callerId, targetUser, db);
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)) {
|
||||
// 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 });
|
||||
}
|
||||
|
||||
// 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
|
||||
if (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 });
|
||||
}
|
||||
|
||||
// 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();
|
||||
db.insert(schema.spaceMembers).values({
|
||||
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 });
|
||||
}
|
||||
|
||||
// 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();
|
||||
db.insert(schema.spaceMembers).values({
|
||||
spaceId: server.id,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { generateSnowflake } from './snowflake.js';
|
||||
import { getDmMessageWithUser } from '../routes/dm.js';
|
||||
import { connectionManager } from '../ws/handler.js';
|
||||
import { generateThumbnail } from './thumbnail.js';
|
||||
import { safeFetch } from './ssrf.js';
|
||||
import type { FederationRelayRequest, FederationRelayResponse, FederationRelayEvent } from '@backspace/shared';
|
||||
import { startupBootstrapSync, onPeerDeactivated } from './federationPeerActivation.js';
|
||||
import { probePeerReachable, recoverOrDetectReset, detectResetOnNeedsAttentionPeers, detectResetForPeer } from './federationRecovery.js';
|
||||
@@ -840,7 +841,8 @@ async function processFileQueueEntry(
|
||||
maxUploadSize: number,
|
||||
now: number,
|
||||
): 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 {
|
||||
const sourceHostname = new URL(entry.sourceUrl).hostname;
|
||||
const peerHostname = new URL(entry.peerOrigin).hostname;
|
||||
@@ -885,7 +887,7 @@ async function processFileQueueEntry(
|
||||
fileQueueAbortController = new AbortController();
|
||||
|
||||
try {
|
||||
const response = await fetch(entry.sourceUrl, {
|
||||
const response = await safeFetch(entry.sourceUrl, {
|
||||
signal: AbortSignal.any([
|
||||
fileQueueAbortController.signal,
|
||||
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();
|
||||
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)
|
||||
const everyoneRole = db.select().from(schema.roles)
|
||||
.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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
@@ -473,9 +473,16 @@ function validateActivities(raw: unknown): Activity[] | null {
|
||||
if (obj.assets && typeof obj.assets === 'object') {
|
||||
const aObj = obj.assets as Record<string, unknown>;
|
||||
const assets: ActivityAssets = {};
|
||||
if (typeof aObj.largeImage === 'string' && aObj.largeImage.length <= ACTIVITY_LIMITS.MAX_URL_LENGTH) assets.largeImage = aObj.largeImage;
|
||||
// Image assets are rendered as <img src> by clients, so they get the same
|
||||
// scheme check `url` above already has. Without it a client could point
|
||||
// them at a host it controls and harvest the IP of everyone who opens
|
||||
// that profile — and data: URIs would smuggle payloads through a field
|
||||
// only length-checked.
|
||||
if (typeof aObj.largeImage === 'string' && aObj.largeImage.length <= ACTIVITY_LIMITS.MAX_URL_LENGTH
|
||||
&& isHttpUrl(aObj.largeImage)) assets.largeImage = aObj.largeImage;
|
||||
if (typeof aObj.largeText === 'string' && aObj.largeText.length <= ACTIVITY_LIMITS.MAX_ASSET_TEXT_LENGTH) assets.largeText = aObj.largeText;
|
||||
if (typeof aObj.smallImage === 'string' && aObj.smallImage.length <= ACTIVITY_LIMITS.MAX_URL_LENGTH) assets.smallImage = aObj.smallImage;
|
||||
if (typeof aObj.smallImage === 'string' && aObj.smallImage.length <= ACTIVITY_LIMITS.MAX_URL_LENGTH
|
||||
&& isHttpUrl(aObj.smallImage)) assets.smallImage = aObj.smallImage;
|
||||
if (typeof aObj.smallText === 'string' && aObj.smallText.length <= ACTIVITY_LIMITS.MAX_ASSET_TEXT_LENGTH) assets.smallText = aObj.smallText;
|
||||
if (Object.keys(assets).length > 0) activity.assets = assets;
|
||||
}
|
||||
@@ -485,6 +492,10 @@ function validateActivities(raw: unknown): Activity[] | null {
|
||||
return validated;
|
||||
}
|
||||
|
||||
function isHttpUrl(value: string): boolean {
|
||||
return value.startsWith('https://') || value.startsWith('http://');
|
||||
}
|
||||
|
||||
function handlePresenceUpdate(event: Record<string, unknown>, userId: string): void {
|
||||
const status = event.status as string;
|
||||
|
||||
|
||||
@@ -54,6 +54,18 @@ function seedSpace(spaceId: string): void {
|
||||
}).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 {
|
||||
testDb.insert(schema.channels).values({
|
||||
id,
|
||||
@@ -178,6 +190,7 @@ describe('connectionManager.buildSpaceVoiceState', () => {
|
||||
const privateCh = 'vc-private-1';
|
||||
seedSpace(spaceId);
|
||||
seedEveryoneRole(spaceId);
|
||||
seedMember(spaceId, 'u-viewer');
|
||||
seedChannel(publicCh, spaceId, 'voice');
|
||||
seedChannel(privateCh, spaceId, 'voice');
|
||||
seedDenyViewOverride(privateCh, spaceId);
|
||||
@@ -204,6 +217,7 @@ describe('connectionManager.addUserSpace voice-state push', () => {
|
||||
const voiceCh = 'vc-push-1';
|
||||
seedSpace(spaceId);
|
||||
seedEveryoneRole(spaceId);
|
||||
seedMember(spaceId, 'u-joiner');
|
||||
seedChannel(voiceCh, spaceId, 'voice');
|
||||
|
||||
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 { peerInstances } from './helpers/seedPeer.js';
|
||||
import { registerLocal } from './helpers/testUsers.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 sharedSecret: string;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import {
|
||||
bootTwoInstancesForHandshake,
|
||||
registerAdmin,
|
||||
@@ -11,6 +11,12 @@ import {
|
||||
import type { TwoInstanceHarness } from './helpers/twoInstanceHarness.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.
|
||||
*
|
||||
|
||||
@@ -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 { peerInstances } from './helpers/seedPeer.js';
|
||||
import type { TestUser } from './helpers/testUsers.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 sharedHmacSecret: string;
|
||||
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { defineConfig, configDefaults } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
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": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"preview": "vite preview",
|
||||
"e2e:identity-deletion": "playwright test e2e/identity-deletion.spec.ts"
|
||||
},
|
||||
|
||||
@@ -32,6 +32,10 @@ export class AudioManager {
|
||||
private rnnoiseReady = false;
|
||||
private keepAliveOscillator: OscillatorNode | null = null;
|
||||
|
||||
// Mic test (settings → Voice). See startMicTest().
|
||||
private micTestGain: GainNode | null = null;
|
||||
private micTestStream: MediaStream | null = null;
|
||||
|
||||
// Cached `getUserMedia` denial. After a NotAllowedError, subsequent
|
||||
// `setInputDevice` calls (e.g. `useLiveKit.syncMic` racing the user's
|
||||
// tap on a denial prompt) re-throw the cached error WITHOUT issuing a
|
||||
@@ -567,6 +571,65 @@ export class AudioManager {
|
||||
osc.stop(now + 0.45);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mic test: routes the processed input bus to the speakers so the user hears
|
||||
* themselves, outside of any call.
|
||||
*
|
||||
* Settings deliberately never opened the mic on their own — the level meter
|
||||
* only measures a stream that a call had already established. A mic test
|
||||
* cannot honour that, so this is the one path that opens it, and
|
||||
* `stopMicTest` hands it back rather than leaving the mic indicator lit.
|
||||
*
|
||||
* Returns false when the mic could not be opened (denied, unplugged).
|
||||
*/
|
||||
async startMicTest(): Promise<boolean> {
|
||||
if (this.micTestGain) return true;
|
||||
const ctx = this.ensureContext();
|
||||
await this.resumeContext();
|
||||
|
||||
const hadStream = this.hasActiveStream();
|
||||
if (!hadStream) {
|
||||
const stream = await this.setInputDevice(this.currentInputDeviceId);
|
||||
if (!stream) return false;
|
||||
// Remember the exact stream we opened, so stopMicTest only ever stops
|
||||
// that one — never a stream something else established meanwhile.
|
||||
this.micTestStream = this.currentStream;
|
||||
}
|
||||
|
||||
this.micTestGain = ctx.createGain();
|
||||
this.inputGain!.connect(this.micTestGain);
|
||||
this.micTestGain.connect(this.getMasterOutput());
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tears down the loopback.
|
||||
*
|
||||
* @param allowRelease Whether the mic may be handed back. Only the caller
|
||||
* knows whether a call has started since the test began — AudioManager
|
||||
* does not read stores — so releasing needs its consent as well as our own
|
||||
* record that this test is what opened the stream.
|
||||
*/
|
||||
stopMicTest(allowRelease: boolean): void {
|
||||
if (!this.micTestGain) return;
|
||||
try { this.inputGain?.disconnect(this.micTestGain); } catch { /* graph already torn down */ }
|
||||
try { this.micTestGain.disconnect(); } catch { /* already detached */ }
|
||||
this.micTestGain = null;
|
||||
|
||||
if (allowRelease && this.micTestStream && this.currentStream === this.micTestStream) {
|
||||
// Detach listeners before stopping (see `_setInputDeviceImpl`).
|
||||
const tracks = this.currentStream.getTracks();
|
||||
tracks.forEach(t => { t.onended = null; });
|
||||
tracks.forEach(t => t.stop());
|
||||
this.currentStream = null;
|
||||
}
|
||||
this.micTestStream = null;
|
||||
}
|
||||
|
||||
isMicTestActive(): boolean {
|
||||
return this.micTestGain !== null;
|
||||
}
|
||||
|
||||
getContext(): AudioContext | null {
|
||||
return this.ctx;
|
||||
}
|
||||
|
||||
@@ -43,11 +43,7 @@ export const MentionBadge = React.memo(function MentionBadge({ userId }: Mention
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
if (!member || !memberUser) return;
|
||||
e.stopPropagation();
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
openUserProfile(memberUser, {
|
||||
top: Math.min(rect.top, window.innerHeight - 450),
|
||||
left: rect.right + 8,
|
||||
});
|
||||
openUserProfile(memberUser, e.currentTarget.getBoundingClientRect());
|
||||
};
|
||||
|
||||
// Build inline styles: role-colored text with tinted background
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { MessageWithUser, Embed, User } from '@backspace/shared';
|
||||
import { MarkdownRenderer } from './MarkdownRenderer';
|
||||
import { MentionBadge } from './MentionBadge';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { ProfileAvatar } from '../ui/ProfileAvatar';
|
||||
import { useContextMenuStore } from '../../stores/contextMenuStore';
|
||||
import { buildMessageMenuItems } from './messageMenuItems';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
@@ -266,11 +267,7 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
|
||||
const handleUsernameClick = (e: React.MouseEvent) => {
|
||||
if (!message.user) return;
|
||||
e.stopPropagation();
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
openUserProfile(message.user, {
|
||||
top: Math.min(rect.top, window.innerHeight - 450),
|
||||
left: rect.right + 16,
|
||||
});
|
||||
openUserProfile(message.user, e.currentTarget.getBoundingClientRect());
|
||||
};
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
@@ -417,7 +414,7 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
|
||||
<div className="w-10 flex-shrink-0 flex items-start justify-start">
|
||||
{isFirstInGroup || message.replyTo ? (
|
||||
<div className="mt-0.5">
|
||||
<Avatar
|
||||
<ProfileAvatar
|
||||
src={displayIdentity.avatar}
|
||||
name={displayName}
|
||||
size={40}
|
||||
|
||||
@@ -959,8 +959,14 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
|
||||
title="GIF"
|
||||
aria-label="GIF picker"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M2 5.5A2.5 2.5 0 0 1 4.5 3h15A2.5 2.5 0 0 1 22 5.5v13a2.5 2.5 0 0 1-2.5 2.5h-15A2.5 2.5 0 0 1 2 18.5v-13ZM5.1 14V10h3.2v1.2H6.5v.6h1.6v1.1H6.5V14H5.1Zm4.5 0V10h1.4v4H9.6Zm2.5 0V10h3.2v1.2h-1.8v.5h1.6v1h-1.6V14h-1.4Z" />
|
||||
{/* Outlined badge, not a filled block: the solid rectangle read as
|
||||
a plain square rather than a GIF picker. Letters reuse the
|
||||
original glyph paths, scaled and centred inside the outline. */}
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<rect x="3" y="6" width="18" height="12" rx="3" stroke="currentColor" strokeWidth="2" />
|
||||
<g fill="currentColor" transform="translate(-1.77 -4.2) scale(1.35)">
|
||||
<path d="M5.1 14V10h3.2v1.2H6.5v.6h1.6v1.1H6.5V14H5.1Zm4.5 0V10h1.4v4H9.6Zm2.5 0V10h3.2v1.2h-1.8v.5h1.6v1h-1.6V14h-1.4Z" />
|
||||
</g>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type PendingBubble,
|
||||
} from '../../stores/pendingMessageStore';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { ProfileAvatar } from '../ui/ProfileAvatar';
|
||||
import { AvatarStack } from '../ui/AvatarStack';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
||||
@@ -832,11 +833,7 @@ function WelcomeHeader({ channelId }: { channelId: string }) {
|
||||
|
||||
const handleOwnerClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (!ownerMember) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
openUserProfile(ownerMember, {
|
||||
top: Math.min(rect.bottom + 8, window.innerHeight - 450),
|
||||
left: rect.left,
|
||||
});
|
||||
openUserProfile(ownerMember, e.currentTarget.getBoundingClientRect(), 'bottom');
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -896,7 +893,7 @@ function WelcomeHeader({ channelId }: { channelId: string }) {
|
||||
return (
|
||||
<div className="px-4 pt-8 pb-4">
|
||||
<div className="mb-2">
|
||||
<Avatar src={otherUser?.avatar} name={displayName} size={80} user={otherUser ?? undefined} />
|
||||
<ProfileAvatar src={otherUser?.avatar} name={displayName} size={80} user={otherUser ?? undefined} />
|
||||
</div>
|
||||
<h3 className="text-[32px] leading-10 font-bold text-txt-primary">{displayName}</h3>
|
||||
<p className="text-txt-secondary text-[14px] mt-1">
|
||||
|
||||
@@ -104,7 +104,6 @@ export function ActivityPanel() {
|
||||
|
||||
const handleFriendClick = (e: React.MouseEvent, friend: Friend) => {
|
||||
e.stopPropagation();
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
openUserProfile(
|
||||
{
|
||||
id: friend.id,
|
||||
@@ -123,10 +122,8 @@ export function ActivityPanel() {
|
||||
isAdmin: false,
|
||||
replicatedInstances: [],
|
||||
},
|
||||
{
|
||||
top: Math.min(rect.top, window.innerHeight - 450),
|
||||
left: rect.left - 316,
|
||||
}
|
||||
e.currentTarget.getBoundingClientRect(),
|
||||
'left',
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -463,7 +463,7 @@ export function AppLayout() {
|
||||
<UpdateToast />
|
||||
|
||||
{/* User Profile Popout */}
|
||||
{userProfilePopout.user && userProfilePopout.position && (
|
||||
{userProfilePopout.user && userProfilePopout.anchor && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-[145]"
|
||||
@@ -472,7 +472,8 @@ export function AppLayout() {
|
||||
<UserProfilePopout
|
||||
user={userProfilePopout.user}
|
||||
onClose={closeUserProfile}
|
||||
position={userProfilePopout.position}
|
||||
anchor={userProfilePopout.anchor}
|
||||
placement={userProfilePopout.placement}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useInstanceStore } from '../../stores/instanceStore';
|
||||
import { VoiceChannel } from '../voice/VoiceChannel';
|
||||
import { VoiceControls } from '../voice/VoiceControls';
|
||||
import { useVoiceStore } from '../../stores/voiceStore';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { ProfileAvatar } from '../ui/ProfileAvatar';
|
||||
import { Mascot } from '../ui/Mascot';
|
||||
import { wsSend } from '../../hooks/useWebSocket';
|
||||
import { AudioManager } from '../../audio/AudioManager';
|
||||
@@ -1135,7 +1135,7 @@ function UserAreaPanel({
|
||||
<div className="h-[52px] px-2 flex items-center select-none">
|
||||
{/* Avatar + name */}
|
||||
<div className="p-1 hover:bg-interactive-hover rounded-[4px] flex items-center gap-2 flex-1 min-w-0 cursor-pointer transition-colors group">
|
||||
<Avatar src={user.avatar} name={user.displayName ?? user.username} size={34} status={user.status as any} user={user} />
|
||||
<ProfileAvatar src={user.avatar} name={user.displayName ?? user.username} size={34} status={user.status} user={user} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[13.5px] font-semibold text-txt-primary truncate leading-tight">{user.displayName ?? user.username}</div>
|
||||
<div className="text-[11px] text-txt-tertiary truncate leading-tight group-hover:text-txt-secondary">@{user.username}</div>
|
||||
|
||||
@@ -285,45 +285,17 @@ describe('DmMemberRow — profile popout anchoring', () => {
|
||||
await user.click(profileBtn);
|
||||
|
||||
expect(openUserProfileMock).toHaveBeenCalledTimes(1);
|
||||
// The row hands over its rect and the side it wants; the card works out its
|
||||
// own coordinates once it knows how tall it is (see UserProfilePopout).
|
||||
expect(openUserProfileMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: member.id }),
|
||||
// Math.min(200, 800 - 450) = 200; left = 1500 - 316 = 1184.
|
||||
{ top: 200, left: 1184 },
|
||||
rect,
|
||||
'left',
|
||||
);
|
||||
// The row no longer routes 'profile' through onMenuAction.
|
||||
expect(onMenuAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clamps top to (innerHeight - 450) when the row sits near the bottom of the viewport', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = renderRow();
|
||||
const row = container.querySelector('[data-dm-member-row]') as HTMLElement;
|
||||
|
||||
const rect: DOMRect = {
|
||||
top: 700,
|
||||
left: 1500,
|
||||
right: 1740,
|
||||
bottom: 740,
|
||||
width: 240,
|
||||
height: 40,
|
||||
x: 1500,
|
||||
y: 700,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect;
|
||||
row.getBoundingClientRect = () => rect;
|
||||
|
||||
Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true });
|
||||
|
||||
openMenuByContextMenu(row);
|
||||
await user.click(await screen.findByText('View Profile'));
|
||||
|
||||
// Math.min(700, 800 - 450 = 350) → top clamped to 350.
|
||||
expect(openUserProfileMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ top: 350, left: 1184 },
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to onMenuAction("profile", ...) when the row has no bounding rect', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container, onMenuAction, member } = renderRow();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useRef } from 'react';
|
||||
import type { User } from '@backspace/shared';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { ProfileAvatar } from '../ui/ProfileAvatar';
|
||||
import { Username } from '../ui/Username';
|
||||
import { Tooltip } from '../ui/Tooltip';
|
||||
import { parseFederatedUsername, isFederationGlobeApplicable } from '../../utils/identity';
|
||||
@@ -104,14 +104,11 @@ export function DmMemberRow({
|
||||
label: 'View Profile',
|
||||
onClick: () => {
|
||||
// Anchor the popout to this row's bounding rect — matches the
|
||||
// MemberSidebar pattern (see MemberSidebar.tsx:158-165). On mobile
|
||||
// the position arg is ignored by the store (full-screen push).
|
||||
// MemberSidebar pattern (see MemberSidebar.tsx). On mobile the anchor
|
||||
// is ignored by the store (full-screen push).
|
||||
const rect = rowRef.current?.getBoundingClientRect();
|
||||
if (rect) {
|
||||
useUIStore.getState().openUserProfile(canonical, {
|
||||
top: Math.min(rect.top, window.innerHeight - 450),
|
||||
left: rect.left - 316,
|
||||
});
|
||||
useUIStore.getState().openUserProfile(canonical, rect, 'left');
|
||||
} else {
|
||||
// Fallback: defer to the consumer if we can't compute a rect
|
||||
// (shouldn't happen in practice, but keeps the contract intact).
|
||||
@@ -183,12 +180,13 @@ export function DmMemberRow({
|
||||
className="group flex items-center gap-2.5 px-2 py-1.5 rounded-[6px] hover:bg-interactive-hover transition-colors select-none"
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<Avatar
|
||||
<ProfileAvatar
|
||||
src={canonical.avatar}
|
||||
name={displayName}
|
||||
size={32}
|
||||
status={isOffline ? null : canonical.status}
|
||||
user={canonical}
|
||||
placement="left"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { isSelf, parseFederatedUsername } from '../../utils/identity';
|
||||
import { api } from '../../api/client';
|
||||
import { ConfirmDialog } from '../ui/ConfirmDialog';
|
||||
import { DmMemberRow, type DmMemberRowAction } from './DmMemberRow';
|
||||
import { pointAnchor } from '../../hooks/useFloatingPosition';
|
||||
|
||||
/**
|
||||
* Right-side roster for group DMs. Mirrors `MemberSidebar`'s layout language
|
||||
@@ -93,7 +94,7 @@ export function DmRosterPanel() {
|
||||
// MemberSidebar pattern). This branch only fires on the unlikely
|
||||
// fallback path where the row couldn't compute its bounding rect —
|
||||
// in that case, anchor to the top-left of the roster column.
|
||||
openUserProfile(member, { top: 100, left: 100 });
|
||||
openUserProfile(member, pointAnchor(100, 100));
|
||||
return;
|
||||
}
|
||||
if (action === 'kick') {
|
||||
|
||||
@@ -157,11 +157,7 @@ export function MemberSidebar() {
|
||||
|
||||
const handleMemberClick = (e: React.MouseEvent, user: MemberWithUser['user']) => {
|
||||
e.stopPropagation();
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
openUserProfile(user, {
|
||||
top: Math.min(rect.top, window.innerHeight - 450),
|
||||
left: rect.left - 316,
|
||||
});
|
||||
openUserProfile(user, e.currentTarget.getBoundingClientRect(), 'left');
|
||||
};
|
||||
|
||||
const renderMember = (member: MemberWithUser, isOffline = false) => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { api } from '../../api/client';
|
||||
import { isSelf, parseFederatedUsername } from '../../utils/identity';
|
||||
import { AvatarStack } from '../ui/AvatarStack';
|
||||
import { DmMemberRow, type DmMemberRowAction } from '../layout/DmMemberRow';
|
||||
import { pointAnchor } from '../../hooks/useFloatingPosition';
|
||||
|
||||
const MAX_NAME_LENGTH = 50;
|
||||
const MAX_GROUP_MEMBERS = 10;
|
||||
@@ -263,7 +264,7 @@ export function GroupDmSettings() {
|
||||
// Fallback path — DmMemberRow normally opens the profile itself via
|
||||
// its own bounding rect. If we reach this branch, just route to a
|
||||
// top-left anchor (matches DmRosterPanel's fallback).
|
||||
useUIStore.getState().openUserProfile(member, { top: 100, left: 100 });
|
||||
useUIStore.getState().openUserProfile(member, pointAnchor(100, 100));
|
||||
return;
|
||||
}
|
||||
if (action === 'kick') {
|
||||
|
||||
@@ -425,6 +425,32 @@ describe('InviteModal', () => {
|
||||
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 () => {
|
||||
const user = userEvent.setup();
|
||||
mockSpaceInvite.mockResolvedValue({});
|
||||
|
||||
@@ -149,6 +149,10 @@ export function InviteModal() {
|
||||
const isOpen = activeModal === 'invite';
|
||||
const currentSpace = spaces.find((s) => s.id === currentSpaceId);
|
||||
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 [codeError, setCodeError] = useState('');
|
||||
@@ -167,7 +171,7 @@ export function InviteModal() {
|
||||
|
||||
// Fetch / generate the per-space invite code on open.
|
||||
useEffect(() => {
|
||||
if (!isOpen || !currentSpaceId) return;
|
||||
if (!isOpen || !currentSpaceId || isRequestOnly) return;
|
||||
setCodeLoading(true);
|
||||
setCodeError('');
|
||||
generateInvite(currentSpaceId).then(
|
||||
@@ -180,7 +184,7 @@ export function InviteModal() {
|
||||
setCodeLoading(false);
|
||||
},
|
||||
);
|
||||
}, [isOpen, currentSpaceId, generateInvite]);
|
||||
}, [isOpen, currentSpaceId, generateInvite, isRequestOnly]);
|
||||
|
||||
// Reset modal state on open.
|
||||
useEffect(() => {
|
||||
@@ -333,6 +337,20 @@ export function InviteModal() {
|
||||
title="Invite Friends"
|
||||
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">
|
||||
<p className="text-[13px] text-txt-tertiary">
|
||||
Send to friends, or share a link.
|
||||
@@ -460,6 +478,7 @@ export function InviteModal() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { useNavigate } from 'react-router-dom';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import type { User } from '@backspace/shared';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { ProfileActivity } from '../ui/ProfileActivity';
|
||||
import { useActivityStore } from '../../stores/activityStore';
|
||||
import { Username } from '../ui/Username';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { useSpaceStore, getApiForOrigin, resolveUserOrigin } from '../../stores/spaceStore';
|
||||
@@ -149,6 +151,13 @@ export function UserProfileModal() {
|
||||
|
||||
// Banner — use correct API client for remote users
|
||||
const profileApi = getApiForOrigin(userOrigin);
|
||||
// Keyed by home id, matching every other activity consumer (ActivityPanel,
|
||||
// MemberSidebar), so federated users resolve to the same record. The `?? []`
|
||||
// stays OUTSIDE the selector: building it inside would hand zustand a fresh
|
||||
// array reference every render and spin.
|
||||
const activityList = useActivityStore((s) => s.userActivities.get(user.homeUserId ?? user.id));
|
||||
const activities = activityList ?? [];
|
||||
|
||||
const bannerSrc = user.banner
|
||||
? (user.banner.startsWith('http') ? user.banner : profileApi.uploads.url(user.banner))
|
||||
: null;
|
||||
@@ -353,6 +362,9 @@ export function UserProfileModal() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current activity — the "Listening to Spotify" block */}
|
||||
<ProfileActivity activities={activities} />
|
||||
|
||||
{/* Member Since */}
|
||||
<div>
|
||||
<span className="text-[11px] uppercase tracking-wide font-semibold text-txt-tertiary">
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useInstanceStore } from '../../../stores/instanceStore';
|
||||
import { useSpaceStore } from '../../../stores/spaceStore';
|
||||
import { Avatar } from '../../ui/Avatar';
|
||||
import { ImageCropModal } from '../../ui/ImageCropModal';
|
||||
import { GifPicker } from '../../chat/GifPicker';
|
||||
import { DeleteAccountModal } from '../DeleteAccountModal';
|
||||
import { api } from '../../../api/client';
|
||||
import { useTransferStore } from '../../../stores/transferStore';
|
||||
@@ -13,6 +14,16 @@ import { getAvatarGradient, adjustColor, mutedGradient, AVATAR_GRADIENT_MAP, BAN
|
||||
import { AVATAR_COLORS } from '@backspace/shared';
|
||||
import type { User, UserStatus, AvatarColor } from '@backspace/shared';
|
||||
import type { FederationOpResult } from '../../../utils/federationOps';
|
||||
/**
|
||||
* Banner/avatar previews hold either a `blob:` object URL (local upload) or a
|
||||
* remote `https:` URL (GIF picker). Only the former owns memory that must be
|
||||
* released — calling revokeObjectURL on a remote URL is a silent no-op that
|
||||
* would quietly hide a mistake here.
|
||||
*/
|
||||
function releasePreview(url: string | null): void {
|
||||
if (url && url.startsWith('blob:')) URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function AccountPanel() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const updateProfile = useAuthStore((s) => s.updateProfile);
|
||||
@@ -37,6 +48,7 @@ export function AccountPanel() {
|
||||
const [bannerFilename, setBannerFilename] = useState<string | null>(null);
|
||||
const [uploadingBanner, setUploadingBanner] = useState(false);
|
||||
const [bannerCropSrc, setBannerCropSrc] = useState<string | null>(null);
|
||||
const [showBannerGif, setShowBannerGif] = useState(false);
|
||||
const bannerInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const addToast = useUIStore((s) => s.addToast);
|
||||
@@ -54,7 +66,7 @@ export function AccountPanel() {
|
||||
setCustomHex(user.accentColor ?? '');
|
||||
// Reset upload state
|
||||
if (avatarPreview) URL.revokeObjectURL(avatarPreview);
|
||||
if (bannerPreview) URL.revokeObjectURL(bannerPreview);
|
||||
releasePreview(bannerPreview);
|
||||
setAvatarPreview(null);
|
||||
setAvatarFilename(null);
|
||||
setBannerPreview(null);
|
||||
@@ -202,7 +214,7 @@ export function AccountPanel() {
|
||||
};
|
||||
|
||||
const handleBannerCropComplete = async (blob: Blob) => {
|
||||
if (bannerPreview) URL.revokeObjectURL(bannerPreview);
|
||||
releasePreview(bannerPreview);
|
||||
const previewUrl = URL.createObjectURL(blob);
|
||||
setBannerPreview(previewUrl);
|
||||
setBannerCropSrc(null);
|
||||
@@ -227,8 +239,21 @@ export function AccountPanel() {
|
||||
setAvatarFilename('');
|
||||
};
|
||||
|
||||
/**
|
||||
* Banners accept absolute URLs end to end: the server's isValidAssetUrl
|
||||
* allows http(s), and the profile render already branches on
|
||||
* `banner.startsWith('http')`. So a picked GIF needs no upload — the remote
|
||||
* URL is stored directly.
|
||||
*/
|
||||
const handleBannerGifSelect = (url: string) => {
|
||||
releasePreview(bannerPreview);
|
||||
setBannerPreview(url);
|
||||
setBannerFilename(url);
|
||||
setShowBannerGif(false);
|
||||
};
|
||||
|
||||
const handleRemoveBanner = () => {
|
||||
if (bannerPreview) URL.revokeObjectURL(bannerPreview);
|
||||
releasePreview(bannerPreview);
|
||||
setBannerPreview(null);
|
||||
setBannerFilename('');
|
||||
};
|
||||
@@ -300,7 +325,7 @@ export function AccountPanel() {
|
||||
setAvatarColorState(user.avatarColor ?? null);
|
||||
setCustomHex(user.accentColor ?? '');
|
||||
if (avatarPreview) URL.revokeObjectURL(avatarPreview);
|
||||
if (bannerPreview) URL.revokeObjectURL(bannerPreview);
|
||||
releasePreview(bannerPreview);
|
||||
setAvatarPreview(null);
|
||||
setAvatarFilename(null);
|
||||
setBannerPreview(null);
|
||||
@@ -482,7 +507,7 @@ export function AccountPanel() {
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<div className="relative flex gap-2 mt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => bannerInputRef.current?.click()}
|
||||
@@ -491,6 +516,23 @@ export function AccountPanel() {
|
||||
>
|
||||
Change Banner
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowBannerGif((v) => !v)}
|
||||
disabled={uploadingBanner}
|
||||
className="text-xs text-accent-primary hover:underline"
|
||||
>
|
||||
Choose GIF
|
||||
</button>
|
||||
{showBannerGif && (
|
||||
<>
|
||||
{/* Click-away layer, below the panel but above the page */}
|
||||
<div className="fixed inset-0 z-[290]" onClick={() => setShowBannerGif(false)} />
|
||||
<div className="absolute left-0 top-full mt-2 z-[300] glass rounded-xl overflow-hidden">
|
||||
<GifPicker onGifSelect={handleBannerGifSelect} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{(displayBannerSrc || user.banner) && bannerFilename !== '' && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -20,6 +20,8 @@ export function AudioInputSection() {
|
||||
// then join voice and expect the meter / resolved-default hint to come
|
||||
// alive without reopening the panel.
|
||||
const [audioCtxGen, setAudioCtxGen] = useState(0);
|
||||
const [micTesting, setMicTesting] = useState(false);
|
||||
const [micTestError, setMicTestError] = useState('');
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const animFrameRef = useRef<number>(0);
|
||||
|
||||
@@ -77,7 +79,39 @@ export function AudioInputSection() {
|
||||
stopped = true;
|
||||
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
|
||||
};
|
||||
}, [permState, audioCtxGen]);
|
||||
}, [permState, audioCtxGen, micTesting]);
|
||||
|
||||
// Subscribed (not a one-off getState) so the hint text below tracks the call
|
||||
// state live. The release decision itself reads getState() at the moment of
|
||||
// stopping, which is when it must be accurate.
|
||||
const isLiveKitConnected = useVoiceStore((s) => s.isLiveKitConnected);
|
||||
|
||||
const toggleMicTest = async () => {
|
||||
const am = AudioManager.getInstance();
|
||||
if (micTesting) {
|
||||
am.stopMicTest(!useVoiceStore.getState().isLiveKitConnected);
|
||||
setMicTesting(false);
|
||||
return;
|
||||
}
|
||||
setMicTestError('');
|
||||
const ok = await am.startMicTest();
|
||||
if (!ok) {
|
||||
setMicTestError('Could not open the microphone. Check the device and its permission.');
|
||||
return;
|
||||
}
|
||||
setMicTesting(true);
|
||||
};
|
||||
|
||||
// Leaving the panel mid-test must not leave the loopback running or the mic
|
||||
// held open.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
const am = AudioManager.getInstance();
|
||||
if (am.isMicTestActive()) {
|
||||
am.stopMicTest(!useVoiceStore.getState().isLiveKitConnected);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Track the resolved upstream deviceId for the "Currently using: X" hint.
|
||||
// Re-runs on `audioCtxGen` because the resolved-default ID is only known
|
||||
@@ -213,9 +247,30 @@ export function AudioInputSection() {
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-xs text-txt-tertiary mt-1.5">
|
||||
The level meter activates once you join a voice channel.
|
||||
<div className="flex items-center gap-3 mt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void toggleMicTest()}
|
||||
disabled={permState !== 'granted'}
|
||||
className={`px-3 py-1.5 rounded-md text-[13px] font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
micTesting
|
||||
? 'bg-interactive-muted text-txt-primary hover:brightness-110'
|
||||
: 'bg-accent-primary text-white hover:brightness-110'
|
||||
}`}
|
||||
>
|
||||
{micTesting ? 'Stop Testing' : "Let's Check"}
|
||||
</button>
|
||||
<span className="text-xs text-txt-tertiary">
|
||||
{micTesting
|
||||
? 'Playing your mic back to you — say something.'
|
||||
: isLiveKitConnected
|
||||
? 'The level meter is live while you are in a call.'
|
||||
: 'Test your mic without joining a call.'}
|
||||
</span>
|
||||
</div>
|
||||
{micTestError && (
|
||||
<div className="text-xs text-txt-danger mt-1.5">{micTestError}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SectionShell>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { render } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { User } from '@backspace/shared';
|
||||
|
||||
import { Avatar } from './Avatar';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
|
||||
function makeUser(): User {
|
||||
return {
|
||||
id: 'u-1',
|
||||
username: 'ada',
|
||||
displayName: 'Ada',
|
||||
avatar: null,
|
||||
banner: null,
|
||||
accentColor: null,
|
||||
avatarColor: null,
|
||||
bio: null,
|
||||
status: 'online',
|
||||
customStatus: null,
|
||||
isAdmin: false,
|
||||
createdAt: 0,
|
||||
homeInstance: null,
|
||||
homeUserId: null,
|
||||
replicatedInstances: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe('Avatar', () => {
|
||||
beforeEach(() => {
|
||||
useUIStore.setState({
|
||||
isMobile: false,
|
||||
userProfilePopout: { user: null, anchor: null, placement: 'right' },
|
||||
});
|
||||
});
|
||||
|
||||
it('is presentational: a `user` prop alone does not make it a profile trigger', async () => {
|
||||
// `user` carries identity for the gradient, colour and status dot. Passing it
|
||||
// must not silently turn the avatar into a popout trigger — otherwise every
|
||||
// avatar inside a modal, settings preview or the profile card itself opens a
|
||||
// second profile card on top of the surface it lives in (issue #37).
|
||||
const { container } = render(<Avatar src={null} name="Ada" size={40} user={makeUser()} />);
|
||||
|
||||
await userEvent.click(container.querySelector('[data-avatar]')!);
|
||||
|
||||
expect(useUIStore.getState().userProfilePopout.user).toBeNull();
|
||||
});
|
||||
|
||||
it('is not focusable or clickable-looking without a handler', () => {
|
||||
const { container } = render(<Avatar src={null} name="Ada" size={40} user={makeUser()} />);
|
||||
|
||||
expect(container.querySelector('[data-avatar]')!.className).not.toContain('cursor-pointer');
|
||||
});
|
||||
|
||||
it('runs an explicit onClick handler', async () => {
|
||||
let clicks = 0;
|
||||
const { container } = render(<Avatar src={null} name="Ada" size={40} user={makeUser()} onClick={() => { clicks++; }} />);
|
||||
|
||||
await userEvent.click(container.querySelector('[data-avatar]')!);
|
||||
|
||||
expect(clicks).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
import type { User } from '@backspace/shared';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { getAvatarGradient } from '../../utils/gradients';
|
||||
|
||||
interface AvatarProps {
|
||||
@@ -56,7 +55,6 @@ function getDotMetrics(avatarSize: number, ringWidth: number = 0) {
|
||||
}
|
||||
|
||||
export function Avatar({ src, name, size = 40, status, className = '', onClick, user, userId, ring, avatarColor }: AvatarProps) {
|
||||
const openUserProfile = useUIStore((s) => s.openUserProfile);
|
||||
const initials = name.charAt(0).toUpperCase();
|
||||
const fontPx = Math.round(size * 0.4);
|
||||
const gradient = getAvatarGradient(userId ?? user?.homeUserId ?? user?.id, name, avatarColor ?? user?.avatarColor);
|
||||
@@ -64,19 +62,6 @@ export function Avatar({ src, name, size = 40, status, className = '', onClick,
|
||||
const ringWidth = ring?.width ?? 0;
|
||||
const outerSize = size + ringWidth * 2;
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
if (onClick) {
|
||||
onClick(e);
|
||||
} else if (user) {
|
||||
e.stopPropagation();
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
openUserProfile(user, {
|
||||
top: Math.min(rect.top, window.innerHeight - 450),
|
||||
left: rect.right + 16,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Only compute mask when status dot is visible
|
||||
const cutoutMask = status ? buildCutoutMask(size, ringWidth) : undefined;
|
||||
const maskStyle: React.CSSProperties | undefined = cutoutMask
|
||||
@@ -88,9 +73,9 @@ export function Avatar({ src, name, size = 40, status, className = '', onClick,
|
||||
return (
|
||||
<div
|
||||
data-avatar
|
||||
className={`relative inline-flex flex-shrink-0 ${(onClick || user) ? 'cursor-pointer' : ''} ${className}`}
|
||||
className={`relative inline-flex flex-shrink-0 ${onClick ? 'cursor-pointer' : ''} ${className}`}
|
||||
style={{ width: outerSize, height: outerSize }}
|
||||
onClick={handleClick}
|
||||
onClick={onClick}
|
||||
>
|
||||
{/* Inner masked circle — ring background + avatar content */}
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { Activity } from '@backspace/shared';
|
||||
import { getPrimaryActivity } from '@backspace/shared/src/activities.js';
|
||||
|
||||
interface ProfileActivityProps {
|
||||
activities: Activity[];
|
||||
}
|
||||
|
||||
const VERB: Record<Activity['type'], string> = {
|
||||
playing: 'Playing',
|
||||
listening: 'Listening to',
|
||||
watching: 'Watching',
|
||||
streaming: 'Streaming',
|
||||
custom: '',
|
||||
};
|
||||
|
||||
function formatClock(ms: number): string {
|
||||
const total = Math.max(0, Math.floor(ms / 1000));
|
||||
const minutes = Math.floor(total / 60);
|
||||
const seconds = total % 60;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours > 0) return `${hours}:${String(minutes % 60).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
||||
return `${minutes}:${String(seconds).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The activity block on the profile card — the "Listening to Spotify" panel.
|
||||
*
|
||||
* Deliberately richer than `ActivityCard` (which renders name + elapsed for
|
||||
* compact list rows): here there is room for the artwork, the track and the
|
||||
* artist, so it reads `details`, `state` and `assets` too. Every one of those
|
||||
* is optional and the block degrades to just the name, which is all today's
|
||||
* process-based detector supplies — the extra fields are what a Spotify
|
||||
* producer would fill in.
|
||||
*/
|
||||
export function ProfileActivity({ activities }: ProfileActivityProps) {
|
||||
const primary = getPrimaryActivity(activities);
|
||||
const start = primary?.timestamps?.start;
|
||||
const end = primary?.timestamps?.end;
|
||||
|
||||
// Re-render once a second only while there is a clock to advance.
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
if (!start) return;
|
||||
const id = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [start]);
|
||||
|
||||
if (!primary || primary.type === 'custom') return null;
|
||||
|
||||
const elapsed = start ? now - start : 0;
|
||||
const duration = start && end ? end - start : 0;
|
||||
const progress = duration > 0 ? Math.min(Math.max(elapsed / duration, 0), 1) : 0;
|
||||
|
||||
// The server restricts asset images to http(s); this mirrors that so a
|
||||
// record stored before that check cannot inject another scheme.
|
||||
const art = primary.assets?.largeImage;
|
||||
const artSrc = art && (art.startsWith('https://') || art.startsWith('http://')) ? art : null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span className="text-[11px] uppercase tracking-wide font-semibold text-txt-tertiary">
|
||||
{VERB[primary.type]} {primary.name}
|
||||
</span>
|
||||
<div className="mt-2 flex gap-3 rounded-lg bg-surface-elevated/40 p-2.5">
|
||||
{artSrc && (
|
||||
<img
|
||||
src={artSrc}
|
||||
alt={primary.assets?.largeText ?? ''}
|
||||
className="w-[60px] h-[60px] rounded object-cover flex-shrink-0"
|
||||
referrerPolicy="no-referrer"
|
||||
loading="lazy"
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
{primary.details && (
|
||||
<div className="text-[13px] font-semibold text-txt-primary truncate">
|
||||
{primary.details}
|
||||
</div>
|
||||
)}
|
||||
{primary.state && (
|
||||
<div className="text-[12px] text-txt-secondary truncate">{primary.state}</div>
|
||||
)}
|
||||
{duration > 0 ? (
|
||||
<div className="mt-2">
|
||||
<div className="h-[3px] rounded-full bg-interactive-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-txt-primary rounded-full"
|
||||
style={{ width: `${progress * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-[10px] text-txt-tertiary mt-1 tabular-nums">
|
||||
<span>{formatClock(elapsed)}</span>
|
||||
<span>{formatClock(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : start ? (
|
||||
<div className="text-[11px] text-txt-tertiary mt-1 tabular-nums">
|
||||
{formatClock(elapsed)} elapsed
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { User } from '@backspace/shared';
|
||||
|
||||
import { ProfileAvatar } from './ProfileAvatar';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
|
||||
function makeUser(): User {
|
||||
return {
|
||||
id: 'u-1',
|
||||
username: 'ada',
|
||||
displayName: 'Ada',
|
||||
avatar: null,
|
||||
banner: null,
|
||||
accentColor: null,
|
||||
avatarColor: null,
|
||||
bio: null,
|
||||
status: 'online',
|
||||
customStatus: null,
|
||||
isAdmin: false,
|
||||
createdAt: 0,
|
||||
homeInstance: null,
|
||||
homeUserId: null,
|
||||
replicatedInstances: [],
|
||||
};
|
||||
}
|
||||
|
||||
function stubRect(el: Element, rect: Partial<DOMRect>) {
|
||||
vi.spyOn(el, 'getBoundingClientRect').mockReturnValue({
|
||||
top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0, x: 0, y: 0,
|
||||
toJSON: () => ({}), ...rect,
|
||||
} as DOMRect);
|
||||
}
|
||||
|
||||
describe('ProfileAvatar', () => {
|
||||
beforeEach(() => {
|
||||
useUIStore.setState({
|
||||
isMobile: false,
|
||||
userProfilePopout: { user: null, anchor: null, placement: 'right' },
|
||||
});
|
||||
});
|
||||
|
||||
it('opens the profile popout anchored to its own box', async () => {
|
||||
const { container } = render(<ProfileAvatar user={makeUser()} name="Ada" size={40} />);
|
||||
const el = container.querySelector('[data-avatar]')!;
|
||||
stubRect(el, { top: 200, left: 100, right: 140, bottom: 240, width: 40, height: 40 });
|
||||
|
||||
await userEvent.click(el);
|
||||
|
||||
const popout = useUIStore.getState().userProfilePopout;
|
||||
expect(popout.user).toMatchObject({ id: 'u-1' });
|
||||
expect(popout.anchor).toMatchObject({ top: 200, left: 100, right: 140, bottom: 240 });
|
||||
expect(popout.placement).toBe('right');
|
||||
});
|
||||
|
||||
it('honours an explicit placement so callers do not hand-roll offsets', async () => {
|
||||
const { container } = render(<ProfileAvatar user={makeUser()} name="Ada" size={40} placement="left" />);
|
||||
const el = container.querySelector('[data-avatar]')!;
|
||||
stubRect(el, { top: 10, left: 900, right: 940, bottom: 50, width: 40, height: 40 });
|
||||
|
||||
await userEvent.click(el);
|
||||
|
||||
expect(useUIStore.getState().userProfilePopout.placement).toBe('left');
|
||||
});
|
||||
|
||||
it('degrades to a plain avatar when the user behind it is unknown', async () => {
|
||||
// Voice tiles and DM intros render before the user record has resolved.
|
||||
const { container } = render(<ProfileAvatar user={undefined} name="?" size={40} />);
|
||||
const el = container.querySelector('[data-avatar]')!;
|
||||
|
||||
await userEvent.click(el);
|
||||
|
||||
expect(useUIStore.getState().userProfilePopout.user).toBeNull();
|
||||
expect(el.className).not.toContain('cursor-pointer');
|
||||
});
|
||||
|
||||
it('stops the click from reaching an enclosing row handler', async () => {
|
||||
let rowClicks = 0;
|
||||
const { container } = render(
|
||||
<div onClick={() => { rowClicks++; }}>
|
||||
<ProfileAvatar user={makeUser()} name="Ada" size={40} />
|
||||
</div>,
|
||||
);
|
||||
const el = container.querySelector('[data-avatar]')!;
|
||||
stubRect(el, { top: 0, left: 0, right: 40, bottom: 40, width: 40, height: 40 });
|
||||
|
||||
await userEvent.click(el);
|
||||
|
||||
expect(rowClicks).toBe(0);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user