Compare commits

...
29 Commits
Author SHA1 Message Date
devsyncwrld 08db5374cb build: compile better-sqlite3 from source (no Node 20 prebuilt)
OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
better-sqlite3@12.11.1 ships prebuilt binaries for ABI 127/137/141/147 only;
Node 20 is ABI 115, so prebuild-install falls back to node-gyp, which fails on
node:20-slim for lack of python3/make/g++.

Add the toolchain to the builder stage, and in the runtime stage install, use
and purge it inside a single layer so the final image ships no compiler.
2026-08-31 11:09:14 -03:00
TheZwiss 6c7cbee808 docs(pr-template): clarify the CLA checkbox is not the signature (#42)
The CLA Assistant only records a signature when the exact phrase is posted as a comment on the PR, so ticking the checkbox alone left the check red with no explanation (see #39). Spell the required comment out next to the checkbox, using the exact phrase from custom-pr-sign-comment in .github/workflows/cla.yml.
2026-08-25 16:36:24 +02:00
cnrd 8456b8f976 fix(web): stop the profile card re-anchoring to its own avatar (#39)
Avatar opened the profile popout whenever it received a user prop. Since user is how every avatar gets its gradient, colour and status dot, all 22 call sites became profile triggers by accident — including the picture inside the profile card itself, which re-anchored the card to that picture on every click and walked it across the screen (120px right, 36px down, until it pinned at the viewport clamp).

Avatar is now presentational. A new ProfileAvatar carries the open-the-profile behaviour at the five call sites that actually want it. The card's own picture escalates to the full profile modal instead of reopening the card.

The card also places itself off its measured size via the shared computeFloatingPosition engine, replacing six call sites that each hand-computed coordinates against a guessed 460px card height.

Closes #37
2026-08-25 15:51:04 +02:00
TheZwiss 747f1b9c5c ci(cla): store CLA signatures on a dedicated branch (#40)
The CLA Assistant appends each signature as a direct commit to the branch named in 'branch:'. That was main, which the 'Require CI on main' ruleset rejects ('Repository rule violations found'), so signatures were never recorded and the check stayed red however often a contributor signed — a deadlock for every outside contribution.

Point the store at the cla-signatures branch. The ruleset targets the default branch only, so the bot can append there without granting any actor a bypass on main. That branch is seeded with the existing signature and carries its own ruleset blocking deletion and non-fast-forward pushes.
2026-08-25 15:46:11 +02:00
TheZwiss 89467d6f93 fix(server): upgrade better-sqlite3 to 12.x for Node 24 compatibility (#41)
better-sqlite3 11.x removes its environment cleanup hook from Statement::~Statement() after the Node environment is torn down. Node 24.19.0 asserts on the null environment and aborts the worker, so vitest reported 'Worker exited unexpectedly' and exited non-zero on a fully green test run — failing the required check on every PR.

11.x also ships no prebuild for Node 24, so CI compiled it from source on every run. 12.x has prebuilds for that ABI and the V8 13.9 shims 11.x lacks. drizzle-orm declares better-sqlite3 >=7, so the major bump is in range.

Supersedes #26.
2026-08-25 15:41:43 +02:00
TheZwiss 8e13aaa057 Merge pull request #27 from TheZwiss/docs/security-design-record
docs: complete security design record on main (spec + Plan A plan)
2026-07-13 11:40:37 +02:00
Jannis Braun aa5052ba8a docs(security): add scanning/hardening design spec + Plan A implementation plan
Completes the design record on main: Plan B's plan and the federation spec were
already here; this adds the umbrella security spec (source of truth for the
remaining container/web/desktop/remediation workstreams) and Plan A's plan.
2026-07-13 11:36:23 +02:00
TheZwiss 97989fbfb4 Merge pull request #18 from TheZwiss/security/container-hardening
Container hardening: non-root + image scan (report-only)
2026-07-13 11:33:54 +02:00
Jannis Braun bf836e208b Merge main into security/container-hardening (bring up to date for merge) 2026-07-13 11:27:02 +02:00
TheZwiss 53a36cd11a Merge pull request #17 from TheZwiss/security/scanning-pipeline
Security scanning pipeline (report-only)
2026-07-13 11:26:27 +02:00
Jannis Braun d2d6ce9756 Merge main into security/scanning-pipeline (bring up to date for merge) 2026-07-13 11:20:45 +02:00
Jannis Braun 7d1895308d fix(docker): make SARIF upload non-blocking; correct seed-admin/build-stage/restore ownership docs
Final whole-branch review (opus) fixes:
- docker-publish.yml: upload-sarif was if:always() but not continue-on-error, so a
  Trivy SARIF-emit flake would fail the job and SKIP the multi-arch publish. Made it
  non-blocking so a scanner hiccup never blocks a release.
- deployment.md: seed-admin-rotated.txt is root-owned (written via docker exec, which
  bypasses the gosu drop) — reverted an over-correction. Corrected the canonical
  runtime-stage build description (no toolchain; non-root gosu). First-boot chown note.
- restore.sh: comment ownership root -> uid 1000.
2026-07-13 01:21:20 +02:00
Jannis Braun 0b3aa42a09 docs(docker): add maintainer release-gate pull-test note (per review) 2026-07-13 01:13:19 +02:00
Jannis Braun 6d81b63d86 docs(docker): document non-root runtime, data-volume migration, and image scan 2026-07-13 01:09:26 +02:00
Jannis Braun c4929a8b1b ci(docker): scan the amd64 image before publish; attach SBOM + provenance 2026-07-13 01:06:58 +02:00
Jannis Braun 0ec7ddba81 docs(docker): correct stale runtime-deps comment (no build toolchain anymore) 2026-07-13 01:04:13 +02:00
Jannis Braun 3100965c30 fix(docker): run container as non-root (gosu) and drop build toolchain from runtime 2026-07-13 00:58:07 +02:00
Jannis Braun 9d2eeb0963 docs(security): build native arm64 for local Docker verification (host is Apple Silicon; arm64 = Pi target) 2026-07-13 00:54:42 +02:00
Jannis Braun fd8659a964 docs(security): lighter targeted arm64 native-module check (avoid QEMU Vite build timeout) 2026-07-13 00:53:26 +02:00
Jannis Braun 7457146846 docs(security): fold Plan B review findings (proc/1 uid check, arm64 verify, cheap idempotent chown, provenance floor, doc corrections) 2026-07-13 00:52:13 +02:00
Jannis Braun 321428ba1f docs(security): Plan B implementation plan (container hardening + image scan) 2026-07-13 00:42:02 +02:00
Jannis Braun cf4172e81a fix(security): bump trivy-action v0.28.0 -> v0.36.0
v0.28.0's composite action referenced a nested aquasecurity/setup-trivy@v0.2.1
tag that no longer exists, so the action failed to RESOLVE during job setup
(before any step ran) — continue-on-error can't catch a resolution failure, so
both Trivy jobs went red on every run. v0.36.0 pins setup-trivy to a real SHA
(v0.2.6) and still supports scan-type/scan-ref/scanners/format/output.
2026-07-12 23:54:47 +02:00
Jannis Braun e2d09c0d52 fix(security): point OSV-Scanner at runnable subpath action; doc accuracy fixes
- OSV-Scanner ref was google/osv-scanner-action@<sha> (metadata-only root
  action, no runs:) -> subpath google/osv-scanner-action/osv-scanner-action
  which carries the docker action + scan-args input. Root ref would fail to
  load and redden the job on every run (caught in final whole-branch review).
- security-scanning.md: note gitleaks findings land in job log (not SARIF);
  add scorecard branch_protection_rule trigger; mark SBOM/provenance as not-
  yet-live. CLAUDE.md row: image scan is a later plan, not current.
2026-07-12 23:51:00 +02:00
Jannis Braun cb524675cc docs(security): document the scanning pipeline + maintainer checklist 2026-07-12 23:51:00 +02:00
Jannis Braun 4758ca46fa ci(security): SHA-pin all actions and add harden-runner (audit) 2026-07-12 23:51:00 +02:00
Jannis Braun 21d783e257 ci(security): add OpenSSF Scorecard workflow 2026-07-12 23:51:00 +02:00
Jannis Braun 3f75ff6e36 ci(security): add CodeQL SAST workflow (javascript-typescript) 2026-07-12 23:51:00 +02:00
Jannis Braun 74ae929ab4 ci(security): add report-only security scan workflow (gitleaks, OSV, Trivy) 2026-07-12 23:51:00 +02:00
Jannis Braun 807afba45a ci(security): add Dependabot config (npm + actions + docker) 2026-07-12 23:51:00 +02:00
44 changed files with 2504 additions and 158 deletions
+3 -1
View File
@@ -21,7 +21,9 @@ Closes #
- [ ] 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)
- [ ] 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
+44
View File
@@ -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
+8 -3
View File
@@ -31,15 +31,20 @@ jobs:
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@v5
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
- name: Setup pnpm
uses: pnpm/action-setup@v5
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
with:
version: 10.34.3
- name: Setup Node.js
uses: actions/setup-node@v5
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version: ${{ matrix.node-version }}
cache: pnpm
+12 -4
View File
@@ -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.
+48
View File
@@ -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"
+4 -4
View File
@@ -24,10 +24,10 @@ jobs:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- uses: actions/checkout@v5
- uses: actions/configure-pages@v6
- uses: actions/upload-pages-artifact@v5
- 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@v5
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
+48 -6
View File
@@ -27,13 +27,14 @@ on:
permissions:
contents: read
packages: write
security-events: write
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
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
@@ -44,13 +45,13 @@ jobs:
run: echo "commit=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -58,7 +59,7 @@ jobs:
- name: Derive image tags and labels
id: docker_meta
uses: docker/metadata-action@v5
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).
@@ -76,8 +77,47 @@ jobs:
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@v6
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: .
platforms: linux/amd64,linux/arm64
@@ -86,6 +126,8 @@ jobs:
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
+9 -3
View File
@@ -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@v5
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@v5
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
with:
version: 10.34.3
- name: Setup Node.js
uses: actions/setup-node@v5
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version: 20
cache: pnpm
+48
View File
@@ -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
+123
View File
@@ -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
+1
View File
@@ -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 |
---
+30 -4
View File
@@ -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
@@ -38,9 +47,12 @@ FROM node:20-slim AS runtime
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"]
+18
View File
@@ -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,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,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).
+39 -3
View File
@@ -62,7 +62,7 @@ One installer, three modes, recorded as `DEPLOY_MODE` in `.env`. `install.sh` au
`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.
@@ -73,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:
@@ -167,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.
@@ -250,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
+33
View File
@@ -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`).
+58
View File
@@ -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.)
+1 -1
View File
@@ -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",
@@ -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
+3 -6
View File
@@ -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}
@@ -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') {
@@ -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);
});
});
+2 -17
View File
@@ -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,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);
});
});
@@ -0,0 +1,40 @@
import React from 'react';
import type { User } from '@backspace/shared';
import { Avatar } from './Avatar';
import { useUIStore } from '../../stores/uiStore';
import type { Placement } from '../../hooks/useFloatingPosition';
type AvatarProps = React.ComponentProps<typeof Avatar>;
interface ProfileAvatarProps extends Omit<AvatarProps, 'onClick' | 'user'> {
/** Undefined while the user record is still resolving — the avatar then stays
* presentational rather than offering a click that opens nothing. */
user?: User;
/** Preferred side for the card; it flips automatically when there's no room. */
placement?: Placement;
}
/**
* An avatar that opens the profile card for the user it depicts.
*
* This is deliberately a separate component from `Avatar`: `Avatar` takes a
* `user` for the gradient, colour and status dot, and plenty of avatars carry
* one without being a profile trigger — the picture inside the profile card
* itself, the settings preview, rows inside modals. Folding the behaviour into
* `Avatar` made every one of those a trigger by accident, which is what let the
* profile card re-anchor to its own picture and walk across the screen.
*/
export function ProfileAvatar({ user, placement = 'right', ...avatarProps }: ProfileAvatarProps) {
const openUserProfile = useUIStore((s) => s.openUserProfile);
const handleClick = user
? (e: React.MouseEvent) => {
// Rows that hold an avatar usually have their own click target (open the
// DM, select the member). Opening the profile is the more specific intent.
e.stopPropagation();
openUserProfile(user, e.currentTarget.getBoundingClientRect(), placement);
}
: undefined;
return <Avatar {...avatarProps} user={user} onClick={handleClick} />;
}
@@ -0,0 +1,129 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import type { User } from '@backspace/shared';
// The popout reaches into the space store (origin routing), the API client and
// the federated-mutuals loader. None of that is under test here — stub it so the
// test exercises the card's own click and placement behaviour.
vi.mock('../../stores/spaceStore', () => ({
useSpaceStore: Object.assign(
(selector: (s: Record<string, unknown>) => unknown) =>
selector({ addDmChannel: vi.fn(), findExistingDmForUser: vi.fn() }),
{ getState: () => ({ addDmChannel: vi.fn(), findExistingDmForUser: vi.fn() }) },
),
getApiForOrigin: () => ({ uploads: { url: (k: string) => `/uploads/${k}` } }),
resolveUserOrigin: () => 'local',
}));
vi.mock('../../api/client', () => ({ api: { dm: { create: vi.fn() } } }));
vi.mock('../../utils/mutuals', () => ({
loadFederatedMutuals: vi.fn().mockResolvedValue({ mutualFriends: [], mutualSpaces: [] }),
}));
vi.mock('../../utils/userViewLookup', () => ({ useCanonicalUserView: (u: User) => u }));
import { UserProfilePopout } from './UserProfilePopout';
import { useUIStore } from '../../stores/uiStore';
const CARD_W = 340;
const CARD_H = 420;
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 anchorAt(left: number, top: number, size = 40) {
return { top, left, right: left + size, bottom: top + size, width: size, height: size };
}
function setViewport(width: number, height: number) {
Object.defineProperty(window, 'innerWidth', { value: width, configurable: true });
Object.defineProperty(window, 'innerHeight', { value: height, configurable: true });
}
function renderCard(anchor: ReturnType<typeof anchorAt>, placement?: 'left' | 'right') {
return render(
<MemoryRouter>
<UserProfilePopout user={makeUser()} onClose={() => {}} anchor={anchor} placement={placement} />
</MemoryRouter>,
);
}
describe('UserProfilePopout', () => {
beforeEach(() => {
setViewport(1920, 1080);
useUIStore.setState({
isMobile: false,
activeModal: null,
modalData: {},
userProfilePopout: { user: null, anchor: null, placement: 'right' },
});
// jsdom has no layout: give every element the card's real measured size so
// the popout can place itself off its own dimensions.
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({
top: 0, left: 0, right: CARD_W, bottom: CARD_H, width: CARD_W, height: CARD_H,
x: 0, y: 0, toJSON: () => ({}),
} as DOMRect);
});
it('does not reopen itself when its own picture is clicked (issue #37)', async () => {
const { container } = renderCard(anchorAt(300, 200));
const avatar = container.querySelector('[data-avatar]')!;
await userEvent.click(avatar);
await userEvent.click(avatar);
expect(useUIStore.getState().userProfilePopout.user).toBeNull();
});
it("escalates to the full profile when the card's picture is clicked", async () => {
// The picture is the obvious thing to click for "show me more about this
// person". Doing nothing there is a dead end — the only way forward would be
// the View Full Profile link.
let closed = false;
const { container } = render(
<MemoryRouter>
<UserProfilePopout user={makeUser()} onClose={() => { closed = true; }} anchor={anchorAt(300, 200)} />
</MemoryRouter>,
);
await userEvent.click(container.querySelector('[data-avatar]')!);
expect(useUIStore.getState().activeModal).toBe('userProfile');
expect(useUIStore.getState().modalData).toMatchObject({ userId: 'u-1' });
expect(closed).toBe(true);
});
it('sits beside its anchor', () => {
const { container } = renderCard(anchorAt(300, 200));
const card = container.querySelector('[data-user-profile-popout]') as HTMLElement;
expect(parseFloat(card.style.left)).toBe(340 + 8); // anchor.right + offset
expect(parseFloat(card.style.top)).toBe(200); // top-aligned with its anchor
});
it('flips to the other side instead of running off the right edge', () => {
setViewport(1000, 800);
const { container } = renderCard(anchorAt(900, 100));
const card = container.querySelector('[data-user-profile-popout]') as HTMLElement;
const left = parseFloat(card.style.left);
expect(left).toBeGreaterThanOrEqual(8);
expect(left + CARD_W).toBeLessThanOrEqual(1000 - 8);
});
it('keeps a tall card on screen when anchored near the bottom', () => {
setViewport(1280, 700);
const { container } = renderCard(anchorAt(200, 660));
const card = container.querySelector('[data-user-profile-popout]') as HTMLElement;
const top = parseFloat(card.style.top);
expect(top).toBeGreaterThanOrEqual(8);
expect(top + CARD_H).toBeLessThanOrEqual(700 - 8);
});
});
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import ReactMarkdown from 'react-markdown';
import type { User } from '@backspace/shared';
@@ -11,14 +11,20 @@ import { getAvatarGradient, adjustColor, mutedGradient } from '../../utils/gradi
import { parseFederatedUsername } from '../../utils/identity';
import { useCanonicalUserView } from '../../utils/userViewLookup';
import { loadFederatedMutuals } from '../../utils/mutuals';
import { computeFloatingPosition, type AnchorRect, type Placement } from '../../hooks/useFloatingPosition';
/** Gap between the card and the element it was opened from. */
const ANCHOR_OFFSET = 8;
interface UserProfilePopoutProps {
user: User;
onClose: () => void;
position?: { top: number; left: number };
/** Rect of the element the card was opened from. */
anchor: AnchorRect;
placement?: Placement;
}
export function UserProfilePopout({ user: propUser, onClose, position }: UserProfilePopoutProps) {
export function UserProfilePopout({ user: propUser, onClose, anchor, placement = 'right' }: UserProfilePopoutProps) {
const navigate = useNavigate();
const addDmChannel = useSpaceStore((s) => s.addDmChannel);
const openModal = useUIStore((s) => s.openModal);
@@ -43,12 +49,38 @@ export function UserProfilePopout({ user: propUser, onClose, position }: UserPro
.catch(() => {});
}, [user.id, user.homeUserId]);
const top = position
? Math.min(Math.max(8, position.top), window.innerHeight - 460)
: undefined;
const left = position
? Math.min(Math.max(8, position.left), window.innerWidth - 356)
: undefined;
// Placed off the card's *measured* size rather than a guessed height: the card
// grows with the bio, the custom status and the mutuals row, so any constant
// here would cut tall cards off at the bottom of the viewport.
const cardRef = useRef<HTMLDivElement>(null);
const [placed, setPlaced] = useState<{ top: number; left: number } | null>(null);
useLayoutEffect(() => {
const card = cardRef.current;
if (!card) return;
const place = () => {
const { width, height } = card.getBoundingClientRect();
// 'start': the card's top edge lines up with the row it came from, the
// way it always has — centring a tall card on a 32px avatar would drag it
// up over unrelated content.
const next = computeFloatingPosition(anchor, width, height, placement, ANCHOR_OFFSET, 'start');
setPlaced((prev) =>
prev && prev.top === next.top && prev.left === next.left
? prev
: { top: next.top, left: next.left },
);
};
place();
const observer = new ResizeObserver(place);
observer.observe(card);
window.addEventListener('resize', place);
return () => {
observer.disconnect();
window.removeEventListener('resize', place);
};
}, [anchor, placement]);
const handleSendMessage = async () => {
try {
@@ -78,6 +110,11 @@ export function UserProfilePopout({ user: propUser, onClose, position }: UserPro
openModal('userProfile', { userId: user.id, user, origin });
};
const handleAvatarClick = (event: React.MouseEvent) => {
event.stopPropagation();
handleViewFullProfile();
};
// Banner display
const bannerSrc = user.banner
? (user.banner.startsWith('http') || user.banner.startsWith('/') ? user.banner : userApi.uploads.url(user.banner))
@@ -89,12 +126,17 @@ export function UserProfilePopout({ user: propUser, onClose, position }: UserPro
return mutedGradient(g.from, g.to);
})();
// Parked off-screen for the one layout pass before the card knows how tall it
// is; `useLayoutEffect` places it before the browser paints, so it never
// renders visibly in the wrong spot.
const cardStyle = placed ?? { top: -9999, left: -9999 };
return (
<div
ref={cardRef}
data-user-profile-popout
className="fixed z-[200] w-[340px] rounded-[12px] overflow-hidden animate-fade-in select-none glass-modal"
style={position
? { top, left }
: { top: '50%', left: '50%', transform: 'translate(-50%, -50%)' }}
style={cardStyle}
>
{/* Banner */}
<div
@@ -108,6 +150,9 @@ export function UserProfilePopout({ user: propUser, onClose, position }: UserPro
{/* Body */}
<div className="px-4 pb-4 relative">
{/* Avatar */}
{/* The picture escalates to the full profile — the card is a preview, and
clicking the face is the obvious way to ask for the whole thing. It
deliberately does NOT reopen the card (see issue #37). */}
<Avatar
src={user.avatar}
name={displayName}
@@ -115,6 +160,7 @@ export function UserProfilePopout({ user: propUser, onClose, position }: UserPro
status={user.status as 'online' | 'idle' | 'dnd' | 'offline' | null}
userId={user.homeUserId ?? user.id}
user={user}
onClick={handleAvatarClick}
ring={{ width: 4, color: 'rgba(20,20,26,0.85)' }}
className="mt-[-44px] mb-3"
/>
@@ -1,5 +1,5 @@
import React, { useRef, useEffect, useState, useCallback } from 'react';
import { Avatar } from '../ui/Avatar';
import { ProfileAvatar } from '../ui/ProfileAvatar';
import { useVoiceStore } from '../../stores/voiceStore';
import { useContextMenuStore, type ContextMenuItem } from '../../stores/contextMenuStore';
import { buildVoiceModMenuItems, VolumeSliderItem } from './voiceMenuItems';
@@ -160,7 +160,7 @@ export function VoiceUser({ tile, large }: VoiceUserProps) {
) : (
<div className="w-full h-full flex flex-col items-center justify-center gap-3 bg-surface-channel">
<div className="relative flex">
<Avatar
<ProfileAvatar
src={avatar}
name={displayName}
size={large ? 100 : 64}
@@ -0,0 +1,44 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { computeFloatingPosition } from './useFloatingPosition';
function rect(left: number, top: number, w = 40, h = 40): DOMRect {
return { left, top, right: left + w, bottom: top + h, width: w, height: h, x: left, y: top, toJSON: () => ({}) } as DOMRect;
}
describe('computeFloatingPosition', () => {
beforeEach(() => {
Object.defineProperty(window, 'innerWidth', { value: 1000, configurable: true });
Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true });
});
it('places a right-anchored surface just past the anchor', () => {
expect(computeFloatingPosition(rect(100, 100), 340, 420, 'right', 8)).toMatchObject({
left: 148,
actualPlacement: 'right',
});
});
it('flips left when the surface would overflow the right edge', () => {
const pos = computeFloatingPosition(rect(900, 100), 340, 420, 'right', 8);
expect(pos.actualPlacement).toBe('left');
expect(pos.left).toBe(900 - 340 - 8);
});
it("aligns to the anchor's leading edge when asked, instead of centring on it", () => {
// The profile card lines its top edge up with the row it was opened from;
// centring a 420px card on a 32px avatar would drag it far up the screen.
const pos = computeFloatingPosition(rect(100, 300), 340, 420, 'right', 8, 'start');
expect(pos.top).toBe(300);
});
it('centres on the anchor by default', () => {
const pos = computeFloatingPosition(rect(100, 300), 340, 420, 'right', 8);
expect(pos.top).toBe(300 + 20 - 210);
});
it('clamps a surface taller than the space below its anchor', () => {
const pos = computeFloatingPosition(rect(100, 700), 340, 420, 'right', 8);
expect(pos.top + 420).toBeLessThanOrEqual(800 - 8);
expect(pos.top).toBeGreaterThanOrEqual(8);
});
});
+42 -8
View File
@@ -1,6 +1,23 @@
import { type RefObject, type CSSProperties, useState, useLayoutEffect, useCallback } from 'react';
type Placement = 'top' | 'bottom' | 'left' | 'right';
export type Placement = 'top' | 'bottom' | 'left' | 'right';
/** Cross-axis alignment: centred on the anchor, or flush with its leading edge. */
export type Alignment = 'center' | 'start';
/** The subset of DOMRect placement needs — lets callers pass a stored rect. */
export interface AnchorRect {
top: number;
left: number;
right: number;
bottom: number;
width: number;
height: number;
}
/** A zero-size anchor at a viewport point, for callers with no anchor element. */
export function pointAnchor(x: number, y: number): AnchorRect {
return { top: y, left: x, right: x, bottom: y, width: 0, height: 0 };
}
interface UseFloatingPositionOptions {
placement: Placement;
@@ -22,12 +39,22 @@ const oppositePlacement: Record<Placement, Placement> = {
right: 'left',
};
function computePosition(
anchorRect: DOMRect,
/**
* Places a floating surface next to an anchor: preferred side first, flipping to
* the opposite side when it would overflow, then clamping into the viewport.
*
* Exported because not every floating surface has a live anchor element. The
* profile popout, for instance, is opened from a store and keeps only the
* anchor's rect — the row it came from may have re-rendered or scrolled away by
* the time the card mounts.
*/
export function computeFloatingPosition(
anchorRect: AnchorRect,
floatingWidth: number,
floatingHeight: number,
placement: Placement,
offset: number,
align: Alignment = 'center',
): { top: number; left: number; actualPlacement: Placement } {
const vw = window.innerWidth;
const vh = window.innerHeight;
@@ -37,17 +64,24 @@ function computePosition(
let actual = placement;
// Compute initial position on primary axis
const crossLeft = align === 'start'
? anchorRect.left
: anchorRect.left + anchorRect.width / 2 - floatingWidth / 2;
const crossTop = align === 'start'
? anchorRect.top
: anchorRect.top + anchorRect.height / 2 - floatingHeight / 2;
if (placement === 'top') {
top = anchorRect.top - floatingHeight - offset;
left = anchorRect.left + anchorRect.width / 2 - floatingWidth / 2;
left = crossLeft;
} else if (placement === 'bottom') {
top = anchorRect.bottom + offset;
left = anchorRect.left + anchorRect.width / 2 - floatingWidth / 2;
left = crossLeft;
} else if (placement === 'left') {
top = anchorRect.top + anchorRect.height / 2 - floatingHeight / 2;
top = crossTop;
left = anchorRect.left - floatingWidth - offset;
} else {
top = anchorRect.top + anchorRect.height / 2 - floatingHeight / 2;
top = crossTop;
left = anchorRect.right + offset;
}
@@ -113,7 +147,7 @@ export function useFloatingPosition(
const anchorRect = anchor.getBoundingClientRect();
const floatingRect = floating.getBoundingClientRect();
const pos = computePosition(
const pos = computeFloatingPosition(
anchorRect,
floatingRect.width,
floatingRect.height,
+12 -6
View File
@@ -1,6 +1,7 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import type { User } from '@backspace/shared';
import type { AnchorRect, Placement } from '../hooks/useFloatingPosition';
type ModalType =
| 'createSpace'
@@ -40,7 +41,11 @@ interface UIState {
imagePreviewUrl: string | null;
userProfilePopout: {
user: User | null;
position: { top: number; left: number } | null;
/** Rect of the element the card was opened from. The card places itself off
* this rect once it knows its own measured size — callers never compute
* coordinates, so no surface can drift by re-anchoring to itself. */
anchor: AnchorRect | null;
placement: Placement;
};
toasts: Toast[];
toggleSidebar: () => void;
@@ -51,7 +56,7 @@ interface UIState {
setShowDms: (show: boolean) => void;
openImagePreview: (url: string) => void;
closeImagePreview: () => void;
openUserProfile: (user: User, position: { top: number; left: number }) => void;
openUserProfile: (user: User, anchor: AnchorRect, placement?: Placement) => void;
closeUserProfile: () => void;
addToast: (message: string, type?: 'info' | 'warning' | 'success', duration?: number) => void;
removeToast: (id: string) => void;
@@ -93,7 +98,8 @@ export const useUIStore = create<UIState>()(
imagePreviewUrl: null,
userProfilePopout: {
user: null,
position: null,
anchor: null,
placement: 'right',
},
toasts: [],
@@ -120,7 +126,7 @@ export const useUIStore = create<UIState>()(
openImagePreview: (url) => set({ activeModal: 'imagePreview', imagePreviewUrl: url }),
closeImagePreview: () => set({ activeModal: null, imagePreviewUrl: null }),
openUserProfile: (user, position) => {
openUserProfile: (user, anchor, placement = 'right') => {
if (get().isMobile) {
// On mobile, push a full-screen user profile instead of a positioned popout
set((state) => ({
@@ -128,11 +134,11 @@ export const useUIStore = create<UIState>()(
}));
history.pushState({ mobileScreen: 'user-profile' }, '');
} else {
set({ userProfilePopout: { user, position } });
set({ userProfilePopout: { user, anchor, placement } });
}
},
closeUserProfile: () => set({
userProfilePopout: { user: null, position: null }
userProfilePopout: { user: null, anchor: null, placement: 'right' }
}),
addToast: (message, type = 'info', duration = 5000) => {
+17
View File
@@ -112,3 +112,20 @@ const OriginalResponse = globalThis.Response;
return b;
}
};
// jsdom does not implement ResizeObserver. Floating surfaces (tooltips,
// popovers, the profile card) observe their own box so they can re-place
// themselves when their content grows. Provide an inert stub — tests drive
// layout explicitly by stubbing getBoundingClientRect.
if (!('ResizeObserver' in globalThis)) {
class NoopResizeObserver implements ResizeObserver {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
}
Object.defineProperty(globalThis, 'ResizeObserver', {
value: NoopResizeObserver,
configurable: true,
writable: true,
});
}
+11 -9
View File
@@ -78,8 +78,8 @@ importers:
specifier: ^2.4.3
version: 2.4.3
better-sqlite3:
specifier: ^11.3.0
version: 11.10.0
specifier: ^12.11.1
version: 12.11.1
cheerio:
specifier: ^1.0.0
version: 1.2.0
@@ -88,7 +88,7 @@ importers:
version: 16.6.1
drizzle-orm:
specifier: ^0.33.0
version: 0.33.0(@types/better-sqlite3@7.6.13)(@types/react@18.3.28)(better-sqlite3@11.10.0)(react@18.3.1)
version: 0.33.0(@types/better-sqlite3@7.6.13)(@types/react@18.3.28)(better-sqlite3@12.11.1)(react@18.3.1)
fastify:
specifier: ^4.28.1
version: 4.29.1
@@ -834,7 +834,7 @@ packages:
engines: {node: '>=12'}
'@electron/node-gyp@https://codeload.github.com/electron/node-gyp/tar.gz/06b29aafb7708acef8b3669835c8a7857ebc92d2':
resolution: {tarball: https://codeload.github.com/electron/node-gyp/tar.gz/06b29aafb7708acef8b3669835c8a7857ebc92d2}
resolution: {gitHosted: true, integrity: sha512-MXgzlTDEEndJB3TBbvd5uFQO/8gaINo1Hfen8vef5rq/VHVPeB63uuv/uO5+8GFsAJ/rauu6XB79S6K4+aXc+w==, tarball: https://codeload.github.com/electron/node-gyp/tar.gz/06b29aafb7708acef8b3669835c8a7857ebc92d2}
version: 10.2.0-electron.1
engines: {node: '>=12.13.0'}
hasBin: true
@@ -2374,8 +2374,9 @@ packages:
bcryptjs@2.4.3:
resolution: {integrity: sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==}
better-sqlite3@11.10.0:
resolution: {integrity: sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==}
better-sqlite3@12.11.1:
resolution: {integrity: sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==}
engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x || 26.x}
bidi-js@1.0.3:
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
@@ -4426,6 +4427,7 @@ packages:
prebuild-install@7.1.3:
resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
engines: {node: '>=10'}
deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available.
hasBin: true
pretty-bytes@5.6.0:
@@ -7658,7 +7660,7 @@ snapshots:
bcryptjs@2.4.3: {}
better-sqlite3@11.10.0:
better-sqlite3@12.11.1:
dependencies:
bindings: 1.5.0
prebuild-install: 7.1.3
@@ -8197,11 +8199,11 @@ snapshots:
transitivePeerDependencies:
- supports-color
drizzle-orm@0.33.0(@types/better-sqlite3@7.6.13)(@types/react@18.3.28)(better-sqlite3@11.10.0)(react@18.3.1):
drizzle-orm@0.33.0(@types/better-sqlite3@7.6.13)(@types/react@18.3.28)(better-sqlite3@12.11.1)(react@18.3.1):
optionalDependencies:
'@types/better-sqlite3': 7.6.13
'@types/react': 18.3.28
better-sqlite3: 11.10.0
better-sqlite3: 12.11.1
react: 18.3.1
dunder-proto@1.0.1:
+3 -2
View File
@@ -47,8 +47,9 @@ read -rp "Continue? [y/N] " yn
echo "[1/3] Stopping backspace container..."
docker compose stop backspace
# data/backspace.db and data/backups/ are container-owned (root). The host user cannot
# cp/rm them directly, so do the swap inside a throwaway root container that mounts data/.
# data/backspace.db and data/backups/ are container-owned (uid 1000, the non-root runtime
# user). The host user cannot cp/rm them directly, so do the swap inside a throwaway root
# container that mounts data/ (root can rewrite the uid-1000-owned files).
# (youruser is in the docker group on both boxes — no sudo prompt.)
TS="$(date -u +%Y%m%dT%H%M%S)"
echo "[2/3] Swapping DB inside a root container (pre-restore copy + WAL clear + install)..."