Compare commits

..

No commits in common. "main" and "prod-v0.2.0-initial" have entirely different histories.

656 changed files with 1987 additions and 51448 deletions

View File

@ -1,24 +0,0 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|Grep",
"hooks": [
{
"type": "command",
"command": "/Users/stwaidele/.local/bin/graphify hook-guard search"
}
]
},
{
"matcher": "Read|Glob",
"hooks": [
{
"type": "command",
"command": "/Users/stwaidele/.local/bin/graphify hook-guard read"
}
]
}
]
}
}

View File

@ -68,7 +68,7 @@ jobs:
- name: Pull and restart the Test stack - name: Pull and restart the Test stack
run: | run: |
ssh deploy@$DEPLOY_HOST 'cd /srv/DOCKER/dorfteich-test \ ssh deploy@$DEPLOY_HOST 'cd /home/DOCKER/dorfteich-test \
&& docker compose pull --quiet && docker compose up -d --remove-orphans \ && docker compose pull --quiet && docker compose up -d --remove-orphans \
&& docker compose ps' && docker compose ps'
@ -86,7 +86,7 @@ jobs:
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version-file: .node-version node-version: 22
cache: pnpm cache: pnpm
- name: Install dependencies - name: Install dependencies
@ -138,6 +138,6 @@ jobs:
- name: Pull and restart the Int stack - name: Pull and restart the Int stack
run: | run: |
ssh deploy@$DEPLOY_HOST 'cd /srv/DOCKER/dorfteich-int \ ssh deploy@$DEPLOY_HOST 'cd /home/DOCKER/dorfteich-int \
&& docker compose pull --quiet && docker compose up -d --remove-orphans \ && docker compose pull --quiet && docker compose up -d --remove-orphans \
&& docker compose ps' && docker compose ps'

View File

@ -38,112 +38,18 @@ jobs:
- name: Check out repository - name: Check out repository
uses: actions/checkout@v4 uses: actions/checkout@v4
# Fails if a real .env (anything but .env.example) is ever tracked, or
# if a tracked file matches an obvious secret pattern (issue #198).
# .env.example is the authoritative reference; real values never enter
# the repository (docs/self-hosting/README.md).
- name: No tracked .env files or secret material
run: |
set -euo pipefail
bad_env=$(git ls-files | grep -E '(^|/)\.env(\.[^/]*)?$' | grep -v '\.env\.example$' || true)
if [ -n "$bad_env" ]; then
echo "tracked .env file(s) — only .env.example may be tracked:"
echo "$bad_env"
exit 1
fi
secrets=$(git grep -nIE -e '-----BEGIN [A-Z ]*PRIVATE KEY-----|AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{36}|glpat-[A-Za-z0-9_-]{20}|xox[baprs]-[0-9A-Za-z-]{10}' -- . || true)
if [ -n "$secrets" ]; then
echo "tracked file matches a secret pattern:"
echo "$secrets"
exit 1
fi
# One authoritative Node version (issue #236): `.node-version` is the
# pin; every Dockerfile image tag and the engines floor must match it
# exactly, and workflows select Node only through node-version-file.
# Raising Node = update .node-version, every `FROM node:` tag and the
# engines floor in ONE commit (procedure: docs/architecture/operations.md
# §Update strategy). The bracketed grep pattern keeps this step from
# matching its own source (same trick as the secret fence above).
- name: Node version pin is consistent
run: |
set -euo pipefail
ver="$(cat .node-version)"
echo "pinned Node version: $ver"
bad=0
for f in apps/*/Dockerfile; do
if grep '^FROM node:' "$f" | grep -v "node:${ver}-alpine"; then
echo "$f pins a different Node image than node:${ver}-alpine"
bad=1
fi
done
if grep -rn "node-version[:] " .gitea/workflows; then
echo "workflows must use node-version-file, not a literal version"
bad=1
fi
if grep -rnE 'node:[0-9][^ ]*-alpine' .gitea/workflows | grep -v "node:${ver}-alpine"; then
echo "a workflow references a different node image than node:${ver}-alpine"
bad=1
fi
if ! grep -q "\"node\": \">=${ver}\"" package.json; then
echo "package.json engines floor does not match ${ver}"
bad=1
fi
exit "$bad"
# Third-party deploy images are pinned by digest (issue #203): every
# image in the deploy compose that is not one of our own
# (${IMAGE_PREFIX}…) must carry @sha256 — the tag stays for
# readability, the digest decides what runs. Update procedure:
# deploy/stages.md §Third-party image digests. compose.dev.yml is a
# local convenience, deliberately not held to this.
- name: Third-party compose images are digest-pinned
run: |
set -euo pipefail
bad=$(grep -hE '^ *image: ' deploy/compose/docker-compose.yml | grep -v 'IMAGE_PREFIX' | grep -v '@sha256:' || true)
if [ -n "$bad" ]; then
echo "third-party image reference(s) without a digest:"
echo "$bad"
exit 1
fi
# A fresh named volume inherits the ownership of the image directory it
# is mounted over. Every /data/… path the api image defaults to must
# therefore be pre-created AND chowned to `node`, or the non-root user
# cannot write to it — found on a real deploy in #303, where the env
# entry was added but the mkdir/chown line was not.
- name: api image pre-creates its data directories node-owned
run: |
set -euo pipefail
dirs=$(grep -oE '[A-Z_]+_DIR=/data/[a-z]+' apps/api/Dockerfile | cut -d= -f2 | sort -u)
bad=0
for d in $dirs; do
grep -q "mkdir -p .*$d" apps/api/Dockerfile || {
echo "$d is not pre-created in apps/api/Dockerfile"; bad=1; }
grep -q "chown -R node:node .*$d" apps/api/Dockerfile || {
echo "$d is not chowned to node in apps/api/Dockerfile"; bad=1; }
done
exit "$bad"
- name: Set up pnpm - name: Set up pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version-file: .node-version node-version: 22
cache: pnpm cache: pnpm
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
# License allowlist gate (issue #202): fails when any dependency's
# license falls outside the documented policy in
# scripts/check-licenses.mjs (which is also where the reasoning and
# per-package exceptions live).
- name: License allowlist
run: pnpm licenses list --json | node scripts/check-licenses.mjs
# Build first: package type checks resolve @dorfteich/shared through # Build first: package type checks resolve @dorfteich/shared through
# its built dist, and i18n:check imports the built helpers. # its built dist, and i18n:check imports the built helpers.
- name: Build all packages - name: Build all packages
@ -193,7 +99,7 @@ jobs:
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version-file: .node-version node-version: 22
cache: pnpm cache: pnpm
- name: Install dependencies - name: Install dependencies
@ -210,16 +116,7 @@ jobs:
- name: Start api, collab, and static web server - name: Start api, collab, and static web server
run: | run: |
# VS_NFD_MODE=marked: the marking pack and the a11y admin scan (cd apps/api && PORT=3001 node dist/main.js > /tmp/api.log 2>&1 &)
# cover the marked state (issue #244); mode off is covered by
# local full runs and the marking pack's off-assertions there.
(cd apps/api && PORT=3001 VS_NFD_MODE=marked node dist/main.js > /tmp/api.log 2>&1 &)
# Second api on the SAME database with VS_NFD_MODE=hidden: the
# marking pack's hidden half runs against it via its own static
# server (issue #245); the mode is env-only, so sharing the db is
# exactly the deploy semantics.
(cd apps/api && PORT=3006 VS_NFD_MODE=hidden MIGRATE_ON_START=false node dist/main.js > /tmp/api-hidden.log 2>&1 &)
(PORT=5176 API_TARGET=http://127.0.0.1:3006 node scripts/e2e-static-server.mjs > /tmp/web-hidden.log 2>&1 &)
(cd apps/collab && PORT=3002 node dist/index.js > /tmp/collab.log 2>&1 &) (cd apps/collab && PORT=3002 node dist/index.js > /tmp/collab.log 2>&1 &)
(PORT=5173 COLLAB_TARGET=http://127.0.0.1:3002 node scripts/e2e-static-server.mjs > /tmp/web.log 2>&1 &) (PORT=5173 COLLAB_TARGET=http://127.0.0.1:3002 node scripts/e2e-static-server.mjs > /tmp/web.log 2>&1 &)
for i in $(seq 1 30); do for i in $(seq 1 30); do
@ -345,16 +242,6 @@ jobs:
E2E_BASE_URL=http://localhost:5173 \ E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/social.spec.ts pnpm --filter @dorfteich/web exec playwright test e2e/social.spec.ts
- name: Reset login rate limit before admin-settings pack
run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
- name: Run admin-settings pack
run: |
E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/admin-settings.spec.ts
- name: Reset login rate limit before admin-quotas pack - name: Reset login rate limit before admin-quotas pack
run: | run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
@ -375,18 +262,6 @@ jobs:
E2E_BASE_URL=http://localhost:5173 \ E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/admin-users.spec.ts pnpm --filter @dorfteich/web exec playwright test e2e/admin-users.spec.ts
- name: Reset login rate limit before invitations pack
run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
# Invitations (issue #332) need the mail catcher like the auth pack:
# the invite link and the follow-up verification both travel by mail.
- name: Run invitations pack
run: |
E2E_BASE_URL=http://localhost:5173 E2E_MAILPIT_URL=http://mailpit:8025 \
pnpm --filter @dorfteich/web exec playwright test e2e/invitations.spec.ts
- name: Reset login rate limit before permission-matrix pack - name: Reset login rate limit before permission-matrix pack
run: | run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
@ -491,76 +366,6 @@ jobs:
E2E_BASE_URL=http://localhost:5173 \ E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/backlinks.spec.ts pnpm --filter @dorfteich/web exec playwright test e2e/backlinks.spec.ts
- name: Reset login rate limit before page-tree pack
run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
- name: Run page-tree pack
run: |
E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/page-tree.spec.ts
- name: Reset login rate limit before graph pack
run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
- name: Run graph pack
run: |
E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/graph.spec.ts
- name: Reset login rate limit before favorites pack
run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
- name: Run favorites pack
run: |
E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/favorites.spec.ts
- name: Reset login rate limit before settings-nav pack
run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
- name: Run settings-nav pack
run: |
E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/settings-nav.spec.ts
- name: Reset login rate limit before tasks pack
run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
- name: Run tasks pack
run: |
E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/tasks.spec.ts
- name: Reset login rate limit before create-missing-page pack
run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
- name: Run create-missing-page pack
run: |
E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/create-missing-page.spec.ts
- name: Reset login rate limit before vault-import pack
run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
- name: Run vault-import pack
run: |
E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/import-vault.spec.ts
- name: Reset login rate limit before search pack - name: Reset login rate limit before search pack
run: | run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
@ -646,57 +451,6 @@ jobs:
sleep 2 sleep 2
done done
# Six logins per run since #180 doubled the scans (3 contexts × light/
# dark, limit is 10/min) → reset first (see note above).
- name: Reset login rate limit before a11y pack
run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
# WCAG-A/AA-Regressionsschutz (issue #171): axe-Scan der Kernscreens,
# seit #180 in beiden Farbschemata.
- name: Run a11y pack
run: |
E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/a11y.spec.ts
# Das a11y-Pack kostet seit #301 einen Login mehr (der Reflow-Zaun);
# damit reicht das Budget nicht mehr bis in die VS-NfD-Packs → hier
# zusätzlich zurücksetzen (siehe Hinweis oben).
- name: Reset login rate limit before the VS-NfD packs
run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
# VS-NfD-Markierungen im Modus `marked` (issue #244).
- name: Run VS-NfD marking pack
run: |
E2E_BASE_URL=http://localhost:5173 E2E_VS_NFD_MODE=marked \
pnpm --filter @dorfteich/web exec playwright test e2e/vs-nfd-marking.spec.ts
# Ausblendung + Policy-Hinweis im Modus `hidden` (issue #245).
- name: Run VS-NfD hidden pack
run: |
for i in $(seq 1 30); do
curl -sf http://localhost:3006/api/v1/readyz >/dev/null && break
sleep 2
done
E2E_BASE_URL=http://localhost:5176 E2E_VS_NFD_MODE=hidden \
pnpm --filter @dorfteich/web exec playwright test e2e/vs-nfd-marking.spec.ts
# The marking pack's extra login on top of the six a11y logins pushes
# the theme pack over the 10/min login limit — reset again (#244).
- name: Reset login rate limit before theme pack
run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
# Hell/Dunkel/System-Umschalter (issue #180).
- name: Run theme pack
run: |
E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/theme.spec.ts
- name: Run setup wizard pack - name: Run setup wizard pack
run: | run: |
E2E_BASE_URL=http://localhost:5175 E2E_SETUP=1 \ E2E_BASE_URL=http://localhost:5175 E2E_SETUP=1 \
@ -730,7 +484,7 @@ jobs:
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version-file: .node-version node-version: 22
cache: pnpm cache: pnpm
- name: Install dependencies - name: Install dependencies
@ -754,16 +508,13 @@ jobs:
# image has no iproute2). Sharing the netns means no published ports. # image has no iproute2). Sharing the netns means no published ports.
- name: Start pinned pandoc + Gotenberg sidecars - name: Start pinned pandoc + Gotenberg sidecars
run: | run: |
# Sidecar names carry THIS job container's id: parallel runs on the # Clear any leftovers from an earlier interrupted run so the named
# shared host must not collide on a fixed name (a fixed-name rm -f # containers never collide, and nothing leaks on the shared host.
# here even killed a sibling run's live sidecars — run 547). docker rm -f fidelity-pandoc fidelity-gotenberg 2>/dev/null || true
JOB_ID=$(cat /etc/hostname) JOB_ID=$(cat /etc/hostname)
echo "PANDOC_NAME=fidelity-pandoc-${JOB_ID}" >> "$GITHUB_ENV" docker run -d --name fidelity-pandoc \
echo "GOTENBERG_NAME=fidelity-gotenberg-${JOB_ID}" >> "$GITHUB_ENV"
docker rm -f "fidelity-pandoc-${JOB_ID}" "fidelity-gotenberg-${JOB_ID}" 2>/dev/null || true
docker run -d --name "fidelity-pandoc-${JOB_ID}" \
--network "container:${JOB_ID}" pandoc/core:3.6 server --network "container:${JOB_ID}" pandoc/core:3.6 server
docker run -d --name "fidelity-gotenberg-${JOB_ID}" \ docker run -d --name fidelity-gotenberg \
--network "container:${JOB_ID}" gotenberg/gotenberg:8 --network "container:${JOB_ID}" gotenberg/gotenberg:8
for i in $(seq 1 30); do for i in $(seq 1 30); do
curl -sf http://localhost:3030/version >/dev/null && break curl -sf http://localhost:3030/version >/dev/null && break
@ -787,15 +538,15 @@ jobs:
- name: Dump sidecar logs on failure - name: Dump sidecar logs on failure
if: failure() if: failure()
run: | run: |
echo '--- pandoc ---'; docker logs "$PANDOC_NAME" 2>&1 | tail -30 || true echo '--- pandoc ---'; docker logs fidelity-pandoc 2>&1 | tail -30 || true
echo '--- gotenberg ---'; docker logs "$GOTENBERG_NAME" 2>&1 | tail -30 || true echo '--- gotenberg ---'; docker logs fidelity-gotenberg 2>&1 | tail -30 || true
# Always tear the sidecars down — they run on the shared runner host, so a # Always tear the sidecars down — they run on the shared runner host, so a
# leaked (especially Chromium-backed Gotenberg) container would waste its # leaked (especially Chromium-backed Gotenberg) container would waste its
# memory until the next run. # memory until the next run and break re-runs on the container name.
- name: Stop sidecars - name: Stop sidecars
if: always() if: always()
run: docker rm -f "$PANDOC_NAME" "$GOTENBERG_NAME" 2>/dev/null || true run: docker rm -f fidelity-pandoc fidelity-gotenberg 2>/dev/null || true
images: images:
name: Build container images name: Build container images

View File

@ -1,11 +1,8 @@
# Monthly restore drill (ADR 0015, issue #87): restores the latest backup # Monthly restore drill (ADR 0015, issue #87): restores the latest backup
# set of the drilled stage into a scratch environment on the runner's Docker # set of the drilled stage into a scratch environment on the runner's Docker
# daemon (the stage host), verifies it, and logs the outcome as a comment on # daemon (the stage host), verifies it, and logs the outcome as a comment on
# the pinned "Restore drills" issue. Since go-live (2026-07-12, #89) the # the pinned "Restore drills" issue. Pre-go-live the drilled stage is Test;
# drilled stage is Prod; the runner on ONE holds the dorfteich-prod_backups # switch DRILL_SOURCE_VOLUME to the Prod backups volume at go-live (#89).
# volume. TAG stays `test` on purpose: it only selects the drill-harness
# images (backup + api) that perform and verify the restore, and a forward
# schema restores a Prod set fine — it is not a claim about the Prod release.
name: Restore drill name: Restore drill
@ -21,7 +18,7 @@ on:
env: env:
IMAGE_BASE: gitea.101010.cloud/stwaidele/dorfteich IMAGE_BASE: gitea.101010.cloud/stwaidele/dorfteich
DRILL_SOURCE_VOLUME: dorfteich-prod_backups DRILL_SOURCE_VOLUME: dorfteich-test_backups
DRILL_LOG_ISSUE: '98' DRILL_LOG_ISSUE: '98'
jobs: jobs:
@ -55,7 +52,7 @@ jobs:
} > comment.md } > comment.md
# JSON-encode via a node container — the runner image guarantees # JSON-encode via a node container — the runner image guarantees
# only git/curl/docker, not python or node. # only git/curl/docker, not python or node.
docker run --rm -i node:22.15.1-alpine node -e \ docker run --rm -i node:22.15-alpine node -e \
'const fs=require("fs");process.stdout.write(JSON.stringify({body:fs.readFileSync(0,"utf8")}))' \ 'const fs=require("fs");process.stdout.write(JSON.stringify({body:fs.readFileSync(0,"utf8")}))' \
< comment.md > comment.json < comment.md > comment.json
curl -sf -X POST \ curl -sf -X POST \

View File

@ -38,13 +38,13 @@ jobs:
- name: Set up SSH - name: Set up SSH
run: | run: |
mkdir -p ~/.ssh && chmod 700 ~/.ssh mkdir -p ~/.ssh && chmod 700 ~/.ssh
printf '%s\n' "${{ secrets.DEPLOY_SSH_KEY_PROD }}" > ~/.ssh/id_ed25519 printf '%s\n' "${{ secrets.DEPLOY_SSH_KEY_TEST }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519 chmod 600 ~/.ssh/id_ed25519
printf '%s\n' "${{ secrets.DEPLOY_HOST_KEY }}" > ~/.ssh/known_hosts printf '%s\n' "${{ secrets.DEPLOY_HOST_KEY }}" > ~/.ssh/known_hosts
- name: Pin the version and restart the Prod stack - name: Pin the version and restart the Prod stack
run: | run: |
ssh deploy@$DEPLOY_HOST "cd /srv/DOCKER/dorfteich-prod \ ssh deploy@$DEPLOY_HOST "cd /home/DOCKER/dorfteich-prod \
&& sed -i 's/^TAG=.*/TAG=$VERSION/' .env \ && sed -i 's/^TAG=.*/TAG=$VERSION/' .env \
&& docker compose pull --quiet && docker compose up -d --remove-orphans \ && docker compose pull --quiet && docker compose up -d --remove-orphans \
&& docker compose ps" && docker compose ps"

View File

@ -38,62 +38,6 @@ jobs:
docker push $IMAGE_BASE-$app:$TAG docker push $IMAGE_BASE-$app:$TAG
done done
# Supply-chain artefacts (issue #202): one CycloneDX SBOM per release
# image, one for the pnpm workspace, plus the full license report —
# attached as build artefacts of this run BEFORE the release is
# published, so a red gate stops the release. Mechanics dictated by
# the runner (the job talks to the HOST daemon, so bind mounts of
# workspace paths resolve on the host and go nowhere): files travel
# into the pinned syft container via `docker cp` (an API stream), and
# images via `docker save` to a tar copied the same way — syft cannot
# read a tar from stdin (not seekable).
- name: Generate SBOMs
run: |
set -euo pipefail
TAG=${GITHUB_REF_NAME}
SYFT=anchore/syft:v1.33.0
mkdir -p supply-chain sbom-src
cp pnpm-lock.yaml package.json sbom-src/
c=$(docker create $SYFT scan dir:/src --source-name dorfteich-workspace --source-version "$TAG" -o cyclonedx-json=/out.json)
docker cp sbom-src "$c:/src"
docker start -a "$c"
docker cp "$c:/out.json" supply-chain/sbom-workspace-$TAG.cdx.json
docker rm "$c" > /dev/null
for app in web api collab backup; do
docker save $IMAGE_BASE-$app:$TAG -o image.tar
c=$(docker create $SYFT scan docker-archive:/image.tar --source-name dorfteich-$app --source-version "$TAG" -o cyclonedx-json=/out.json)
docker cp image.tar "$c:/image.tar"
docker start -a "$c"
docker cp "$c:/out.json" supply-chain/sbom-image-$app-$TAG.cdx.json
docker rm "$c" > /dev/null
rm image.tar
done
ls -l supply-chain/
- name: Set up pnpm
uses: pnpm/action-setup@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version-file: .node-version
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: License report and allowlist gate
run: |
set -euo pipefail
pnpm licenses list --json > supply-chain/licenses-${GITHUB_REF_NAME}.json
node scripts/check-licenses.mjs < supply-chain/licenses-${GITHUB_REF_NAME}.json
- name: Attach supply-chain artefacts
uses: actions/upload-artifact@v3
with:
name: supply-chain-${{ github.ref_name }}
path: supply-chain/
- name: Generate release notes and publish the release - name: Generate release notes and publish the release
run: | run: |
TAG=${GITHUB_REF_NAME} TAG=${GITHUB_REF_NAME}
@ -110,7 +54,7 @@ jobs:
echo '_No database migrations in this release._' echo '_No database migrations in this release._'
fi fi
} > notes.md } > notes.md
TAG=$TAG docker run --rm -i -e TAG node:22.15.1-alpine node -e \ TAG=$TAG docker run --rm -i -e TAG node:22.15-alpine node -e \
'const fs=require("fs");const body=fs.readFileSync(0,"utf8");process.stdout.write(JSON.stringify({tag_name:process.env.TAG,name:process.env.TAG,body}))' \ 'const fs=require("fs");const body=fs.readFileSync(0,"utf8");process.stdout.write(JSON.stringify({tag_name:process.env.TAG,name:process.env.TAG,body}))' \
< notes.md > release.json < notes.md > release.json
curl -sf -X POST \ curl -sf -X POST \

3
.gitignore vendored
View File

@ -14,6 +14,3 @@ apps/api/data/
# Font catalog WOFF2 + generated stylesheet — fetched at build time # Font catalog WOFF2 + generated stylesheet — fetched at build time
# (ADR 0016, deploy/fonts/build-fonts.mjs), never committed. # (ADR 0016, deploy/fonts/build-fonts.mjs), never committed.
apps/web/public/fonts/ apps/web/public/fonts/
# graphify knowledge graph (generated)
graphify-out/

View File

@ -1 +0,0 @@
22.15.1

View File

@ -15,8 +15,3 @@ fixtures/import/*.src.html
# as pandoc reads the exported document back — Prettier would break them. # as pandoc reads the exported document back — Prettier would break them.
fixtures/export/*.expected.md fixtures/export/*.expected.md
packages/plugins/*/vendor/ packages/plugins/*/vendor/
# Agent/tool config generated by Claude Code + `graphify claude install`
# (regenerated on demand, not hand-formatted source) — keep out of Prettier so
# a re-install never breaks the lint gate.
CLAUDE.md
.claude/

View File

@ -1,29 +0,0 @@
## Barrierefreiheit (verbindlich, ADR 0017)
Jede UI-Änderung wird von Anfang an barrierefrei entwickelt (WCAG 2.1
AA) — nicht nachträglich. Kurzfassung; Details und Begründung in
`docs/architecture/adr/0017-accessibility-by-default.md`:
- **Bausteine wiederverwenden:** `IconButton` (erzwungener Name),
`Field` (Label + Fehler-Verdrahtung), `useModalFocus` für Dialoge
(Trap/Initialfokus/Rückgabe + `aria-labelledby`). Keine Parallelbauten.
- **Tastatur zuerst:** alles erreichbar/bedienbar, Fokus sichtbar,
Escape schließt, kein Fokusverlust; Einzeltasten-Shortcuts respektieren
`lib/single-key-shortcuts.ts`.
- **Name/Rolle/Wert:** korrekte Rollen, lokalisierte (de+en) Labels,
Zustände via aria-*; dekorative Icons `aria-hidden`.
- **BEIDE Renderpfade:** Editor-/NodeView-Pfad UND docToHtml/Server-Pfad
gleichwertig behandeln (Cache rollt lazy aus).
- **Kontrast/Farbe:** Tokens nutzen (Text ≥ 4,5:1, UI ≥ 3:1;
`--color-border-input` für Feldränder; `--color-favorite` nur Icons);
Farbe nie als einziges Merkmal.
- **Reflow:** kein seitenweites Horizontal-Scrollen bei 320 px
(`min-width: 0` an Flex-/Grid-Kindern nicht vergessen).
- **Bewegung/Zeit:** `prefers-reduced-motion` respektieren, keine zu
kurzen Auto-Dismiss-Zeiten.
- **CI-Pack pflegen:** neue Kern-Screens in `apps/web/e2e/a11y.spec.ts`
aufnehmen; die Allowlist bleibt leer bzw. nur mit Begründung.
- Neue aria-Labels können bestehende `getByLabel`-e2e-Locator mehrdeutig
machen — betroffene Specs mit anpassen (scopen), nicht das Label opfern.
Verstöße gelten in Review und Abnahme als Funktionsfehler.

View File

@ -32,23 +32,10 @@ offline support.
first-run setup wizard yields a working instance. Start here: first-run setup wizard yields a working instance. Start here:
[`docs/self-hosting/README.md`](docs/self-hosting/README.md). [`docs/self-hosting/README.md`](docs/self-hosting/README.md).
## Documentation
- **What is Dorfteich?** — [`docs/features.md`](docs/features.md)
- **Manuals** (user / pond admin / site admin / API / MCP) —
[`docs/manual/`](docs/manual/README.md), auf Deutsch:
[`docs/de/`](docs/de/manual/README.md)
- **Extending it** (plugins, core) —
[`docs/developer/extending.md`](docs/developer/extending.md)
- **Running it** — [`docs/self-hosting/`](docs/self-hosting/README.md)
- **How it works inside** — [`docs/architecture/`](docs/architecture/README.md)
## Repository layout ## Repository layout
| Path | Contents | | Path | Contents |
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | -------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `docs/manual/` | User-facing manuals: user, pond-admin, site-admin, API, and MCP guides (start at [`docs/manual/README.md`](docs/manual/README.md)) |
| `docs/developer/` | Extending Dorfteich: plugin development and core contributions |
| `docs/architecture/` | Architecture documentation: ADRs, data model, permission model, collaboration and plugin concepts, deployment and operations | | `docs/architecture/` | Architecture documentation: ADRs, data model, permission model, collaboration and plugin concepts, deployment and operations |
| `docs/self-hosting/` | Install, update, backup, and troubleshooting guide for running your own instance | | `docs/self-hosting/` | Install, update, backup, and troubleshooting guide for running your own instance |
| `apps/` | Application packages (web frontend, API server, collaboration server) — created as implementation proceeds | | `apps/` | Application packages (web frontend, API server, collaboration server) — created as implementation proceeds |
@ -73,10 +60,9 @@ imported as `@dorfteich/shared` — never copy code between apps.
## Status ## Status
Feature-complete for a 1.0: collaboration, permissions, import/export, The project is in the architecture and backlog phase. Implementation stories
plugins, public REST API + MCP, backups with off-host copies and in-app are tracked as issues in this repository. Start reading at
restore — all shipped and release-gated. Work is tracked as issues in [`docs/architecture/README.md`](docs/architecture/README.md).
this repository.
## Contributing ## Contributing

View File

@ -1,7 +1,7 @@
# Build context is the repository root (workspace build): # Build context is the repository root (workspace build):
# docker build -f apps/api/Dockerfile . # docker build -f apps/api/Dockerfile .
FROM node:22.15.1-alpine AS build FROM node:22.15-alpine AS build
WORKDIR /repo WORKDIR /repo
RUN npm install -g pnpm@11 RUN npm install -g pnpm@11
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json ./ COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json ./
@ -21,27 +21,25 @@ RUN pnpm install --frozen-lockfile --filter @dorfteich/api... \
# needed for migrate-on-start) at /out. # needed for migrate-on-start) at /out.
&& pnpm --filter @dorfteich/api deploy --prod --legacy /out \ && pnpm --filter @dorfteich/api deploy --prod --legacy /out \
&& cp -r apps/api/dist /out/dist \ && cp -r apps/api/dist /out/dist \
&& cp -r apps/api/assets /out/assets \
&& cp -r /repo/fonts /out/fonts && cp -r /repo/fonts /out/fonts
FROM node:22.15.1-alpine FROM node:22.15-alpine
ARG APP_VERSION=0.0.0-dev ARG APP_VERSION=0.0.0-dev
# Default the data dirs to the writable, node-owned locations created below, so # Default the data dirs to the writable, node-owned locations created below, so
# the image works out of the box even where compose does not set them; compose # the image works out of the box even where compose does not set them; compose
# still mounts named volumes here for persistence (UPLOADS_DIR/PLUGINS_DIR). # still mounts named volumes here for persistence (UPLOADS_DIR/PLUGINS_DIR).
ENV NODE_ENV=production APP_VERSION=${APP_VERSION} UPLOADS_DIR=/data/uploads PLUGINS_DIR=/data/plugins CUSTOM_FONTS_DIR=/data/fonts BRANDING_DIR=/data/branding SECRETS_FILE=/data/secrets/secrets.env BACKUPS_DIR=/data/backups ENV NODE_ENV=production APP_VERSION=${APP_VERSION} UPLOADS_DIR=/data/uploads PLUGINS_DIR=/data/plugins SECRETS_FILE=/data/secrets/secrets.env BACKUPS_DIR=/data/backups
WORKDIR /app WORKDIR /app
COPY --from=build --chown=node:node /out /app COPY --from=build --chown=node:node /out /app
# Generate the Prisma client for this image's platform. # Generate the Prisma client for this image's platform.
RUN node node_modules/prisma/build/index.js generate RUN node node_modules/prisma/build/index.js generate
# A fresh named volume mounted at /data/uploads, /data/plugins, /data/fonts # A fresh named volume mounted at /data/uploads or /data/plugins is created
# or /data/branding is created
# root-owned; pre-creating them here (Docker copies an image directory's # root-owned; pre-creating them here (Docker copies an image directory's
# ownership into a new volume on first mount) lets the non-root `node` user # ownership into a new volume on first mount) lets the non-root `node` user
# write to them. /data/backups is mounted read-only here, but pre-creating it # write to them. /data/backups is mounted read-only here, but pre-creating it
# node-owned keeps the shared `backups` volume writable for the backup # node-owned keeps the shared `backups` volume writable for the backup
# sidecar even when the api container is the one that initializes it. # sidecar even when the api container is the one that initializes it.
RUN mkdir -p /data/uploads /data/plugins /data/fonts /data/branding /data/secrets /data/backups && chown -R node:node /data/uploads /data/plugins /data/fonts /data/branding /data/secrets /data/backups RUN mkdir -p /data/uploads /data/plugins /data/secrets /data/backups && chown -R node:node /data/uploads /data/plugins /data/secrets /data/backups
USER node USER node
EXPOSE 3000 EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \ HEALTHCHECK --interval=30s --timeout=3s --retries=3 \

View File

@ -1,45 +0,0 @@
# Runtime assets
## `reference-vs-nfd.docx` / `reference-vs-nfd.odt` (issue #209, ADR 0022)
Pandoc reference documents for the DOCX/ODT export of a **classified**
page: their page setup defines a header and footer carrying the VS-NfD
marking, which pandoc copies into its output — so the marking repeats on
every page in Word and LibreOffice and is not deletable body text.
Unclassified exports pass no reference document and are unchanged.
These are **derived binaries — never edit them by hand.** Source of truth
is `../scripts/gen-classified-reference-docs.mjs`: it takes the default
reference documents of the pinned sidecar (`pandoc/core:3.6`, the exact
image the stages run) and injects the header/footer, with the wording from
`classificationMarking()` in `@dorfteich/shared` (single source, ADR
0022). Regenerate — after a pandoc pin bump, a wording change, or a layout
tweak in the script — with Docker running:
```sh
pnpm --filter @dorfteich/shared build # the script imports the wording
node apps/api/scripts/gen-classified-reference-docs.mjs
```
Commit script and binaries together. The fidelity suite
(`export.fidelity.test.ts`) asserts against the real pinned pandoc that a
marked export carries the header/footer parts and an unmarked one does
not.
### Per-page verification in the office suites
After regenerating, confirm the marking repeats on **every** page of a
multi-page export (not just structurally in the XML):
1. Produce a marked multi-page export (any classified page with a few
screens of text, exported to `.docx` and `.odt`).
2. **LibreOffice** (scriptable):
`soffice --headless --convert-to pdf <file>` and check every PDF page
shows the marking twice (header + footer) — e.g. with `pypdf`.
3. **Word**: open the `.docx`, check header and footer on every page
(print preview). Word's AppleScript/sandbox makes this hard to script —
this step is a quick manual look.
Last verified 2026-07-31 (pandoc 3.6 output): LibreOffice 25.8, both
formats, 5/5 pages with 2 markings each. Word: manual check pending —
sample files in the workspace under `doku/209-marked-sample.docx/.odt`.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 683 B

Binary file not shown.

View File

@ -30,7 +30,6 @@
"fflate": "^0.8.3", "fflate": "^0.8.3",
"fractional-indexing": "^4.0.0", "fractional-indexing": "^4.0.0",
"i18next": "^26.3.4", "i18next": "^26.3.4",
"jose": "^6.2.4",
"jsdom": "^26.1.0", "jsdom": "^26.1.0",
"multer": "^2.1.1", "multer": "^2.1.1",
"nestjs-pino": "^4.3.0", "nestjs-pino": "^4.3.0",

View File

@ -1,8 +0,0 @@
-- AlterTable
ALTER TABLE "pages" ADD COLUMN "parent_id" TEXT;
-- CreateIndex
CREATE INDEX "pages_parent_id_idx" ON "pages"("parent_id");
-- AddForeignKey
ALTER TABLE "pages" ADD CONSTRAINT "pages_parent_id_fkey" FOREIGN KEY ("parent_id") REFERENCES "pages"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "conversion_jobs" ADD COLUMN "options" JSONB;

View File

@ -1,16 +0,0 @@
-- Personal page favorites (issue #132).
CREATE TABLE "page_favorites" (
"user_id" TEXT NOT NULL,
"page_id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "page_favorites_pkey" PRIMARY KEY ("user_id", "page_id")
);
CREATE INDEX "page_favorites_page_id_idx" ON "page_favorites"("page_id");
ALTER TABLE "page_favorites" ADD CONSTRAINT "page_favorites_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "page_favorites" ADD CONSTRAINT "page_favorites_page_id_fkey"
FOREIGN KEY ("page_id") REFERENCES "pages"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -1,5 +0,0 @@
-- CreateIndex
CREATE INDEX "pages_pond_id_created_at_idx" ON "pages"("pond_id", "created_at");
-- CreateIndex
CREATE INDEX "pages_pond_id_updated_at_idx" ON "pages"("pond_id", "updated_at");

View File

@ -1,20 +0,0 @@
-- CreateTable
CREATE TABLE "feed_tokens" (
"id" TEXT NOT NULL,
"token_hash" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"last_used_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "feed_tokens_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "feed_tokens_token_hash_key" ON "feed_tokens"("token_hash");
-- CreateIndex
CREATE INDEX "feed_tokens_user_id_idx" ON "feed_tokens"("user_id");
-- AddForeignKey
ALTER TABLE "feed_tokens" ADD CONSTRAINT "feed_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -1,16 +0,0 @@
-- CreateTable
CREATE TABLE "page_mentions" (
"page_id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
CONSTRAINT "page_mentions_pkey" PRIMARY KEY ("page_id","user_id")
);
-- CreateIndex
CREATE INDEX "page_mentions_user_id_idx" ON "page_mentions"("user_id");
-- AddForeignKey
ALTER TABLE "page_mentions" ADD CONSTRAINT "page_mentions_page_id_fkey" FOREIGN KEY ("page_id") REFERENCES "pages"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "page_mentions" ADD CONSTRAINT "page_mentions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -1,4 +0,0 @@
-- Issue #194: attachments have exactly one deletion semantics (hard delete
-- by sweep, purge, or manual removal) — the never-written soft-delete
-- marker goes away.
ALTER TABLE "attachments" DROP COLUMN "deleted_at";

View File

@ -1,10 +0,0 @@
-- Issue #195, one-off backfill: the full-text index must hold no trashed
-- content. Clears the search vector of every page that is trashed itself
-- or lives in a trashed pond; the application keeps this invariant from
-- now on (trash hooks + reindex paths).
UPDATE page_content_cache c
SET search_vector = NULL
FROM pages p
LEFT JOIN ponds po ON po.id = p.pond_id
WHERE p.id = c.page_id
AND (p.deleted_at IS NOT NULL OR po.deleted_at IS NOT NULL);

View File

@ -1,16 +0,0 @@
-- #233: conversion job payloads become prunable. The raw input/result bytes
-- are transient; a daily job nulls them once a finished job passes
-- `conversion.payloadRetentionDays` (default 30). The row survives for
-- status/audit purposes.
ALTER TABLE "conversion_jobs" ALTER COLUMN "input" DROP NOT NULL;
-- Backfill: clear the payloads of jobs that already finished longer ago than
-- the default period. Recently finished jobs keep their bytes so a pending
-- download still works; the scheduled job picks them up when they age out.
-- PENDING/RUNNING rows are untouched (the worker's stale-lock recovery may
-- still re-run them).
UPDATE "conversion_jobs"
SET "input" = NULL, "result" = NULL, "result_mime_type" = NULL
WHERE "status" IN ('SUCCEEDED', 'FAILED')
AND "updated_at" < now() - interval '30 days'
AND ("input" IS NOT NULL OR "result" IS NOT NULL);

View File

@ -1,5 +0,0 @@
-- #199: integrity hash for uploaded files. New uploads store the SHA-256 of
-- their bytes at write time; existing rows are hashed by the nightly
-- backfill (part of the orphan-file-sweep job), which reads the uploads
-- volume — something this SQL migration cannot do.
ALTER TABLE "attachments" ADD COLUMN "sha256" TEXT;

View File

@ -1,8 +0,0 @@
-- #204 (ADR 0022): classification becomes first-class page metadata. The
-- column is a marking, not a protection mechanism — permissions are
-- untouched. NOT NULL with a default backfills every existing page to
-- UNCLASSIFIED in the same statement.
CREATE TYPE "PageClassification" AS ENUM ('UNCLASSIFIED', 'VS_NFD');
ALTER TABLE "pages"
ADD COLUMN "classification" "PageClassification" NOT NULL DEFAULT 'UNCLASSIFIED';

View File

@ -1,23 +0,0 @@
-- #222 (ADR 0023): read-access trail for classified pages. Its own table —
-- volume, purpose and legal basis differ from audit_log. No foreign keys:
-- evidence must survive page purges and hard user deletions unchanged.
-- Partitioning and retention follow in #224.
CREATE TABLE "read_events" (
"id" TEXT NOT NULL,
"occurred_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"actor_id" TEXT,
"session_key" TEXT NOT NULL,
"page_id" TEXT,
"pond_id" TEXT NOT NULL,
"channel" TEXT NOT NULL,
"classification" TEXT NOT NULL,
"details" JSONB,
CONSTRAINT "read_events_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "read_events_page_id_occurred_at_idx" ON "read_events"("page_id", "occurred_at");
CREATE INDEX "read_events_actor_id_occurred_at_idx" ON "read_events"("actor_id", "occurred_at");
CREATE INDEX "read_events_occurred_at_idx" ON "read_events"("occurred_at");

View File

@ -1,32 +0,0 @@
-- #223 (ADR 0023): dedup window for the read trail. Aligned buckets
-- (floor(epoch / window)) with a unique (dedup_key, window_bucket) pair make
-- concurrent duplicates collapse race-free at insert time.
ALTER TABLE "read_events"
ADD COLUMN "dedup_key" TEXT,
ADD COLUMN "window_bucket" BIGINT,
ADD COLUMN "window_seconds" INTEGER;
-- Backfill rows written between the #222 and #223 deploys under the default
-- 5-minute window, then apply the window's own semantics retroactively:
-- within one (key, bucket) pair only the FIRST event is the evidence row —
-- exactly what the window would have recorded had it existed.
UPDATE "read_events"
SET "dedup_key" = "session_key" || ':' || COALESCE("page_id", '-') || ':' || "channel",
"window_bucket" = FLOOR(EXTRACT(EPOCH FROM "occurred_at") / 300)::BIGINT,
"window_seconds" = 300
WHERE "dedup_key" IS NULL;
DELETE FROM "read_events" keep
USING "read_events" first
WHERE keep."dedup_key" = first."dedup_key"
AND keep."window_bucket" = first."window_bucket"
AND (first."occurred_at" < keep."occurred_at"
OR (first."occurred_at" = keep."occurred_at" AND first."id" < keep."id"));
ALTER TABLE "read_events"
ALTER COLUMN "dedup_key" SET NOT NULL,
ALTER COLUMN "window_bucket" SET NOT NULL,
ALTER COLUMN "window_seconds" SET NOT NULL;
CREATE UNIQUE INDEX "read_events_dedup_key_window_bucket_key"
ON "read_events"("dedup_key", "window_bucket");

View File

@ -1,76 +0,0 @@
-- #224 (ADR 0023): convert read_events to monthly RANGE partitions on
-- occurred_at. Volume grows unbounded with use; retention then DROPs whole
-- expired partitions instead of scanning deletes. The primary key gains the
-- partition column (PostgreSQL requirement); the dedup unique pair
-- (dedup_key, window_bucket) moves to PER-PARTITION unique indexes — a
-- partitioned parent cannot carry it without the partition key. A bucket
-- spanning a month boundary can therefore record one duplicate; documented
-- in ADR 0023, over-recording is acceptable, gaps are not.
--
-- A DEFAULT partition catches rows outside every maintained range, so a
-- lagging maintenance job can never make classified reads fail (the trail's
-- hard-failure semantics would otherwise turn an ops miss into an outage).
ALTER TABLE "read_events" RENAME TO "read_events_old";
ALTER INDEX "read_events_pkey" RENAME TO "read_events_old_pkey";
ALTER INDEX "read_events_dedup_key_window_bucket_key" RENAME TO "read_events_old_dedup_key";
ALTER INDEX "read_events_page_id_occurred_at_idx" RENAME TO "read_events_old_page_idx";
ALTER INDEX "read_events_actor_id_occurred_at_idx" RENAME TO "read_events_old_actor_idx";
ALTER INDEX "read_events_occurred_at_idx" RENAME TO "read_events_old_at_idx";
CREATE TABLE "read_events" (
"id" TEXT NOT NULL,
"occurred_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"actor_id" TEXT,
"session_key" TEXT NOT NULL,
"page_id" TEXT,
"pond_id" TEXT NOT NULL,
"channel" TEXT NOT NULL,
"classification" TEXT NOT NULL,
"details" JSONB,
"dedup_key" TEXT NOT NULL,
"window_bucket" BIGINT NOT NULL,
"window_seconds" INTEGER NOT NULL,
CONSTRAINT "read_events_pkey" PRIMARY KEY ("id", "occurred_at")
) PARTITION BY RANGE ("occurred_at");
-- Non-unique parent indexes propagate to every partition automatically.
CREATE INDEX "read_events_page_id_occurred_at_idx" ON "read_events"("page_id", "occurred_at");
CREATE INDEX "read_events_actor_id_occurred_at_idx" ON "read_events"("actor_id", "occurred_at");
CREATE INDEX "read_events_occurred_at_idx" ON "read_events"("occurred_at");
-- The safety-net partition, plus the current and the next month — the daily
-- maintenance job (read-trail-maintenance) keeps creating months ahead and
-- adds the same per-partition dedup index to each new one.
CREATE TABLE "read_events_default" PARTITION OF "read_events" DEFAULT;
CREATE UNIQUE INDEX "read_events_default_dedup_key"
ON "read_events_default"("dedup_key", "window_bucket");
DO $$
DECLARE
m DATE;
part TEXT;
BEGIN
FOR i IN 0..1 LOOP
m := date_trunc('month', now())::date + (i || ' month')::interval;
part := 'read_events_y' || to_char(m, 'YYYY') || 'm' || to_char(m, 'MM');
EXECUTE format(
'CREATE TABLE %I PARTITION OF "read_events" FOR VALUES FROM (%L) TO (%L)',
part, m, m + interval '1 month');
EXECUTE format(
'CREATE UNIQUE INDEX %I ON %I ("dedup_key", "window_bucket")',
part || '_dedup_key', part);
END LOOP;
END $$;
INSERT INTO "read_events"
("id", "occurred_at", "actor_id", "session_key", "page_id", "pond_id",
"channel", "classification", "details", "dedup_key", "window_bucket",
"window_seconds")
SELECT "id", "occurred_at", "actor_id", "session_key", "page_id", "pond_id",
"channel", "classification", "details", "dedup_key", "window_bucket",
"window_seconds"
FROM "read_events_old";
DROP TABLE "read_events_old";

View File

@ -1,9 +0,0 @@
-- #217 (ADR 0021): IdP claim mapping. Grants gain an origin so mapped rows
-- are distinguishable from manual ones (the mapping only ever touches its
-- own); the site-admin flag gains a "managed" marker so only a
-- mapping-granted flag can be mapping-revoked.
ALTER TABLE "role_grants"
ADD COLUMN "origin" TEXT NOT NULL DEFAULT 'manual';
ALTER TABLE "users"
ADD COLUMN "is_site_admin_managed" BOOLEAN NOT NULL DEFAULT false;

View File

@ -1,4 +0,0 @@
-- #232: SHA-256 of the installed bundle ZIP, observed at install time.
-- NULL for plugins installed before this migration — the admin UI says so
-- and a reinstall records it.
ALTER TABLE "plugins" ADD COLUMN "bundle_hash" TEXT;

View File

@ -1,45 +0,0 @@
-- #303: operator-uploaded font families (ADR 0016 §#303).
-- The bytes live on disk under CUSTOM_FONTS_DIR; these rows record only what
-- the upload form stated, because the api never parses the font file.
CREATE TABLE "custom_fonts" (
"id" TEXT NOT NULL,
"family" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"category" TEXT NOT NULL,
"licence" TEXT NOT NULL,
"licence_url" TEXT,
"uploaded_by" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "custom_fonts_pkey" PRIMARY KEY ("id")
);
-- Both unique: `family` keeps `fonts.<slot>.family` in pond settings
-- unambiguous, `slug` owns a directory under CUSTOM_FONTS_DIR.
CREATE UNIQUE INDEX "custom_fonts_family_key" ON "custom_fonts"("family");
CREATE UNIQUE INDEX "custom_fonts_slug_key" ON "custom_fonts"("slug");
ALTER TABLE "custom_fonts" ADD CONSTRAINT "custom_fonts_uploaded_by_fkey"
FOREIGN KEY ("uploaded_by") REFERENCES "users"("id")
ON DELETE RESTRICT ON UPDATE CASCADE;
CREATE TABLE "custom_font_weights" (
"id" TEXT NOT NULL,
"font_id" TEXT NOT NULL,
"weight" INTEGER NOT NULL,
"has_woff" BOOLEAN NOT NULL DEFAULT false,
"byte_size" INTEGER NOT NULL,
CONSTRAINT "custom_font_weights_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "custom_font_weights_font_id_weight_key"
ON "custom_font_weights"("font_id", "weight");
-- Deleting a family takes its weights with it; the files on disk are removed
-- by the service in the same operation.
ALTER TABLE "custom_font_weights" ADD CONSTRAINT "custom_font_weights_font_id_fkey"
FOREIGN KEY ("font_id") REFERENCES "custom_fonts"("id")
ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -1,26 +0,0 @@
-- Peer invitations (issue #332): a user invites an e-mail address; the token
-- allows exactly one registration even while registration is closed.
-- CreateTable
CREATE TABLE "invitations" (
"id" TEXT NOT NULL,
"inviter_id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"token_hash" TEXT NOT NULL,
"expires_at" TIMESTAMP(3) NOT NULL,
"revoked_at" TIMESTAMP(3),
"accepted_at" TIMESTAMP(3),
"accepted_user_id" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "invitations_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "invitations_token_hash_key" ON "invitations"("token_hash");
-- CreateIndex
CREATE INDEX "invitations_inviter_id_idx" ON "invitations"("inviter_id");
-- AddForeignKey
ALTER TABLE "invitations" ADD CONSTRAINT "invitations_inviter_id_fkey" FOREIGN KEY ("inviter_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -37,11 +37,6 @@ model User {
displayName String @map("display_name") displayName String @map("display_name")
locale String @default("en") locale String @default("en")
isSiteAdmin Boolean @default(false) @map("is_site_admin") isSiteAdmin Boolean @default(false) @map("is_site_admin")
/// True when the flag was last SET by the IdP claim mapping (issue #217):
/// only then may the mapping revoke it again on a later login. A manual
/// admin toggle clears the marker, so hand-granted admins are never
/// demoted by a missing claim.
isSiteAdminManaged Boolean @default(false) @map("is_site_admin_managed")
/// Auto-watch preferences (issue #93): watch pages I create / comment on. /// Auto-watch preferences (issue #93): watch pages I create / comment on.
autoWatchOwnPages Boolean @default(true) @map("auto_watch_own_pages") autoWatchOwnPages Boolean @default(true) @map("auto_watch_own_pages")
autoWatchOnComment Boolean @default(true) @map("auto_watch_on_comment") autoWatchOnComment Boolean @default(true) @map("auto_watch_on_comment")
@ -56,8 +51,6 @@ model User {
sessions Session[] sessions Session[]
authTokens AuthToken[] authTokens AuthToken[]
apiTokens ApiToken[] apiTokens ApiToken[]
feedTokens FeedToken[]
mentionRows PageMention[]
ponds Pond[] ponds Pond[]
pages Page[] pages Page[]
attachments Attachment[] attachments Attachment[]
@ -66,36 +59,10 @@ model User {
comments Comment[] comments Comment[]
watches Watch[] watches Watch[]
notifications Notification[] notifications Notification[]
favorites PageFavorite[]
customFonts CustomFont[]
invitations Invitation[] @relation("InvitationsSent")
@@map("users") @@map("users")
} }
/// Peer invitations (issue #332): a user invites an e-mail address; the
/// token allows exactly one registration even while registration is
/// closed. Only the SHA-256 hash of the token is stored (auth-tokens
/// pattern); revoked/accepted rows are kept so the settings UI can show
/// history. "Open" (pending, unexpired) rows count against the per-user
/// quota `invitations.maxOpenPerUser`.
model Invitation {
id String @id @default(uuid())
inviterId String @map("inviter_id")
email String
tokenHash String @unique @map("token_hash")
expiresAt DateTime @map("expires_at")
revokedAt DateTime? @map("revoked_at")
acceptedAt DateTime? @map("accepted_at")
acceptedUserId String? @map("accepted_user_id")
createdAt DateTime @default(now()) @map("created_at")
inviter User @relation("InvitationsSent", fields: [inviterId], references: [id], onDelete: Cascade)
@@index([inviterId])
@@map("invitations")
}
/// Persistent audit trail (issue #86, security.md §Logging): auth events and /// Persistent audit trail (issue #86, security.md §Logging): auth events and
/// admin actions — grants, member roles, plugin installs, quota and settings /// admin actions — grants, member roles, plugin installs, quota and settings
/// changes, setup steps, manual job triggers. Written by AuditService, which /// changes, setup steps, manual job triggers. Written by AuditService, which
@ -121,52 +88,6 @@ model AuditEntry {
@@map("audit_log") @@map("audit_log")
} }
/// Read-access trail for classified pages (issue #222, ADR 0023): one row per
/// read of a `VS_NFD` page, per channel. Separate from `audit_log` because
/// volume, purpose and legal basis all differ. Deliberately WITHOUT foreign
/// keys: evidence must survive a page purge and a hard user deletion — the
/// ids stay as recorded (pseudonymous uuids), history is never rewritten.
///
/// In migrated databases the table is RANGE-partitioned by `occurred_at`
/// (monthly, issue #224) — hence the composite id. The dedup unique pair
/// lives per partition there (a partitioned parent cannot carry it without
/// the partition key); `db push` test databases get it on the plain table.
model ReadEvent {
id String @default(uuid())
occurredAt DateTime @default(now()) @map("occurred_at")
/// Null = anonymous reader (public grant); `sessionKey` still names the
/// browsing session, so the anonymous marker is explicit, not an accident.
actorId String? @map("actor_id")
/// `session:<id>` for cookie sessions, `token:<id>` for PATs, `job:<id>`
/// for background builds (account data export), `anon` for anonymous
/// visitors — the dedup-window key basis (#223).
sessionKey String @map("session_key")
pageId String? @map("page_id")
pondId String @map("pond_id")
/// Which read surface fired: `page_view` | `no_js_shell` | `public_api` |
/// `attachment` | `export` | `collab_join` (READ_CHANNELS union in code).
channel String
/// Classification at read time — a later reclassification must not
/// rewrite history (ADR 0023).
classification String
details Json?
/// Dedup window (issue #223): `<sessionKey>:<pageId|->:<channel>` plus the
/// aligned bucket `floor(epoch / windowSeconds)`. The unique pair makes
/// concurrent duplicate reads collapse race-free (insert or P2002-skip).
dedupKey String @map("dedup_key")
windowBucket BigInt @map("window_bucket")
/// Window length the event was recorded under — the row itself states it
/// represents up to this many seconds, so the evidence is not overread.
windowSeconds Int @map("window_seconds")
@@id([id, occurredAt])
@@unique([dedupKey, windowBucket])
@@index([pageId, occurredAt])
@@index([actorId, occurredAt])
@@index([occurredAt])
@@map("read_events")
}
/// Threaded page comments (issue #91, data-model.md §Comments). Threads are /// Threaded page comments (issue #91, data-model.md §Comments). Threads are
/// one level deep: roots carry the optional document anchor and the resolve /// one level deep: roots carry the optional document anchor and the resolve
/// state, replies reference the root via `parentId`. Purging a page cascades /// state, replies reference the root via `parentId`. Purging a page cascades
@ -315,10 +236,6 @@ model RoleGrant {
scopeType GrantScopeType @map("scope_type") scopeType GrantScopeType @map("scope_type")
scopeId String? @map("scope_id") scopeId String? @map("scope_id")
effect GrantEffect effect GrantEffect
/// `manual` (admin-created) or `idp` (written by the claim mapping,
/// issue #217). The mapping only ever creates and revokes ITS OWN rows —
/// manual grants are never touched, which is the documented precedence.
origin String @default("manual")
createdBy String @map("created_by") createdBy String @map("created_by")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
@ -334,31 +251,13 @@ model RoleGrant {
/// the merged state Y.Doc, decoded by the API to derive `PageContentCache` /// the merged state Y.Doc, decoded by the API to derive `PageContentCache`
/// on every save (issue #23). `sortKey` uses fractional indexing so pages /// on every save (issue #23). `sortKey` uses fractional indexing so pages
/// can be reordered without rewriting siblings (sidebar reorder is #26). /// can be reordered without rewriting siblings (sidebar reorder is #26).
/// `parentId` nests pages into a tree (issue #106), mirroring the label
/// hierarchy (max 6 levels, enforced in the service; cycles rejected at write
/// time). Purely organizational: slugs stay flat and pond-unique, so moving a
/// page never changes its URL or breaks wikilinks. Trashed pages keep their
/// `parentId` (restore re-attaches to the nearest live ancestor, issue #107);
/// `SetNull` is only the FK backstop — purge promotes children explicitly.
/// VS-NfD marking level of a page (ADR 0022). Deliberately an enum on Page,
/// not a label: instance-wide meaning, not user-deletable in routine content
/// work, inherits down the tree (#205), reaches every output channel
/// (#206#212). It is a MARKING, not a protection mechanism — separation of
/// levels happens outside the application (one instance per level).
enum PageClassification {
UNCLASSIFIED
VS_NFD
}
model Page { model Page {
id String @id @default(uuid()) id String @id @default(uuid())
pondId String @map("pond_id") pondId String @map("pond_id")
parentId String? @map("parent_id")
title String title String
slug String slug String
ydocState Bytes @map("ydoc_state") ydocState Bytes @map("ydoc_state")
sortKey String @map("sort_key") sortKey String @map("sort_key")
classification PageClassification @default(UNCLASSIFIED)
createdBy String @map("created_by") createdBy String @map("created_by")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
@ -366,29 +265,20 @@ model Page {
deletedBy String? @map("deleted_by") deletedBy String? @map("deleted_by")
pond Pond @relation(fields: [pondId], references: [id]) pond Pond @relation(fields: [pondId], references: [id])
parent Page? @relation("PageHierarchy", fields: [parentId], references: [id], onDelete: SetNull)
children Page[] @relation("PageHierarchy")
creator User @relation(fields: [createdBy], references: [id]) creator User @relation(fields: [createdBy], references: [id])
updates PageUpdate[] updates PageUpdate[]
contentCache PageContentCache? contentCache PageContentCache?
attachments Attachment[] attachments Attachment[]
versions PageVersion[] versions PageVersion[]
pendingContributors PagePendingContributor[] pendingContributors PagePendingContributor[]
mentionRows PageMention[]
labels PageLabel[] labels PageLabel[]
outgoingLinks PageLink[] @relation("outgoingLinks") outgoingLinks PageLink[] @relation("outgoingLinks")
comments Comment[] comments Comment[]
incomingLinks PageLink[] @relation("incomingLinks") incomingLinks PageLink[] @relation("incomingLinks")
conversionJobs ConversionJob[] conversionJobs ConversionJob[]
favorites PageFavorite[]
@@unique([pondId, slug]) @@unique([pondId, slug])
@@index([pondId]) @@index([pondId])
@@index([parentId])
// Time-filtered listings (issue #148): "pages of this pond created/updated
// since X" hit these instead of scanning the pond.
@@index([pondId, createdAt])
@@index([pondId, updatedAt])
@@map("pages") @@map("pages")
} }
@ -442,21 +332,6 @@ model PageVersion {
/// Collab flushes the current session's contributors here (deduplicated by the /// Collab flushes the current session's contributors here (deduplicated by the
/// composite key); version creation on either side reads and clears it in the /// composite key); version creation on either side reads and clears it in the
/// same transaction as writing the snapshot. Cascades on page purge (ADR 0013). /// same transaction as writing the snapshot. Cascades on page purge (ADR 0013).
/// Derived mention index (issue #151): one row per user currently
/// mentioned in the page's document. Rewritten on every collab persist;
/// the diff against the previous rows drives the `mentioned` notifications.
model PageMention {
pageId String @map("page_id")
userId String @map("user_id")
page Page @relation(fields: [pageId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@id([pageId, userId])
@@index([userId])
@@map("page_mentions")
}
model PagePendingContributor { model PagePendingContributor {
pageId String @map("page_id") pageId String @map("page_id")
userId String @map("user_id") userId String @map("user_id")
@ -565,22 +440,6 @@ model PageLabel {
@@map("page_labels") @@map("page_labels")
} }
/// Personal page favorites (issue #132) — per user, deliberately NOT
/// pond-wide (planning pivot documented on the issue). Trashed pages keep
/// their rows, so a restore keeps the star; a purge cascades them away.
model PageFavorite {
userId String @map("user_id")
pageId String @map("page_id")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
page Page @relation(fields: [pageId], references: [id], onDelete: Cascade)
@@id([userId, pageId])
@@index([pageId])
@@map("page_favorites")
}
enum QuotaSubjectType { enum QuotaSubjectType {
USER USER
POND POND
@ -620,18 +479,15 @@ model PondUsage {
/// `<uploadsDir>/<pondId>/<id>` (FileStorageService); this row carries the /// `<uploadsDir>/<pondId>/<id>` (FileStorageService); this row carries the
/// metadata needed to serve and account for it. `pageId` starts unset — /// metadata needed to serve and account for it. `pageId` starts unset —
/// images are uploaded before the page referencing them is known /// images are uploaded before the page referencing them is known
/// (paste-then-insert, issue #28) — and is claimed on every collab persist /// (paste-then-insert, issue #28) — and is set on every page state save to
/// by whichever page's document embeds the file (issue #31), or at upload /// whichever page's document currently embeds the file (issue #31,
/// for the page attachments panel (#61); the trash-purge job uses that /// `PagesService.saveState`); the trash-purge job uses that link to delete
/// link to delete a purged page's files. A row whose `pageId` is STILL /// a purged page's files. Not touched when an image is later removed from
/// null after a grace period was claimed by nothing and is reclaimed by /// its page's content — an orphan-file sweep to reclaim those is a
/// the nightly orphan-file sweep (issue #194, OrphanSweepService). /// separate future maintenance job (operations.md), not this one.
/// Claimed files are deliberately NOT auto-reclaimed when the content /// `deletedAt` stays unused for now — purge hard-deletes attachments
/// stops referencing them: the page attachments panel lists them as /// directly rather than soft-deleting them first — reserved for that same
/// user-managed objects (insert is optional there), so "not embedded" is /// future orphan-sweep job.
/// not "unused" — the pond file manager is the human cleanup path.
/// Deletion is hard everywhere (sweep, purge, manual) — there is no
/// soft-delete state on attachments (issue #194 removed `deletedAt`).
model Attachment { model Attachment {
id String @id @default(uuid()) id String @id @default(uuid())
pondId String @map("pond_id") pondId String @map("pond_id")
@ -641,12 +497,8 @@ model Attachment {
sizeBytes Int @map("size_bytes") sizeBytes Int @map("size_bytes")
storagePath String @map("storage_path") storagePath String @map("storage_path")
uploadedBy String @map("uploaded_by") uploadedBy String @map("uploaded_by")
/// SHA-256 (hex) of the stored bytes (issue #199), computed from the
/// in-memory upload buffer as it is written — never by re-reading disk.
/// Downloads verify against it and fail closed on mismatch. Null only
/// for rows that predate #199 until the nightly backfill hashes them.
sha256 String?
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
deletedAt DateTime? @map("deleted_at")
pond Pond @relation(fields: [pondId], references: [id]) pond Pond @relation(fields: [pondId], references: [id])
page Page? @relation(fields: [pageId], references: [id]) page Page? @relation(fields: [pageId], references: [id])
@ -724,24 +576,6 @@ enum ApiTokenScope {
/// user — the whole permission model applies — narrowed by `scope` and the /// user — the whole permission model applies — narrowed by `scope` and the
/// optional pond restriction. Revoking keeps the row so the settings UI can /// optional pond restriction. Revoking keeps the row so the settings UI can
/// show history; validation skips revoked/expired rows. /// show history; validation skips revoked/expired rows.
/// Read-only feed authentication (issue #149): a `dt_feed_…` secret carried as
/// a query parameter in Atom feed URLs, so feed readers can subscribe to
/// non-public ponds/pages. Deliberately much narrower than an ApiToken —
/// it can only ever authenticate the two feed endpoints, never the API.
model FeedToken {
id String @id @default(uuid())
tokenHash String @unique @map("token_hash")
userId String @map("user_id")
name String
lastUsedAt DateTime? @map("last_used_at")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@map("feed_tokens")
}
model ApiToken { model ApiToken {
id String @id @default(uuid()) id String @id @default(uuid())
tokenHash String @unique @map("token_hash") tokenHash String @unique @map("token_hash")
@ -838,11 +672,9 @@ enum ConversionJobStatus {
/// enqueued PENDING, a worker claims it (`FOR UPDATE SKIP LOCKED`, `lockedAt` /// enqueued PENDING, a worker claims it (`FOR UPDATE SKIP LOCKED`, `lockedAt`
/// recovers a crashed run), calls the pandoc sidecar with a timeout, and /// recovers a crashed run), calls the pandoc sidecar with a timeout, and
/// stores the output bytes or an `errorCode`. `input`/`result` are the raw /// stores the output bytes or an `errorCode`. `input`/`result` are the raw
/// document bytes — kept small by the request size limit and transient, not /// document bytes — kept small by the request size limit and pruned by a
/// the durable copy an Attachment is: the daily `conversion-payload-prune` /// later maintenance job (they are transient, not the durable copy an
/// job (#233) nulls both once a finished job passes /// Attachment is). The polling endpoint `GET /jobs/:id` is owner-scoped.
/// `conversion.payloadRetentionDays`; the row survives for status/audit.
/// The polling endpoint `GET /jobs/:id` is owner-scoped.
model ConversionJob { model ConversionJob {
id String @id @default(uuid()) id String @id @default(uuid())
ownerId String @map("owner_id") ownerId String @map("owner_id")
@ -852,9 +684,7 @@ model ConversionJob {
sourceFormat String @map("source_format") sourceFormat String @map("source_format")
targetFormat String @map("target_format") targetFormat String @map("target_format")
standalone Boolean @default(true) standalone Boolean @default(true)
/// Null once the retention job (#233) pruned a finished job's payload — input Bytes
/// never while the job is PENDING/RUNNING (incl. stale-lock recovery).
input Bytes?
status ConversionJobStatus @default(PENDING) status ConversionJobStatus @default(PENDING)
attempts Int @default(0) attempts Int @default(0)
result Bytes? result Bytes?
@ -863,12 +693,8 @@ model ConversionJob {
lockedAt DateTime? @map("locked_at") lockedAt DateTime? @map("locked_at")
/// For a data-export job (#68): when its stored result stops being /// For a data-export job (#68): when its stored result stops being
/// downloadable and is purged (GDPR data minimization). Null for every /// downloadable and is purged (GDPR data minimization). Null for every
/// other job kind, whose payload the general retention (#233) prunes. /// other job kind, whose result never expires.
expiresAt DateTime? @map("expires_at") expiresAt DateTime? @map("expires_at")
/// Kind-specific job options (issue #117): a vault import carries
/// `{parentPageId, labelIds, frontmatterMode}`; a PDF/DOCX/ODT export of a
/// classified page carries `{marking}` (issues #208/#209). Null otherwise.
options Json?
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
@ -910,8 +736,6 @@ model Plugin {
mode PluginInstanceMode @default(DISABLED) mode PluginInstanceMode @default(DISABLED)
/// The full manifest as validated at install time (@dorfteich/plugin-sdk). /// The full manifest as validated at install time (@dorfteich/plugin-sdk).
manifest Json manifest Json
/// SHA-256 (hex) of the installed bundle ZIP (#232); null = pre-#232 install.
bundleHash String? @map("bundle_hash")
installedAt DateTime @default(now()) @map("installed_at") installedAt DateTime @default(now()) @map("installed_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
/// Set when uninstalled; active queries filter `removedAt: null`. /// Set when uninstalled; active queries filter `removedAt: null`.
@ -938,48 +762,3 @@ model PondPlugin {
@@id([pondId, pluginId]) @@id([pondId, pluginId])
@@map("pond_plugins") @@map("pond_plugins")
} }
/// An operator-uploaded font family (issue #303, ADR 0016 §#303). The bytes
/// live on disk under CUSTOM_FONTS_DIR — this row only records what the
/// upload form stated, because the api never parses the font file itself.
/// Additive to the compile-time catalog: a family whose name or slug
/// collides with a catalog entry is rejected, so `fonts.<slot>.family` in a
/// pond's settings stays unambiguous.
model CustomFont {
id String @id @default(uuid())
/// CSS `font-family` name, as typed by the uploader.
family String @unique
/// URL/file-safe form; names the directory under CUSTOM_FONTS_DIR.
slug String @unique
/// Drives the system fallback stack, like FontCatalogEntry.category.
category String
/// Free-text licence label, e.g. "Commercial — Foundry XY". Required so
/// an attribution obligation can be met on the font catalogue page.
licence String
licenceUrl String? @map("licence_url")
uploadedBy String @map("uploaded_by")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
uploader User @relation(fields: [uploadedBy], references: [id])
weights CustomFontWeight[]
@@map("custom_fonts")
}
/// One weight of a custom family. Style is always `normal`: the PDF
/// `@font-face` builder emits only that, and browsers synthesise oblique —
/// italic uploads are a follow-up, not a silent half-feature.
model CustomFontWeight {
id String @id @default(uuid())
fontId String @map("font_id")
weight Int
/// Whether a legacy WOFF was supplied next to the required WOFF2.
hasWoff Boolean @default(false) @map("has_woff")
byteSize Int @map("byte_size")
font CustomFont @relation(fields: [fontId], references: [id], onDelete: Cascade)
@@unique([fontId, weight])
@@map("custom_font_weights")
}

View File

@ -311,36 +311,6 @@ async function seedContentFixtures(ownerId: string): Promise<void> {
deriveContentOf(everyElementDoc), deriveContentOf(everyElementDoc),
); );
// "Classified Note" (issue #206, ADR 0022): a VS-NfD-marked page so e2e
// (a11y pack) can assert the marking banner in both themes. Kept simple —
// the marking, not the content, is what the fixture exists for.
const classifiedDoc = editorSchema.node('doc', null, [
editorSchema.node('heading', { level: 1 }, [editorSchema.text('Classified Note')]),
editorSchema.node('paragraph', null, [
editorSchema.text('This fixture page carries the VS-NfD marking.'),
]),
]);
const classifiedYdoc = new Y.Doc();
prosemirrorJSONToYXmlFragment(
editorSchema,
classifiedDoc.toJSON(),
classifiedYdoc.getXmlFragment('default'),
);
const classifiedState = new Uint8Array(Y.encodeStateAsUpdate(classifiedYdoc));
classifiedYdoc.destroy();
const classifiedPageId = await upsertFixturePage(
pond.id,
'classified-note',
'Classified Note',
ownerId,
classifiedState,
deriveContentOf(classifiedDoc),
);
await prisma.page.update({
where: { id: classifiedPageId },
data: { classification: 'VS_NFD' },
});
// "Fixture Image": one real, servable uploaded image (the Markdown // "Fixture Image": one real, servable uploaded image (the Markdown
// fixture above only carries a placeholder fileId for round-trip // fixture above only carries a placeholder fileId for round-trip
// testing — this is the one that actually resolves via /media/:fileId). // testing — this is the one that actually resolves via /media/:fileId).

View File

@ -1,118 +0,0 @@
/**
* Regenerate the classified reference documents (issue #209, ADR 0022):
* `apps/api/assets/reference-vs-nfd.docx` / `.odt`.
*
* The DOCX/ODT export of a classified page passes these to pandoc via
* `--reference-doc`; pandoc copies the reference's page setup including
* headers and footers into its output, which is how the VS-NfD marking
* repeats on every page in Word and LibreOffice without being deletable
* body text.
*
* The binaries are DERIVED files: base = the default reference documents of
* the PINNED pandoc (`pandoc/core:3.6`, the exact sidecar the stages run),
* plus a header and footer carrying the marking. Never edit the binaries by
* hand edit this script and re-run it (Docker required):
*
* node apps/api/scripts/gen-classified-reference-docs.mjs
*
* The marking wording comes from @dorfteich/shared (single source, ADR
* 0022); the shared package must be built (`pnpm --filter @dorfteich/shared
* build`).
*/
import { execFileSync } from 'node:child_process';
import { mkdirSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { classificationMarking } from '@dorfteich/shared';
import { strToU8, strFromU8, unzipSync, zipSync } from 'fflate';
const PANDOC_IMAGE = 'pandoc/core:3.6';
const MARKING = classificationMarking('vs_nfd');
const outDir = join(dirname(fileURLToPath(import.meta.url)), '../assets');
function defaultReference(name) {
return execFileSync('docker', ['run', '--rm', PANDOC_IMAGE, '--print-default-data-file', name], {
maxBuffer: 64 * 1024 * 1024,
});
}
function escapeXml(value) {
return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
/** DOCX: add word/header1.xml + word/footer1.xml, register them in the
* content types and document relationships, and reference them from the
* document's sectPr Word repeats them on every page. */
function patchDocx(bytes) {
const zip = unzipSync(new Uint8Array(bytes));
const marking = escapeXml(MARKING);
const partXml = (root) =>
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n` +
`<w:${root} xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">` +
`<w:p><w:pPr><w:jc w:val="center"/></w:pPr>` +
`<w:r><w:rPr><w:b/></w:rPr><w:t xml:space="preserve">${marking}</w:t></w:r>` +
`</w:p></w:${root}>`;
zip['word/header1.xml'] = strToU8(partXml('hdr'));
zip['word/footer1.xml'] = strToU8(partXml('ftr'));
const types = strFromU8(zip['[Content_Types].xml']);
zip['[Content_Types].xml'] = strToU8(
types.replace(
'</Types>',
'<Override PartName="/word/header1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml" />' +
'<Override PartName="/word/footer1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml" />' +
'</Types>',
),
);
const rels = strFromU8(zip['word/_rels/document.xml.rels']);
zip['word/_rels/document.xml.rels'] = strToU8(
rels.replace(
'</Relationships>',
'<Relationship Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Id="rIdVsNfdHeader" Target="header1.xml" />' +
'<Relationship Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer" Id="rIdVsNfdFooter" Target="footer1.xml" />' +
'</Relationships>',
),
);
const doc = strFromU8(zip['word/document.xml']);
if (!doc.includes('<w:sectPr>')) throw new Error('reference.docx has no sectPr');
zip['word/document.xml'] = strToU8(
doc.replace(
'<w:sectPr>',
'<w:sectPr>' +
'<w:headerReference xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" w:type="default" r:id="rIdVsNfdHeader" />' +
'<w:footerReference xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" w:type="default" r:id="rIdVsNfdFooter" />',
),
);
return zipSync(zip);
}
/** ODT: give the Standard master page a header with the marking and put the
* marking next to the existing page number in its footer LibreOffice
* repeats master-page headers/footers on every page. */
function patchOdt(bytes) {
const zip = unzipSync(new Uint8Array(bytes));
const marking = escapeXml(MARKING);
const styles = strFromU8(zip['styles.xml']);
if (!styles.includes('<style:footer>')) throw new Error('reference.odt has no footer');
const patched = styles
.replace(
'<style:footer>',
`<style:header><text:p text:style-name="MP1">${marking}</text:p></style:header><style:footer>`,
)
.replace(
'<style:footer>\n <text:p text:style-name="MP1">',
`<style:footer>\n <text:p text:style-name="MP1">${marking} · `,
);
zip['styles.xml'] = strToU8(patched);
return zipSync(zip);
}
mkdirSync(outDir, { recursive: true });
writeFileSync(join(outDir, 'reference-vs-nfd.docx'), patchDocx(defaultReference('reference.docx')));
writeFileSync(join(outDir, 'reference-vs-nfd.odt'), patchOdt(defaultReference('reference.odt')));
console.log(`generated reference-vs-nfd.docx/.odt in ${outDir} (marking: ${MARKING})`);

View File

@ -1,127 +0,0 @@
#!/usr/bin/env node
/**
* Generates the shipped default favicons (issue #306):
* `apps/api/assets/default-favicon-32.png` and `-180.png`.
*
* The api serves these whenever an operator has not uploaded one, so an
* instance always has a tab icon the `<link rel="icon">` in index.html is
* static and its resource must never 404.
*
* Drawn here rather than pulled in as a binary: the whole toolchain must
* survive the `--network none` offline build (96-offline-build-protokoll.md),
* and adding an image library for one 32×32 icon would be the tail wagging
* the dog. Node's own zlib is enough to write a PNG.
*
* Motif: a pond seen from above the accent-green disc with two ripples.
*
* Regenerate with `node apps/api/scripts/gen-default-favicon.mjs`, commit
* script and binaries together.
*/
import { deflateSync } from 'node:zlib';
import { writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
/** Brand green — the same value as index.html's light `theme-color`. */
const GREEN = [0x2f, 0x6f, 0x4f];
const LIGHT = [0xe8, 0xf2, 0xec];
const crcTable = Array.from({ length: 256 }, (_, n) => {
let c = n;
for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
return c >>> 0;
});
function crc32(buf) {
let c = 0xffffffff;
for (const byte of buf) c = crcTable[(c ^ byte) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
function chunk(type, data) {
const length = Buffer.alloc(4);
length.writeUInt32BE(data.length);
const body = Buffer.concat([Buffer.from(type, 'ascii'), data]);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(body));
return Buffer.concat([length, body, crc]);
}
/** Minimal RGBA PNG writer — no filtering, one IDAT. */
function encodePng(size, rgba) {
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(size, 0);
ihdr.writeUInt32BE(size, 4);
ihdr[8] = 8; // bit depth
ihdr[9] = 6; // colour type RGBA
const raw = Buffer.alloc(size * (size * 4 + 1));
for (let y = 0; y < size; y += 1) {
raw[y * (size * 4 + 1)] = 0; // filter: none
rgba.copy(raw, y * (size * 4 + 1) + 1, y * size * 4, (y + 1) * size * 4);
}
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
chunk('IHDR', ihdr),
chunk('IDAT', deflateSync(raw, { level: 9 })),
chunk('IEND', Buffer.alloc(0)),
]);
}
/**
* Colour at one point of the unit square, in continuous coordinates the
* caller supersamples it, which is where the anti-aliasing comes from.
*/
function sample(x, y) {
const dx = x - 0.5;
const dy = y - 0.5;
const r = Math.hypot(dx, dy);
if (r > 0.48) return null; // outside the disc: transparent
// Two ripples spreading from a point struck slightly above centre — rings
// rather than a bullseye, which is why the centre stays green and the
// spacing widens outward the way real ripples do.
const rr = Math.hypot(dx, dy + 0.06);
const onRing = (radius, width) => Math.abs(rr - radius) < width;
if (onRing(0.33, 0.028) || onRing(0.19, 0.026)) return LIGHT;
return GREEN;
}
function render(size) {
const SS = 4; // supersampling factor
const out = Buffer.alloc(size * size * 4);
for (let y = 0; y < size; y += 1) {
for (let x = 0; x < size; x += 1) {
let r = 0;
let g = 0;
let b = 0;
let a = 0;
for (let sy = 0; sy < SS; sy += 1) {
for (let sx = 0; sx < SS; sx += 1) {
const c = sample((x + (sx + 0.5) / SS) / size, (y + (sy + 0.5) / SS) / size);
if (c) {
r += c[0];
g += c[1];
b += c[2];
a += 255;
}
}
}
const n = SS * SS;
const covered = a / 255;
const i = (y * size + x) * 4;
// Premultiplied average of the covered samples only, so the edge fades
// in alpha rather than towards black.
out[i] = covered ? Math.round(r / covered) : 0;
out[i + 1] = covered ? Math.round(g / covered) : 0;
out[i + 2] = covered ? Math.round(b / covered) : 0;
out[i + 3] = Math.round(a / n);
}
}
return out;
}
const assets = join(dirname(fileURLToPath(import.meta.url)), '../assets');
for (const size of [32, 180]) {
const file = join(assets, `default-favicon-${size}.png`);
writeFileSync(file, encodePng(size, render(size)));
console.log(`wrote ${file}`);
}

View File

@ -11,17 +11,9 @@ import {
} from '../settings/instance-settings.service'; } from '../settings/instance-settings.service';
import { SiteAdminGuard } from './site-admin.guard'; import { SiteAdminGuard } from './site-admin.guard';
// Lifecycle markers and file-backed metadata, not configuration: never // Lifecycle markers, not configuration: never editable through this
// editable through this endpoint. The setup lock must be irreversible // endpoint (the setup lock must be irreversible, issue #80).
// (issue #80), and the branding entries only describe bytes on disk const INTERNAL_KEYS: ReadonlySet<InstanceSettingKey> = new Set(['setup.completedAt']);
// (issue #306) — writing one by hand would claim an asset that is not
// there. Both have their own write paths.
const INTERNAL_KEYS: ReadonlySet<InstanceSettingKey> = new Set([
'setup.completedAt',
'instance.logo',
'instance.logoDark',
'instance.favicon',
]);
// Partial update: any subset of the known settings, each validated by // Partial update: any subset of the known settings, each validated by
// its own schema inside the service (double validation is fine — this // its own schema inside the service (double validation is fine — this

View File

@ -2,10 +2,8 @@ import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
import { BackupModule } from '../backup/backup.module'; import { BackupModule } from '../backup/backup.module';
import { PondsModule } from '../ponds/ponds.module';
import { QuotasModule } from '../quotas/quotas.module'; import { QuotasModule } from '../quotas/quotas.module';
import { SchedulerModule } from '../scheduler/scheduler.module'; import { SchedulerModule } from '../scheduler/scheduler.module';
import { SearchModule } from '../search/search.module';
import { UsersModule } from '../users/users.module'; import { UsersModule } from '../users/users.module';
import { AdminSettingsController } from './admin.controller'; import { AdminSettingsController } from './admin.controller';
@ -20,15 +18,7 @@ import { UserAdminController } from './user-admin.controller';
import { UserAdminService } from './user-admin.service'; import { UserAdminService } from './user-admin.service';
@Module({ @Module({
imports: [ imports: [QuotasModule, UsersModule, AuthModule, SchedulerModule, BackupModule],
QuotasModule,
UsersModule,
AuthModule,
SchedulerModule,
BackupModule,
SearchModule,
PondsModule,
],
controllers: [ controllers: [
AdminSettingsController, AdminSettingsController,
BackupAdminController, BackupAdminController,

View File

@ -155,9 +155,6 @@ describe.skipIf(!hasTestDb)('backup admin (e2e, issue #103)', () => {
secretsFile = join(mkdtempSync(join(tmpdir(), 'dorfteich-backup-secrets-')), 'secrets.env'); secretsFile = join(mkdtempSync(join(tmpdir(), 'dorfteich-backup-secrets-')), 'secrets.env');
process.env.BACKUPS_DIR = backupsDir; process.env.BACKUPS_DIR = backupsDir;
process.env.SECRETS_FILE = secretsFile; process.env.SECRETS_FILE = secretsFile;
// The in-test WebDAV server must be allowlisted (issue #192) — the
// policy paths themselves are covered by backup-allowlist*.e2e.db.test.ts.
process.env.BACKUP_ALLOWED_TARGETS = '127.0.0.1';
davUrl = await dav.start(); davUrl = await dav.start();
prisma = createTestPrisma(); prisma = createTestPrisma();
await prisma.rateLimit.deleteMany({}); await prisma.rateLimit.deleteMany({});

View File

@ -65,7 +65,6 @@ export class BackupAdminService {
*/ */
async saveSettings(input: BackupSettingsInput, actor: User): Promise<BackupSettingsView> { async saveSettings(input: BackupSettingsInput, actor: User): Promise<BackupSettingsView> {
if (input.nextcloud.enabled) { if (input.nextcloud.enabled) {
this.assertTargetAllowed(input.nextcloud.baseUrl);
const test = await this.target.testConnection({ const test = await this.target.testConnection({
baseUrl: input.nextcloud.baseUrl, baseUrl: input.nextcloud.baseUrl,
username: input.nextcloud.username, username: input.nextcloud.username,
@ -101,29 +100,9 @@ export class BackupAdminService {
} }
testConnection(input: BackupConnectionTestInput): Promise<BackupConnectionTestResult> { testConnection(input: BackupConnectionTestInput): Promise<BackupConnectionTestResult> {
// Policy first (issue #192): the "test connection" button must not be
// usable as an egress probe towards non-allowlisted hosts.
this.assertTargetAllowed(input.baseUrl);
return this.target.testConnection(input); return this.target.testConnection(input);
} }
/**
* Deploy-level target policy (issue #192, ADR 0026): an empty
* `BACKUP_ALLOWED_TARGETS` disables remote targets outright; a host
* outside the list is rejected with an admin-visible error.
*/
private assertTargetAllowed(baseUrl: string): void {
if (!this.target.remoteAllowed()) {
throw new BadRequestException({ code: 'backup_remote_disabled_by_policy' });
}
if (!this.target.targetAllowed(baseUrl)) {
throw new BadRequestException({
code: 'backup_target_not_allowed',
details: { nextcloud: [`host is not in BACKUP_ALLOWED_TARGETS`] },
});
}
}
/** Both restore sources for the picker: newest first. */ /** Both restore sources for the picker: newest first. */
async sets(): Promise<BackupSetsView> { async sets(): Promise<BackupSetsView> {
const local = this.localSets(); const local = this.localSets();

View File

@ -1,101 +0,0 @@
import { INestApplication } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createTestApp, sessionCookieOf } from '../testing/test-app';
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service';
/**
* Backup target allowlist (issue #192, ADR 0026), empty-list half: with
* `BACKUP_ALLOWED_TARGETS` unset (the default) every remote target is
* unavailable by policy the view says so, and enabling one is rejected
* before any connection attempt. Lives in its own file because the env is
* read once at app boot.
*/
describe.skipIf(!hasTestDb)('backup targets disabled by empty allowlist (e2e, issue #192)', () => {
let app: INestApplication;
let prisma: PrismaClient;
const suffix = uniqueSuffix();
const password = 'backup allowlist pass 2';
let adminId: string;
let adminCookie: string;
const api = () => request(app.getHttpServer());
beforeAll(async () => {
delete process.env.BACKUP_ALLOWED_TARGETS;
prisma = createTestPrisma();
app = await createTestApp();
const users = app.get(UsersService);
const username = `bae-admin-${suffix}`;
const admin = await users.createUser({
username,
email: `${username}@example.org`,
displayName: 'Backup Admin Empty',
password,
locale: 'en',
});
adminId = admin.id;
await users.markEmailVerified(adminId);
await prisma.user.update({ where: { id: adminId }, data: { isSiteAdmin: true } });
adminCookie = sessionCookieOf(
await api()
.post('/api/v1/auth/login')
.send({ usernameOrEmail: username, password })
.expect(200),
);
});
afterAll(async () => {
await prisma.auditEntry.deleteMany({ where: { actorId: adminId } });
await prisma.session.deleteMany({ where: { userId: adminId } });
await prisma.userIdentity.deleteMany({ where: { userId: adminId } });
await prisma.user.deleteMany({ where: { id: adminId } });
await prisma.$disconnect();
await app.close();
});
it('reports remote targets as unavailable by policy', async () => {
const res = await api()
.get('/api/v1/admin/system/backup/settings')
.set('Cookie', adminCookie)
.expect(200);
expect(res.body.remoteTargets).toEqual({ allowed: false, allowlist: [] });
});
it('rejects enabling any remote destination', async () => {
const res = await api()
.put('/api/v1/admin/system/backup/settings')
.set('Cookie', adminCookie)
.send({
localRetentionDays: null,
remoteRetentionDays: 30,
nextcloud: {
enabled: true,
baseUrl: 'https://cloud.example.org/dav',
username: 'backupuser',
folder: 'dorfteich-backups',
uploadSchedule: 'daily',
password: 'app-pass',
},
})
.expect(400);
expect(res.body.code).toBe('backup_remote_disabled_by_policy');
});
it('rejects the connection test outright', async () => {
const res = await api()
.post('/api/v1/admin/system/backup/nextcloud/test')
.set('Cookie', adminCookie)
.send({
baseUrl: 'https://cloud.example.org/dav',
username: 'backupuser',
folder: 'dorfteich-backups',
password: 'app-pass',
})
.expect(400);
expect(res.body.code).toBe('backup_remote_disabled_by_policy');
});
});

View File

@ -1,134 +0,0 @@
import { INestApplication } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createTestApp, sessionCookieOf } from '../testing/test-app';
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service';
// Deploy-level env — must be set BEFORE the app (AppConfig) boots.
process.env.BACKUP_ALLOWED_TARGETS = 'cloud.example.org';
/**
* Backup target allowlist (issue #192, ADR 0026), populated-list half:
* hosts outside `BACKUP_ALLOWED_TARGETS` are rejected admin-visibly, hosts
* inside pass the policy. The empty-list half lives in its own file
* (`backup-allowlist-empty.e2e.db.test.ts`) because the env is read once
* at app boot.
*/
describe.skipIf(!hasTestDb)('backup target allowlist (e2e, issue #192)', () => {
let app: INestApplication;
let prisma: PrismaClient;
const suffix = uniqueSuffix();
const password = 'backup allowlist pass 1';
let adminId: string;
let adminCookie: string;
const api = () => request(app.getHttpServer());
const settingsInput = (baseUrl: string, enabled = true) => ({
localRetentionDays: null,
remoteRetentionDays: 30,
nextcloud: {
enabled,
baseUrl,
username: 'backupuser',
folder: 'dorfteich-backups',
uploadSchedule: 'daily',
password: 'app-pass',
},
});
beforeAll(async () => {
prisma = createTestPrisma();
app = await createTestApp();
const users = app.get(UsersService);
const username = `bal-admin-${suffix}`;
const admin = await users.createUser({
username,
email: `${username}@example.org`,
displayName: 'Backup Admin',
password,
locale: 'en',
});
adminId = admin.id;
await users.markEmailVerified(adminId);
await prisma.user.update({ where: { id: adminId }, data: { isSiteAdmin: true } });
adminCookie = sessionCookieOf(
await api()
.post('/api/v1/auth/login')
.send({ usernameOrEmail: username, password })
.expect(200),
);
});
afterAll(async () => {
await prisma.instanceSetting.deleteMany({ where: { key: { startsWith: 'backup.' } } });
await prisma.auditEntry.deleteMany({ where: { actorId: adminId } });
await prisma.session.deleteMany({ where: { userId: adminId } });
await prisma.userIdentity.deleteMany({ where: { userId: adminId } });
await prisma.user.deleteMany({ where: { id: adminId } });
await prisma.$disconnect();
await app.close();
});
it('exposes the policy in the settings view', async () => {
const res = await api()
.get('/api/v1/admin/system/backup/settings')
.set('Cookie', adminCookie)
.expect(200);
expect(res.body.remoteTargets).toEqual({
allowed: true,
allowlist: ['cloud.example.org'],
});
});
it('rejects enabling a destination outside the allowlist', async () => {
const res = await api()
.put('/api/v1/admin/system/backup/settings')
.set('Cookie', adminCookie)
.send(settingsInput('https://evil.example.net/dav'))
.expect(400);
expect(res.body.code).toBe('backup_target_not_allowed');
});
it('rejects the connection test towards a non-allowlisted host', async () => {
const res = await api()
.post('/api/v1/admin/system/backup/nextcloud/test')
.set('Cookie', adminCookie)
.send({
baseUrl: 'https://evil.example.net/dav',
username: 'backupuser',
folder: 'dorfteich-backups',
password: 'app-pass',
})
.expect(400);
expect(res.body.code).toBe('backup_target_not_allowed');
});
it('lets an allowlisted destination through the policy', async () => {
// The host passes the policy; what fails afterwards is the live
// connection test against the (unreachable) example host — proving the
// rejection above was the policy, not the connectivity.
const res = await api()
.put('/api/v1/admin/system/backup/settings')
.set('Cookie', adminCookie)
.send(settingsInput('https://cloud.example.org/dav'))
.expect(400);
expect(res.body.code).toBe('backup_connection_failed');
// Saving the same destination disabled skips the connection test and
// persists — an existing in-allowlist configuration stays untouched.
await api()
.put('/api/v1/admin/system/backup/settings')
.set('Cookie', adminCookie)
.send(settingsInput('https://cloud.example.org/dav', false))
.expect(200);
const view = await api()
.get('/api/v1/admin/system/backup/settings')
.set('Cookie', adminCookie)
.expect(200);
expect(view.body.nextcloud.baseUrl).toBe('https://cloud.example.org/dav');
});
});

View File

@ -4,7 +4,6 @@ import { PinoLogger } from 'nestjs-pino';
import { AuditService } from '../audit/audit.service'; import { AuditService } from '../audit/audit.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { SearchProvider } from '../search/search.provider';
/** /**
* GDPR account deletion (issue #59, security.md §Privacy). Rather than * GDPR account deletion (issue #59, security.md §Privacy). Rather than
@ -18,7 +17,6 @@ export class PseudonymizationService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly audit: AuditService, private readonly audit: AuditService,
private readonly search: SearchProvider,
private readonly logger: PinoLogger, private readonly logger: PinoLogger,
) { ) {
this.logger.setContext(PseudonymizationService.name); this.logger.setContext(PseudonymizationService.name);
@ -47,14 +45,6 @@ export class PseudonymizationService {
data: { deletedAt: new Date(), deletedBy: userId }, data: { deletedAt: new Date(), deletedBy: userId },
}); });
}); });
// Trash path includes leaving the search index (issue #195).
const personalPonds = await this.prisma.pond.findMany({
where: { ownerId: userId, type: 'PERSONAL' },
select: { id: true },
});
for (const pond of personalPonds) {
await this.search.removePond(pond.id);
}
await this.audit.record({ await this.audit.record({
action: 'user.pseudonymized', action: 'user.pseudonymized',
targetType: 'user', targetType: 'user',

View File

@ -1,21 +1,16 @@
import { Controller, Get, Param, Post, Query, Req, UseGuards } from '@nestjs/common'; import { Controller, Get, Param, Post, Query, Req, UseGuards } from '@nestjs/common';
import { import {
auditListQuerySchema, auditListQuerySchema,
readEventListQuerySchema,
type AuditListQuery, type AuditListQuery,
type AuditListView, type AuditListView,
type JobTriggerResult, type JobTriggerResult,
type ReadEventListQuery,
type ReadEventListView,
type StorageOverviewView, type StorageOverviewView,
type SystemBackupView, type SystemBackupView,
type SystemJobView, type SystemJobView,
type VsNfdProfileView,
} from '@dorfteich/shared'; } from '@dorfteich/shared';
import { AuthedRequest } from '../auth/auth.guard'; import { AuthedRequest } from '../auth/auth.guard';
import { ZodValidationPipe } from '../common/zod-validation.pipe'; import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { VsNfdProfileService } from '../settings/vs-nfd-profile.service';
import { SiteAdminGuard } from './site-admin.guard'; import { SiteAdminGuard } from './site-admin.guard';
import { SystemAdminService } from './system-admin.service'; import { SystemAdminService } from './system-admin.service';
@ -23,17 +18,7 @@ import { SystemAdminService } from './system-admin.service';
@Controller('admin/system') @Controller('admin/system')
@UseGuards(SiteAdminGuard) @UseGuards(SiteAdminGuard)
export class SystemAdminController { export class SystemAdminController {
constructor( constructor(private readonly system: SystemAdminService) {}
private readonly system: SystemAdminService,
private readonly vsNfdProfile: VsNfdProfileService,
) {}
/** Active VS-NfD mode + catalog verdict for the running configuration
* (issue #243, ADR 0027). Exposure only the treatments are #244#246. */
@Get('vs-nfd-profile')
vsNfd(): Promise<VsNfdProfileView> {
return this.vsNfdProfile.evaluate();
}
@Get('jobs') @Get('jobs')
async jobs(): Promise<SystemJobView[]> { async jobs(): Promise<SystemJobView[]> {
@ -60,15 +45,6 @@ export class SystemAdminController {
return this.system.auditLog(query); return this.system.auditLog(query);
} }
/** Read-access trail queries (issue #224): "who read page X", "what did
* user Y read" Site-Admin only, like the audit viewer above. */
@Get('read-events')
async readEvents(
@Query(new ZodValidationPipe(readEventListQuerySchema)) query: ReadEventListQuery,
): Promise<ReadEventListView> {
return this.system.readEvents(query);
}
@Get('storage') @Get('storage')
async storage(): Promise<StorageOverviewView> { async storage(): Promise<StorageOverviewView> {
return this.system.storage(); return this.system.storage();

View File

@ -7,13 +7,10 @@ import {
AUDIT_PAGE_SIZE, AUDIT_PAGE_SIZE,
BACKUP_FRESH_MAX_AGE_HOURS, BACKUP_FRESH_MAX_AGE_HOURS,
BACKUP_STATUS_FILE, BACKUP_STATUS_FILE,
READ_EVENT_PAGE_SIZE,
type AuditListQuery, type AuditListQuery,
type AuditListView, type AuditListView,
type BackupStatus, type BackupStatus,
type JobTriggerResult, type JobTriggerResult,
type ReadEventListQuery,
type ReadEventListView,
type StorageOverviewView, type StorageOverviewView,
type SystemBackupView, type SystemBackupView,
type SystemJobView, type SystemJobView,
@ -163,66 +160,6 @@ export class SystemAdminService {
}; };
} }
/**
* The Site-Admin query path over the read-access trail (issue #224,
* ADR 0023) evidence nobody can read is not evidence. Answers "who read
* page X" and "what did user Y read" within a period. API-only by design
* (no panel yet): the trail is an examiner's tool, not a daily screen
* documented in data-model.md §read_events.
*/
async readEvents(query: ReadEventListQuery): Promise<ReadEventListView> {
const where: Prisma.ReadEventWhereInput = {};
if (query.pageId) where.pageId = query.pageId;
if (query.actor) {
const actor = await this.prisma.user.findUnique({ where: { username: query.actor } });
// An unknown username matches nothing rather than everything.
where.actorId = actor?.id ?? '00000000-0000-0000-0000-000000000000';
}
if (query.channel) where.channel = query.channel;
if (query.from || query.to) {
where.occurredAt = {
...(query.from ? { gte: query.from } : {}),
...(query.to ? { lte: query.to } : {}),
};
}
const total = await this.prisma.readEvent.count({ where });
const pageCount = Math.max(1, Math.ceil(total / READ_EVENT_PAGE_SIZE));
const page = Math.min(query.page, pageCount);
const events = await this.prisma.readEvent.findMany({
where,
orderBy: { occurredAt: 'desc' },
skip: (page - 1) * READ_EVENT_PAGE_SIZE,
take: READ_EVENT_PAGE_SIZE,
});
// No FK on actor_id (evidence outlives accounts) — resolve what still
// exists in one query, show the bare id otherwise.
const actorIds = [...new Set(events.map((e) => e.actorId).filter((id): id is string => !!id))];
const actors = actorIds.length
? await this.prisma.user.findMany({
where: { id: { in: actorIds } },
select: { id: true, username: true, displayName: true },
})
: [];
const actorById = new Map(actors.map((a) => [a.id, a]));
return {
entries: events.map((event) => ({
id: event.id,
occurredAt: event.occurredAt.toISOString(),
actor: event.actorId ? (actorById.get(event.actorId) ?? null) : null,
pageId: event.pageId,
pondId: event.pondId,
channel: event.channel,
classification: event.classification,
windowSeconds: event.windowSeconds,
details: (event.details as Record<string, unknown> | null) ?? null,
})),
page,
pageCount,
total,
};
}
async storage(): Promise<StorageOverviewView> { async storage(): Promise<StorageOverviewView> {
const usages = await this.prisma.pondUsage.findMany({ const usages = await this.prisma.pondUsage.findMany({
where: { pond: { deletedAt: null } }, where: { pond: { deletedAt: null } },

View File

@ -12,11 +12,9 @@ import {
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { import {
AdminCreateUserInput,
AdminUserListQuery, AdminUserListQuery,
AdminUserListView, AdminUserListView,
AdminUserView, AdminUserView,
adminCreateUserSchema,
adminUserListQuerySchema, adminUserListQuerySchema,
setSiteAdminSchema, setSiteAdminSchema,
setUserDisabledSchema, setUserDisabledSchema,
@ -33,14 +31,6 @@ import { UserAdminService } from './user-admin.service';
export class UserAdminController { export class UserAdminController {
constructor(private readonly users: UserAdminService) {} constructor(private readonly users: UserAdminService) {}
@Post()
async create(
@Body(new ZodValidationPipe(adminCreateUserSchema)) input: AdminCreateUserInput,
@Req() request: AuthedRequest,
): Promise<AdminUserView> {
return this.users.createUser(request.user!, input);
}
@Get() @Get()
async list( async list(
@Query(new ZodValidationPipe(adminUserListQuerySchema)) query: AdminUserListQuery, @Query(new ZodValidationPipe(adminUserListQuerySchema)) query: AdminUserListQuery,

View File

@ -6,7 +6,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { PondsService } from '../ponds/ponds.service'; import { PondsService } from '../ponds/ponds.service';
import { createTestApp, sessionCookieOf } from '../testing/test-app'; import { createTestApp, sessionCookieOf } from '../testing/test-app';
import { createTestPrisma, deletePondsWhere, hasTestDb, uniqueSuffix } from '../testing/test-db'; import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service'; import { UsersService } from '../users/users.service';
/** /**
@ -62,71 +62,13 @@ describe.skipIf(!hasTestDb)('user admin (e2e, issue #59)', () => {
afterAll(async () => { afterAll(async () => {
const all = Object.values(ids); const all = Object.values(ids);
await prisma.session.deleteMany({ where: { userId: { in: all } } }); await prisma.session.deleteMany({ where: { userId: { in: all } } });
await deletePondsWhere(prisma, { ownerId: { in: all } }); await prisma.pond.deleteMany({ where: { ownerId: { in: all } } });
await prisma.userIdentity.deleteMany({ where: { userId: { in: all } } }); await prisma.userIdentity.deleteMany({ where: { userId: { in: all } } });
await prisma.user.deleteMany({ where: { id: { in: all } } }); await prisma.user.deleteMany({ where: { id: { in: all } } });
await prisma.$disconnect(); await prisma.$disconnect();
await app.close(); await app.close();
}); });
it('creates an account that can log in right away, with a personal pond (issue #331)', async () => {
const username = `ua-created-${suffix}`;
const res = await api()
.post('/api/v1/admin/users')
.set('Cookie', cookies.admin1!)
.send({
username,
email: `${username}@example.org`,
displayName: 'UA Created',
password,
locale: 'de',
})
.expect(201);
const created = res.body as { id: string; status: string };
ids.created = created.id;
// No verification hop: the admin vouched for the address.
expect(created.status).toBe('ACTIVE');
await api()
.post('/api/v1/auth/login')
.send({ usernameOrEmail: username, password })
.expect(200);
// The personal pond exists exactly like after self-registration.
expect(await prisma.pond.count({ where: { ownerId: created.id, type: 'PERSONAL' } })).toBe(1);
});
it('rejects duplicate usernames with a field-level conflict', async () => {
await api()
.post('/api/v1/admin/users')
.set('Cookie', cookies.admin1!)
.send({
username: `ua-created-${suffix}`,
email: `ua-created-other-${suffix}@example.org`,
displayName: 'UA Dup',
password,
locale: 'en',
})
.expect(409)
.expect((r) =>
expect((r.body as { details: Record<string, string[]> }).details.username).toEqual([
'validation.taken',
]),
);
});
it('refuses creation for non-admins', async () => {
await api()
.post('/api/v1/admin/users')
.set('Cookie', cookies.bob!)
.send({
username: `ua-sneak-${suffix}`,
email: `ua-sneak-${suffix}@example.org`,
displayName: 'UA Sneak',
password,
locale: 'en',
})
.expect(403);
});
it('lists and searches users (Site-Admin only)', async () => { it('lists and searches users (Site-Admin only)', async () => {
const res = await api() const res = await api()
.get(`/api/v1/admin/users?q=ua-bob-${suffix}`) .get(`/api/v1/admin/users?q=ua-bob-${suffix}`)

View File

@ -1,6 +1,5 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { import {
AdminCreateUserInput,
AdminUserListQuery, AdminUserListQuery,
AdminUserListView, AdminUserListView,
AdminUserStatus, AdminUserStatus,
@ -11,9 +10,7 @@ import { PinoLogger } from 'nestjs-pino';
import { AuthService } from '../auth/auth.service'; import { AuthService } from '../auth/auth.service';
import { AuditService } from '../audit/audit.service'; import { AuditService } from '../audit/audit.service';
import { PondsService } from '../ponds/ponds.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { UsersService } from '../users/users.service';
import { PseudonymizationService } from './pseudonymization.service'; import { PseudonymizationService } from './pseudonymization.service';
/** /**
@ -30,33 +27,12 @@ export class UserAdminService {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly pseudonymizer: PseudonymizationService, private readonly pseudonymizer: PseudonymizationService,
private readonly auth: AuthService, private readonly auth: AuthService,
private readonly users: UsersService,
private readonly ponds: PondsService,
private readonly audit: AuditService, private readonly audit: AuditService,
private readonly logger: PinoLogger, private readonly logger: PinoLogger,
) { ) {
this.logger.setContext(UserAdminService.name); this.logger.setContext(UserAdminService.name);
} }
/**
* Creates an account on behalf of a user (issue #331). The e-mail is
* marked verified immediately the admin vouches for the address and
* the personal pond is provisioned exactly like the verify-email path
* does, so the account is indistinguishable from a self-registered one.
*/
async createUser(actor: User, input: AdminCreateUserInput): Promise<AdminUserView> {
const user = await this.users.createUser(input);
const verified = await this.users.markEmailVerified(user.id);
await this.ponds.ensurePersonalPond(verified);
await this.audit.record({
action: 'user.created_by_admin',
actorId: actor.id,
targetType: 'user',
targetId: user.id,
});
return this.viewOf(verified, await this.pondCountOf(user.id));
}
async list(query: AdminUserListQuery): Promise<AdminUserListView> { async list(query: AdminUserListQuery): Promise<AdminUserListView> {
const q = query.q?.trim(); const q = query.q?.trim();
const where: Prisma.UserWhereInput = q const where: Prisma.UserWhereInput = q
@ -139,9 +115,7 @@ export class UserAdminService {
if (!value && user.isSiteAdmin) await this.assertNotLastSiteAdmin(); if (!value && user.isSiteAdmin) await this.assertNotLastSiteAdmin();
const updated = await this.prisma.user.update({ const updated = await this.prisma.user.update({
where: { id }, where: { id },
// A manual toggle takes ownership of the flag: the IdP mapping data: { isSiteAdmin: value },
// (#217) may only revoke what it itself set.
data: { isSiteAdmin: value, isSiteAdminManaged: false },
}); });
await this.audit.record({ await this.audit.record({
action: 'user.site_admin_set', action: 'user.site_admin_set',

View File

@ -1,4 +1,4 @@
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { APP_FILTER } from '@nestjs/core'; import { APP_FILTER } from '@nestjs/core';
import { LoggerModule } from 'nestjs-pino'; import { LoggerModule } from 'nestjs-pino';
@ -6,10 +6,7 @@ import { AdminModule } from './admin/admin.module';
import { AuditModule } from './audit/audit.module'; import { AuditModule } from './audit/audit.module';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { BackupModule } from './backup/backup.module'; import { BackupModule } from './backup/backup.module';
import { BrandingModule } from './branding/branding.module';
import { ApiExceptionFilter } from './common/api-exception.filter'; import { ApiExceptionFilter } from './common/api-exception.filter';
import { maskTokenParam } from './common/mask-token-param';
import { SecurityHeadersMiddleware } from './common/security-headers.middleware';
import { CommentsModule } from './comments/comments.module'; import { CommentsModule } from './comments/comments.module';
import { CompactionModule } from './compaction/compaction.module'; import { CompactionModule } from './compaction/compaction.module';
import { AppConfig } from './config/app-config.service'; import { AppConfig } from './config/app-config.service';
@ -17,8 +14,6 @@ import { ConfigModule } from './config/config.module';
import { FilesModule } from './files/files.module'; import { FilesModule } from './files/files.module';
import { GrantsModule } from './grants/grants.module'; import { GrantsModule } from './grants/grants.module';
import { HealthModule } from './health/health.module'; import { HealthModule } from './health/health.module';
import { HomeModule } from './home/home.module';
import { FontsModule } from './fonts/fonts.module';
import { ImportExportModule } from './import-export/import-export.module'; import { ImportExportModule } from './import-export/import-export.module';
import { LabelsModule } from './labels/labels.module'; import { LabelsModule } from './labels/labels.module';
import { LegalModule } from './legal/legal.module'; import { LegalModule } from './legal/legal.module';
@ -34,7 +29,6 @@ import { PrismaModule } from './prisma/prisma.module';
import { PublicApiModule } from './public-api/public-api.module'; import { PublicApiModule } from './public-api/public-api.module';
import { PublicModule } from './public/public.module'; import { PublicModule } from './public/public.module';
import { RateLimitModule } from './rate-limit/rate-limit.module'; import { RateLimitModule } from './rate-limit/rate-limit.module';
import { ReadTrailModule } from './read-trail/read-trail.module';
import { SearchModule } from './search/search.module'; import { SearchModule } from './search/search.module';
import { SettingsModule } from './settings/settings.module'; import { SettingsModule } from './settings/settings.module';
import { SetupModule } from './setup/setup.module'; import { SetupModule } from './setup/setup.module';
@ -42,7 +36,6 @@ import { TrashModule } from './trash/trash.module';
import { UsersModule } from './users/users.module'; import { UsersModule } from './users/users.module';
import { NotificationsModule } from './notifications/notifications.module'; import { NotificationsModule } from './notifications/notifications.module';
import { WatchesModule } from './watches/watches.module'; import { WatchesModule } from './watches/watches.module';
import { FavoritesModule } from './favorites/favorites.module';
import { VersionsModule } from './versions/versions.module'; import { VersionsModule } from './versions/versions.module';
@Module({ @Module({
@ -50,7 +43,6 @@ import { VersionsModule } from './versions/versions.module';
ConfigModule, ConfigModule,
PrismaModule, PrismaModule,
AuditModule, AuditModule,
ReadTrailModule,
RateLimitModule, RateLimitModule,
MailModule, MailModule,
SettingsModule, SettingsModule,
@ -67,7 +59,6 @@ import { VersionsModule } from './versions/versions.module';
PagesModule, PagesModule,
CommentsModule, CommentsModule,
WatchesModule, WatchesModule,
FavoritesModule,
NotificationsModule, NotificationsModule,
FilesModule, FilesModule,
TrashModule, TrashModule,
@ -75,7 +66,6 @@ import { VersionsModule } from './versions/versions.module';
VersionsModule, VersionsModule,
LabelsModule, LabelsModule,
LegalModule, LegalModule,
HomeModule,
LinksModule, LinksModule,
SearchModule, SearchModule,
GrantsModule, GrantsModule,
@ -83,8 +73,6 @@ import { VersionsModule } from './versions/versions.module';
PublicModule, PublicModule,
PublicApiModule, PublicApiModule,
McpModule, McpModule,
BrandingModule,
FontsModule,
ImportExportModule, ImportExportModule,
PluginsModule, PluginsModule,
AuthModule, AuthModule,
@ -99,11 +87,6 @@ import { VersionsModule } from './versions/versions.module';
autoLogging: config.env.NODE_ENV !== 'test', autoLogging: config.env.NODE_ENV !== 'test',
// Request bodies are never logged (operations.md logging rules). // Request bodies are never logged (operations.md logging rules).
redact: { paths: ['req.headers.authorization', 'req.headers.cookie'], remove: true }, redact: { paths: ['req.headers.authorization', 'req.headers.cookie'], remove: true },
// Feed tokens travel as `?token=` (issue #191) — mask them so the
// request log never stores the credential.
serializers: {
req: (req: { url?: string }) => ({ ...req, url: maskTokenParam(req.url) }),
},
}, },
}), }),
}), }),
@ -111,10 +94,4 @@ import { VersionsModule } from './versions/versions.module';
], ],
providers: [{ provide: APP_FILTER, useClass: ApiExceptionFilter }], providers: [{ provide: APP_FILTER, useClass: ApiExceptionFilter }],
}) })
export class AppModule implements NestModule { export class AppModule {}
configure(consumer: MiddlewareConsumer): void {
// Module-level (not main.ts) so createTestApp boots the identical
// security-header/CORS middleware — see security-headers.middleware.ts.
consumer.apply(SecurityHeadersMiddleware).forRoutes('{*path}');
}
}

View File

@ -1,75 +0,0 @@
/**
* The audit event catalogue (issue #201): every action id the trail may
* carry, with the severity the stdout line is stamped with. This const is
* the CODE half of the published catalogue in
* `docs/architecture/audit-events.md` `audit-catalogue.test.ts` fails
* whenever the two drift, so an id cannot be added, renamed, or removed
* without its documentation moving in the same commit.
*
* Compatibility promise (the reason this exists): ids are never repurposed.
* New events may be added (minor catalogue version); an id that stops being
* emitted is retired in the catalogue document, its meaning frozen forever
* so an operator's SIEM rules survive our releases.
*/
export const AUDIT_EVENTS = {
'api.token_created': { severity: 'info' },
'api.token_revoked': { severity: 'info' },
'api.write': { severity: 'info' },
'audit.pruned': { severity: 'info' },
'auth.email_verified': { severity: 'info' },
'auth.identity_linked': { severity: 'notice' },
'auth.login_failed': { severity: 'warning' },
'auth.login_succeeded': { severity: 'info' },
'auth.password_reset': { severity: 'notice' },
'auth.proxy_rejected': { severity: 'warning' },
'auth.signup': { severity: 'info' },
'backup.restore_requested': { severity: 'warning' },
'backup.run_triggered': { severity: 'info' },
'backup.settings_changed': { severity: 'notice' },
'file.integrity_failed': { severity: 'critical' },
'grant.created': { severity: 'notice' },
'grant.deleted': { severity: 'notice' },
'invitation.accepted': { severity: 'notice' },
'invitation.created': { severity: 'info' },
'invitation.revoked': { severity: 'info' },
'job.triggered': { severity: 'info' },
'member.added': { severity: 'notice' },
'member.removed': { severity: 'notice' },
'member.role_changed': { severity: 'notice' },
'page.classification_lowered': { severity: 'warning' },
'page.classification_raised': { severity: 'notice' },
'plugin.installed': { severity: 'notice' },
'plugin.rejected': { severity: 'warning' },
'plugin.mode_set': { severity: 'notice' },
'plugin.pond_toggled': { severity: 'info' },
'plugin.uninstalled': { severity: 'notice' },
'pond.archived': { severity: 'notice' },
'pond.purged': { severity: 'notice' },
'quota.override_cleared': { severity: 'notice' },
'quota.override_set': { severity: 'notice' },
'read_trail.pruned': { severity: 'info' },
'settings.changed': { severity: 'notice' },
'branding.changed': { severity: 'notice' },
'font.uploaded': { severity: 'notice' },
'font.deleted': { severity: 'notice' },
'setup.admin_created': { severity: 'notice' },
'setup.completed': { severity: 'info' },
'setup.preseeded': { severity: 'info' },
'setup.smtp_stored': { severity: 'info' },
'user.created_by_admin': { severity: 'notice' },
'user.deleted': { severity: 'notice' },
'user.disabled_set': { severity: 'notice' },
'user.pseudonymized': { severity: 'notice' },
'user.site_admin_set': { severity: 'notice' },
'user.verification_resent': { severity: 'info' },
} as const satisfies Record<string, { severity: AuditSeverity }>;
/** Severity vocabulary of the catalogue syslog-inspired, four levels are
* enough for rule routing (critical pages someone, warning feeds detection,
* notice is configuration drift, info is lifecycle noise). */
export type AuditSeverity = 'info' | 'notice' | 'warning' | 'critical';
/** A catalogued action id the ONLY thing {@link AuditService.record}
* accepts, so an uncatalogued event cannot be emitted (compile-time), and
* the doc fence keeps the catalogue document in step (test-time). */
export type AuditAction = keyof typeof AUDIT_EVENTS;

View File

@ -1,48 +0,0 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import { AUDIT_EVENTS } from './audit-actions';
/**
* The fence that keeps the published audit catalogue and the code together
* (issue #201): every id in `AUDIT_EVENTS` must appear as an event row in
* `docs/architecture/audit-events.md` with the same severity, and the
* document may not describe ids the code does not know. Emission of an
* uncatalogued id is already a TYPE error (AuditAction union) this test
* covers the half the compiler cannot see: the document.
*/
// __dirname, not import.meta: the api package compiles CJS (tsconfig has no
// nodenext module), and vitest resolves both — the compiler only the former.
const doc = readFileSync(join(__dirname, '../../../../docs/architecture/audit-events.md'), 'utf8');
/** Event rows are `| \`ns.event\` | trigger | severity | ` the dot in the
* id keeps field-set rows (`msg`, `severity`, ) out of the match. The
* namespace may carry an underscore since `read_trail.*` (issue #224). */
function documentedEvents(): Map<string, string> {
const events = new Map<string, string>();
for (const line of doc.split('\n')) {
const id = /^\| `([a-z_]+\.[a-z_]+)` +\|/.exec(line)?.[1];
if (!id) continue;
const cells = line.split('|').map((cell) => cell.trim());
// cells[0] is the empty string before the leading pipe.
events.set(id, cells[3] ?? '');
}
return events;
}
describe('audit catalogue fence (issue #201)', () => {
it('documents exactly the ids the code can emit', () => {
const documented = documentedEvents();
const inCode = Object.keys(AUDIT_EVENTS).sort();
expect([...documented.keys()].sort()).toEqual(inCode);
});
it('documents each id with the severity the code stamps', () => {
const documented = documentedEvents();
for (const [action, { severity }] of Object.entries(AUDIT_EVENTS)) {
expect(`${action}: ${documented.get(action)}`).toBe(`${action}: ${severity}`);
}
});
});

View File

@ -1,90 +0,0 @@
import { INestApplication } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createTestApp } from '../testing/test-app';
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { AuditRetentionService } from './audit-retention.service';
const DAY = 24 * 60 * 60 * 1000;
/**
* Audit-trail retention (issue #196): entries past `audit.retentionDays`
* are pruned, newer ones stay, and the pruning itself lands in the trail
* (`audit.pruned` with count and cutoff) so the gap is explainable.
*/
describe.skipIf(!hasTestDb)('audit retention (e2e, issue #196)', () => {
let app: INestApplication;
let prisma: PrismaClient;
const suffix = uniqueSuffix();
const marker = `retention-${suffix}`;
beforeAll(async () => {
prisma = createTestPrisma();
// A short period so ages are unambiguous; written straight to the row
// BEFORE the app boots (the settings cache is in-process and fills on
// first read). The key is cleaned afterAll.
await prisma.instanceSetting.upsert({
where: { key: 'audit.retentionDays' },
create: { key: 'audit.retentionDays', value: 30 },
update: { value: 30 },
});
app = await createTestApp();
});
afterAll(async () => {
await prisma.instanceSetting.deleteMany({ where: { key: 'audit.retentionDays' } });
await prisma.auditEntry.deleteMany({
where: { OR: [{ targetId: { contains: suffix } }, { action: 'audit.pruned' }] },
});
await prisma.$disconnect();
await app.close();
});
it('prunes entries past the period, keeps newer ones, and records the pruning', async () => {
await prisma.auditEntry.createMany({
data: [
{
action: 'test.old',
targetType: 'test',
targetId: marker,
at: new Date(Date.now() - 40 * DAY),
},
{
action: 'test.older',
targetType: 'test',
targetId: marker,
at: new Date(Date.now() - 400 * DAY),
},
{
action: 'test.fresh',
targetType: 'test',
targetId: marker,
at: new Date(Date.now() - 5 * DAY),
},
],
});
const pruned = await app.get(AuditRetentionService).pruneExpired();
expect(pruned).toBeGreaterThanOrEqual(2);
const remaining = await prisma.auditEntry.findMany({ where: { targetId: marker } });
expect(remaining.map((entry) => entry.action)).toEqual(['test.fresh']);
// The gap is explainable: the pruning run is itself on the trail.
const prunedEvent = await prisma.auditEntry.findFirst({
where: { action: 'audit.pruned' },
orderBy: { at: 'desc' },
});
expect(prunedEvent).not.toBeNull();
expect(prunedEvent!.details).toMatchObject({ retentionDays: 30 });
expect((prunedEvent!.details as { count: number }).count).toBeGreaterThanOrEqual(2);
});
it('is a no-op when nothing is due', async () => {
const pruned = await app.get(AuditRetentionService).pruneExpired();
expect(pruned).toBe(0);
// The fresh marker entry from the first test is untouched.
expect(await prisma.auditEntry.count({ where: { targetId: marker } })).toBe(1);
});
});

View File

@ -1,47 +0,0 @@
import { Injectable } from '@nestjs/common';
import { PinoLogger } from 'nestjs-pino';
import { ClockService } from '../common/clock.service';
import { PrismaService } from '../prisma/prisma.service';
import { InstanceSettingsService } from '../settings/instance-settings.service';
import { AuditService } from './audit.service';
const MS_PER_DAY = 24 * 60 * 60 * 1000;
/**
* Audit-trail retention (issue #196): the daily job deletes `audit_log`
* entries older than the configurable `audit.retentionDays` (default one
* year) and records the deletion itself (`audit.pruned` with count and
* cutoff) so a gap in the trail is always explainable. Separate from
* {@link AuditService} because the settings service audits its own writes
* folding retention into AuditService would close a constructor cycle.
* The read-access trail (#224) is deliberately not covered here.
*/
@Injectable()
export class AuditRetentionService {
constructor(
private readonly prisma: PrismaService,
private readonly settings: InstanceSettingsService,
private readonly audit: AuditService,
private readonly clock: ClockService,
private readonly logger: PinoLogger,
) {
this.logger.setContext(AuditRetentionService.name);
}
async pruneExpired(): Promise<number> {
const retentionDays = await this.settings.get('audit.retentionDays');
const cutoff = new Date(this.clock.now().getTime() - retentionDays * MS_PER_DAY);
const { count } = await this.prisma.auditEntry.deleteMany({
where: { at: { lt: cutoff } },
});
if (count > 0) {
await this.audit.record({
action: 'audit.pruned',
details: { count, cutoff: cutoff.toISOString(), retentionDays },
});
}
return count;
}
}

View File

@ -1,16 +1,7 @@
import { Global, Module, OnModuleInit } from '@nestjs/common'; import { Global, Module } from '@nestjs/common';
import { CommonModule } from '../common/common.module';
import { SchedulerModule } from '../scheduler/scheduler.module';
import { SchedulerService } from '../scheduler/scheduler.service';
import { SettingsModule } from '../settings/settings.module';
import { AuditRetentionService } from './audit-retention.service';
import { AuditService } from './audit.service'; import { AuditService } from './audit.service';
/** Daily, per operations.md's maintenance-jobs table (issue #196). */
const AUDIT_RETENTION_CADENCE_SECONDS = 24 * 60 * 60;
/** /**
* Global because the audit trail cuts across nearly every feature module * Global because the audit trail cuts across nearly every feature module
* (auth, grants, members, admin, plugins, setup) like PrismaModule, one * (auth, grants, members, admin, plugins, setup) like PrismaModule, one
@ -18,23 +9,7 @@ const AUDIT_RETENTION_CADENCE_SECONDS = 24 * 60 * 60;
*/ */
@Global() @Global()
@Module({ @Module({
imports: [CommonModule, SchedulerModule, SettingsModule], providers: [AuditService],
providers: [AuditService, AuditRetentionService],
exports: [AuditService], exports: [AuditService],
}) })
export class AuditModule implements OnModuleInit { export class AuditModule {}
constructor(
private readonly scheduler: SchedulerService,
private readonly retention: AuditRetentionService,
) {}
onModuleInit(): void {
this.scheduler.register({
name: 'audit-retention',
cadenceSeconds: AUDIT_RETENTION_CADENCE_SECONDS,
run: async () => {
await this.retention.pruneExpired();
},
});
}
}

View File

@ -4,13 +4,9 @@ import { PinoLogger } from 'nestjs-pino';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { AUDIT_EVENTS, AuditAction } from './audit-actions';
export interface AuditEvent { export interface AuditEvent {
/** Stable dot-namespaced id from the catalogue (issue #201, /** Stable dot-namespaced id, e.g. `grant.created` — the UI translates it. */
* docs/architecture/audit-events.md) the UI translates it, SIEM rules action: string;
* key on it. The union makes an uncatalogued emission a type error. */
action: AuditAction;
/** The acting user; null/undefined for anonymous events. */ /** The acting user; null/undefined for anonymous events. */
actorId?: string | null; actorId?: string | null;
targetType?: string; targetType?: string;
@ -41,15 +37,7 @@ export class AuditService {
async record(event: AuditEvent): Promise<void> { async record(event: AuditEvent): Promise<void> {
const { action, actorId, targetType, targetId, details } = event; const { action, actorId, targetType, targetId, details } = event;
this.logger.info( this.logger.info(
// `severity` is the catalogue's routing hint for SIEM rules (#201) — { actor: actorId ?? null, targetType, targetId, ...details },
// pino's own `level` stays 30/info so log transport is unaffected.
{
severity: AUDIT_EVENTS[action].severity,
actor: actorId ?? null,
targetType,
targetId,
...details,
},
`audit: ${action}`, `audit: ${action}`,
); );
try { try {

View File

@ -1,6 +1,5 @@
import { Body, Controller, Get, HttpCode, Post, Req, Res } from '@nestjs/common'; import { Body, Controller, Get, HttpCode, Post, Req, Res } from '@nestjs/common';
import { import {
AuthMethodsView,
CurrentUser as CurrentUserShape, CurrentUser as CurrentUserShape,
LoginInput, LoginInput,
SignupInput, SignupInput,
@ -21,15 +20,13 @@ import { InstanceSettingsService } from '../settings/instance-settings.service';
import { SetupExempt } from '../setup/setup.guard'; import { SetupExempt } from '../setup/setup.guard';
import { import {
AuthedRequest, AuthedRequest,
LocalCredentialFlow,
Public, Public,
SESSION_COOKIE, SESSION_COOKIE,
setSessionCookie, setSessionCookie,
toCurrentUser, toCurrentUser,
} from './auth.guard'; } from './auth.guard';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { OidcService } from './oidc.service'; import { SessionsService } from './sessions.service';
import { SessionsService, sessionAbsoluteMs } from './sessions.service';
@AuthenticatedOnly() // routes reachable without a session opt out via @Public @AuthenticatedOnly() // routes reachable without a session opt out via @Public
@Controller('auth') @Controller('auth')
@ -39,7 +36,6 @@ export class AuthController {
private readonly sessions: SessionsService, private readonly sessions: SessionsService,
private readonly config: AppConfig, private readonly config: AppConfig,
private readonly settings: InstanceSettingsService, private readonly settings: InstanceSettingsService,
private readonly oidc: OidcService,
) {} ) {}
/** Public: the SPA hides the signup route while registration is closed. */ /** Public: the SPA hides the signup route while registration is closed. */
@ -49,21 +45,8 @@ export class AuthController {
return { mode: await this.settings.get('auth.registrationMode') }; return { mode: await this.settings.get('auth.registrationMode') };
} }
/** Public: what the login screen offers (issue #214) the local form
* and/or the deploy-configured OIDC provider. */
@SetupExempt()
@Public()
@Get('methods')
methods(): AuthMethodsView {
return {
local: this.config.env.AUTH_LOCAL_ENABLED,
oidc: this.oidc.enabled ? { label: this.oidc.providerLabel } : null,
};
}
@Public() @Public()
@Post('signup') @Post('signup')
@LocalCredentialFlow()
@HttpCode(201) @HttpCode(201)
@RateLimit({ scope: 'signup', limit: 5, windowSeconds: 60 * 60 }) @RateLimit({ scope: 'signup', limit: 5, windowSeconds: 60 * 60 })
async signup(@Body(new ZodValidationPipe(signupInputSchema)) input: SignupInput): Promise<void> { async signup(@Body(new ZodValidationPipe(signupInputSchema)) input: SignupInput): Promise<void> {
@ -72,7 +55,6 @@ export class AuthController {
@Public() @Public()
@Post('verify-email') @Post('verify-email')
@LocalCredentialFlow()
@HttpCode(204) @HttpCode(204)
@RateLimit({ scope: 'verify-email', limit: 20, windowSeconds: 60 * 60 }) @RateLimit({ scope: 'verify-email', limit: 20, windowSeconds: 60 * 60 })
async verifyEmail( async verifyEmail(
@ -83,7 +65,6 @@ export class AuthController {
@Public() @Public()
@Post('resend-verification') @Post('resend-verification')
@LocalCredentialFlow()
@HttpCode(204) @HttpCode(204)
@RateLimit({ scope: 'resend-verification', limit: 5, windowSeconds: 60 * 60 }) @RateLimit({ scope: 'resend-verification', limit: 5, windowSeconds: 60 * 60 })
async resendVerification( async resendVerification(
@ -97,7 +78,6 @@ export class AuthController {
@SetupExempt() @SetupExempt()
@Public() @Public()
@Post('login') @Post('login')
@LocalCredentialFlow()
@HttpCode(200) @HttpCode(200)
@RateLimit({ scope: 'login', limit: 10, windowSeconds: 60 }) @RateLimit({ scope: 'login', limit: 10, windowSeconds: 60 })
async login( async login(
@ -110,12 +90,7 @@ export class AuthController {
input.password, input.password,
request.headers['user-agent'], request.headers['user-agent'],
); );
setSessionCookie( setSessionCookie(response, sessionToken, this.config.env.NODE_ENV === 'production');
response,
sessionToken,
this.config.env.NODE_ENV === 'production',
sessionAbsoluteMs(this.config.env),
);
return toCurrentUser(user); return toCurrentUser(user);
} }
@ -141,7 +116,6 @@ export class AuthController {
@Public() @Public()
@Post('forgot-password') @Post('forgot-password')
@LocalCredentialFlow()
@HttpCode(204) @HttpCode(204)
@RateLimit({ scope: 'forgot-password', limit: 5, windowSeconds: 60 * 60 }) @RateLimit({ scope: 'forgot-password', limit: 5, windowSeconds: 60 * 60 })
async forgotPassword( async forgotPassword(
@ -152,7 +126,6 @@ export class AuthController {
@Public() @Public()
@Post('reset-password') @Post('reset-password')
@LocalCredentialFlow()
@HttpCode(204) @HttpCode(204)
@RateLimit({ scope: 'reset-password', limit: 10, windowSeconds: 60 * 60 }) @RateLimit({ scope: 'reset-password', limit: 10, windowSeconds: 60 * 60 })
async resetPassword( async resetPassword(

View File

@ -4,7 +4,7 @@ import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createTestApp, sessionCookieOf } from '../testing/test-app'; import { createTestApp, sessionCookieOf } from '../testing/test-app';
import { createTestPrisma, deletePondsWhere, hasTestDb, uniqueSuffix } from '../testing/test-db'; import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
describe.skipIf(!hasTestDb)('auth flows (e2e)', () => { describe.skipIf(!hasTestDb)('auth flows (e2e)', () => {
let app: INestApplication; let app: INestApplication;
@ -46,7 +46,7 @@ describe.skipIf(!hasTestDb)('auth flows (e2e)', () => {
afterAll(async () => { afterAll(async () => {
// Verified users own a personal pond (#21) — remove it before them. // Verified users own a personal pond (#21) — remove it before them.
await deletePondsWhere(prisma, { owner: { username: { contains: suffix } } }); await prisma.pond.deleteMany({ where: { owner: { username: { contains: suffix } } } });
await prisma.user.deleteMany({ where: { username: { contains: suffix } } }); await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
await prisma.mailOutbox.deleteMany({ where: { toAddress: { contains: suffix } } }); await prisma.mailOutbox.deleteMany({ where: { toAddress: { contains: suffix } } });
await prisma.$disconnect(); await prisma.$disconnect();

View File

@ -3,7 +3,6 @@ import {
ExecutionContext, ExecutionContext,
ForbiddenException, ForbiddenException,
Injectable, Injectable,
NotFoundException,
SetMetadata, SetMetadata,
UnauthorizedException, UnauthorizedException,
createParamDecorator, createParamDecorator,
@ -14,7 +13,6 @@ import type { User } from '@prisma/client';
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { AppConfig } from '../config/app-config.service'; import { AppConfig } from '../config/app-config.service';
import { ProxyIdentityService } from './proxy-identity.service';
import { SessionsService } from './sessions.service'; import { SessionsService } from './sessions.service';
export const SESSION_COOKIE = 'dt_session'; export const SESSION_COOKIE = 'dt_session';
@ -23,18 +21,6 @@ const IS_PUBLIC_KEY = 'isPublic';
/** Marks a route as reachable without a session (login, signup, healthz…). */ /** Marks a route as reachable without a session (login, signup, healthz…). */
export const Public = (): MethodDecorator & ClassDecorator => SetMetadata(IS_PUBLIC_KEY, true); export const Public = (): MethodDecorator & ClassDecorator => SetMetadata(IS_PUBLIC_KEY, true);
export const LOCAL_CREDENTIAL_KEY = 'isLocalCredentialFlow';
/**
* Marks a route as part of the LOCAL credential machinery (issue #216,
* ADR 0021): password login, signup, e-mail verification, password
* forgot/reset/change. With `AUTH_LOCAL_ENABLED=false` every marked route
* answers 404 (existence hidden, the switch precedent) and the
* enumeration fence in `local-auth-switch.e2e.db.test.ts` fails when an
* auth route is neither marked nor on its reviewed allowlist, so a new
* credential flow cannot ship unswitched by accident.
*/
export const LocalCredentialFlow = (): MethodDecorator => SetMetadata(LOCAL_CREDENTIAL_KEY, true);
export interface AuthedRequest extends Request { export interface AuthedRequest extends Request {
user?: User; user?: User;
sessionId?: string; sessionId?: string;
@ -63,23 +49,13 @@ export function toCurrentUser(user: User): CurrentUserShape {
}; };
} }
/** /** Session cookie contract shared by login and the setup wizard (issue #80). */
* Session cookie contract shared by login and the setup wizard (issue #80). export function setSessionCookie(response: Response, token: string, production: boolean): void {
* `maxAgeMs` follows the configured absolute session bound (#190) the
* server-side idle/absolute checks are authoritative, the cookie merely
* stops outliving them.
*/
export function setSessionCookie(
response: Response,
token: string,
production: boolean,
maxAgeMs: number,
): void {
response.cookie(SESSION_COOKIE, token, { response.cookie(SESSION_COOKIE, token, {
httpOnly: true, httpOnly: true,
sameSite: 'lax', sameSite: 'lax',
secure: production, secure: production,
maxAge: maxAgeMs, maxAge: 30 * 24 * 60 * 60 * 1000,
path: '/', path: '/',
}); });
} }
@ -98,38 +74,19 @@ export class AuthGuard implements CanActivate {
private readonly reflector: Reflector, private readonly reflector: Reflector,
private readonly sessions: SessionsService, private readonly sessions: SessionsService,
private readonly config: AppConfig, private readonly config: AppConfig,
private readonly proxyIdentity: ProxyIdentityService,
) {} ) {}
async canActivate(context: ExecutionContext): Promise<boolean> { async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<AuthedRequest>(); const request = context.switchToHttp().getRequest<AuthedRequest>();
// The hard local-auth switch (issue #216): marked credential routes
// disappear entirely — before any session or CSRF logic runs.
if (!this.config.env.AUTH_LOCAL_ENABLED) {
const isLocalFlow = this.reflector.getAllAndOverride<boolean>(LOCAL_CREDENTIAL_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isLocalFlow) throw new NotFoundException();
}
const rawToken = (request.cookies as Record<string, string> | undefined)?.[SESSION_COOKIE]; const rawToken = (request.cookies as Record<string, string> | undefined)?.[SESSION_COOKIE];
if (rawToken && MUTATING_METHODS.has(request.method)) { if (rawToken && MUTATING_METHODS.has(request.method)) {
this.assertSameOrigin(request); this.assertSameOrigin(request);
} }
// Trusted-proxy identity first (issue #215): when the perimeter
// authenticates, its header IS the identity for this request — a
// session cookie riding along never escalates beyond it, and an
// untrusted peer carrying the header is rejected inside resolve().
const proxyUser = await this.proxyIdentity.resolve(request);
if (proxyUser) {
request.user = proxyUser;
} else if (rawToken) {
// Attach the user whenever the cookie is valid — public routes may // Attach the user whenever the cookie is valid — public routes may
// still want to know who is asking. // still want to know who is asking.
if (rawToken) {
const validated = await this.sessions.validate(rawToken); const validated = await this.sessions.validate(rawToken);
if (validated) { if (validated) {
request.user = validated.user; request.user = validated.user;
@ -148,27 +105,13 @@ export class AuthGuard implements CanActivate {
return true; return true;
} }
/**
* Fail closed (#189): a cookie-carrying mutation must prove its origin
* browsers always send `Origin` on cross- and same-origin mutations, so a
* missing header means "not a browser page of ours" and is rejected like a
* mismatch. Non-browser clients (curl, scripts) either send a matching
* `Origin` explicitly or authenticate with a PAT/bearer token and no
* cookie, which never reaches this check the exception for them is
* structural (bound to the cookie), never a header loophole.
*/
private assertSameOrigin(request: Request): void { private assertSameOrigin(request: Request): void {
const origin = request.headers.origin ?? request.headers.referer; const origin = request.headers.origin ?? request.headers.referer;
if (!origin) throw new ForbiddenException({ code: 'csrf_origin_mismatch' }); // Non-browser clients (curl, supertest) send neither header; SameSite
// cookies already stop cross-site browser requests without Origin.
if (!origin) return;
const expected = new URL(this.config.env.APP_BASE_URL).origin; const expected = new URL(this.config.env.APP_BASE_URL).origin;
let actual: string; if (new URL(origin).origin !== expected) {
try {
actual = new URL(origin).origin;
} catch {
// An unparsable Origin/Referer is a broken or hostile client, not ours.
throw new ForbiddenException({ code: 'csrf_origin_mismatch' });
}
if (actual !== expected) {
throw new ForbiddenException({ code: 'csrf_origin_mismatch' }); throw new ForbiddenException({ code: 'csrf_origin_mismatch' });
} }
} }

View File

@ -1,10 +1,6 @@
import { Logger, Module, OnModuleInit } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core'; import { APP_GUARD } from '@nestjs/core';
import { AppConfig } from '../config/app-config.service';
import { GrantsModule } from '../grants/grants.module';
import { InvitationsModule } from '../invitations/invitations.module';
import { MailModule } from '../mail/mail.module'; import { MailModule } from '../mail/mail.module';
import { PondsModule } from '../ponds/ponds.module'; import { PondsModule } from '../ponds/ponds.module';
import { UsersModule } from '../users/users.module'; import { UsersModule } from '../users/users.module';
@ -12,42 +8,18 @@ import { AuthController } from './auth.controller';
import { AuthGuard } from './auth.guard'; import { AuthGuard } from './auth.guard';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { AuthTokensService } from './auth-tokens.service'; import { AuthTokensService } from './auth-tokens.service';
import { ClaimMappingService } from './claim-mapping.service';
import { OidcController } from './oidc.controller';
import { OidcService } from './oidc.service';
import { ProxyIdentityService } from './proxy-identity.service';
import { SessionsModule } from './sessions.module'; import { SessionsModule } from './sessions.module';
@Module({ @Module({
imports: [UsersModule, MailModule, SessionsModule, PondsModule, GrantsModule, InvitationsModule], imports: [UsersModule, MailModule, SessionsModule, PondsModule],
controllers: [AuthController, OidcController], controllers: [AuthController],
providers: [ providers: [
AuthService, AuthService,
AuthTokensService, AuthTokensService,
ClaimMappingService,
OidcService,
ProxyIdentityService,
// Global default-protected: every route needs a session unless it // Global default-protected: every route needs a session unless it
// opts out with @Public(). // opts out with @Public().
{ provide: APP_GUARD, useClass: AuthGuard }, { provide: APP_GUARD, useClass: AuthGuard },
], ],
exports: [AuthTokensService, AuthService, OidcService], exports: [AuthTokensService, AuthService],
}) })
export class AuthModule implements OnModuleInit { export class AuthModule {}
constructor(
private readonly config: AppConfig,
private readonly oidc: OidcService,
private readonly proxyIdentity: ProxyIdentityService,
) {}
onModuleInit(): void {
// #216: local auth off without ANY external path means nobody can ever
// sign in — loudly stated at boot, because the operator will otherwise
// discover it at the login screen.
if (!this.config.env.AUTH_LOCAL_ENABLED && !this.oidc.enabled && !this.proxyIdentity.enabled) {
new Logger(AuthModule.name).warn(
'AUTH_LOCAL_ENABLED=false with neither OIDC nor proxy authentication configured — no sign-in path exists',
);
}
}
}

View File

@ -9,7 +9,6 @@ import { User } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino'; import { PinoLogger } from 'nestjs-pino';
import { AppConfig } from '../config/app-config.service'; import { AppConfig } from '../config/app-config.service';
import { InvitationsService } from '../invitations/invitations.service';
import { MailService } from '../mail/mail.service'; import { MailService } from '../mail/mail.service';
import { PondsService } from '../ponds/ponds.service'; import { PondsService } from '../ponds/ponds.service';
import { AuditService } from '../audit/audit.service'; import { AuditService } from '../audit/audit.service';
@ -34,7 +33,6 @@ export class AuthService {
private readonly sessions: SessionsService, private readonly sessions: SessionsService,
private readonly mail: MailService, private readonly mail: MailService,
private readonly ponds: PondsService, private readonly ponds: PondsService,
private readonly invitations: InvitationsService,
private readonly rateLimits: RateLimitService, private readonly rateLimits: RateLimitService,
private readonly audit: AuditService, private readonly audit: AuditService,
private readonly config: AppConfig, private readonly config: AppConfig,
@ -45,37 +43,10 @@ export class AuthService {
} }
async signup(input: SignupInput): Promise<void> { async signup(input: SignupInput): Promise<void> {
// An invitation token (issue #332) lets exactly one signup through a if ((await this.settings.get('auth.registrationMode')) === 'closed') {
// closed registration. Claimed atomically BEFORE the account exists;
// rolled back if the signup fails (duplicate username), so the invitee
// can retry with the same link.
const invitation = input.invitationToken
? await this.invitations.redeem(input.invitationToken)
: null;
if (input.invitationToken && !invitation) {
throw new BadRequestException({ code: 'token_invalid' });
}
if (!invitation && (await this.settings.get('auth.registrationMode')) === 'closed') {
throw new ForbiddenException({ code: 'registration_closed' }); throw new ForbiddenException({ code: 'registration_closed' });
} }
let user: User; const user = await this.users.createUser(input);
try {
user = await this.users.createUser(input);
} catch (error) {
if (invitation) await this.invitations.unredeem(invitation.id);
throw error;
}
if (invitation) {
await this.invitations.markAccepted(invitation.id, user.id);
await this.audit.record({
action: 'invitation.accepted',
actorId: user.id,
targetType: 'invitation',
targetId: invitation.id,
});
}
// The invite link proves nothing about the mailbox (it can be
// forwarded), so the usual verification mail still applies.
await this.sendVerificationMail(user); await this.sendVerificationMail(user);
await this.audit.record({ action: 'auth.signup', actorId: user.id }); await this.audit.record({ action: 'auth.signup', actorId: user.id });
} }

View File

@ -1,272 +0,0 @@
import { createServer, type Server } from 'node:http';
import type { AddressInfo } from 'node:net';
import { INestApplication } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { SignJWT, exportJWK, generateKeyPair, type JWTPayload } from 'jose';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { PondAccessNotifier } from '../ponds/pond-access-notifier.service';
import { InstanceSettingsService } from '../settings/instance-settings.service';
import { createTestApp, sessionCookieOf } from '../testing/test-app';
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service';
/**
* IdP claim mapping (issue #217, ADR 0021): declarative `idpMapping.rules`
* turn ID-token claims into pond roles and the site-admin flag on every
* OIDC login through the same grant-service path as manual grants (the
* collab revocation notify is asserted), with removal on the next login,
* "manual wins" precedence, and audited changes.
*/
describe.skipIf(!hasTestDb)('idp claim mapping (e2e, issue #217)', () => {
let app: INestApplication;
let prisma: PrismaClient;
let idp: Server;
let issuer: string;
const suffix = uniqueSuffix();
let signingKey: CryptoKey;
let publicJwk: Record<string, unknown>;
let nextClaims: (nonce: string) => JWTPayload;
let currentNonce = '';
let adminId: string;
let pondId: string;
const pondSlug = `mapped-${suffix}`;
const api = () => request(app.getHttpServer());
async function loginViaIdp(): Promise<string> {
const begin = await api().get('/api/v1/auth/oidc/login').expect(302);
const url = new URL(begin.headers.location!);
currentNonce = url.searchParams.get('nonce')!;
const stateCookie = (begin.headers['set-cookie'] as unknown as string[])
.find((c) => c.startsWith('dt_oidc='))!
.split(';')[0]!;
const res = await api()
.get(
`/api/v1/auth/oidc/callback?code=fake&state=${encodeURIComponent(
url.searchParams.get('state')!,
)}`,
)
.set('Cookie', stateCookie)
.expect(302);
expect(res.headers.location!).toMatch(/\/$/);
return sessionCookieOf(res);
}
function subjectClaims(groups: string[]): (nonce: string) => JWTPayload {
return (nonce) => ({
iss: issuer,
aud: 'dorfteich-map',
sub: `mapped-${suffix}`,
nonce,
email: `mapped-${suffix}@idp.example`,
email_verified: true,
preferred_username: `mapped-${suffix}`,
groups,
});
}
beforeAll(async () => {
prisma = createTestPrisma();
await prisma.rateLimit.deleteMany({});
let signingPublic: CryptoKey;
({ privateKey: signingKey, publicKey: signingPublic } = await generateKeyPair('RS256', {
extractable: true,
}));
publicJwk = { ...(await exportJWK(signingPublic)), kid: 'map-key', alg: 'RS256' };
idp = createServer((req, res) => {
void (async () => {
res.setHeader('content-type', 'application/json');
if (req.url === '/.well-known/openid-configuration') {
res.end(
JSON.stringify({
issuer,
authorization_endpoint: `${issuer}/authorize`,
token_endpoint: `${issuer}/token`,
jwks_uri: `${issuer}/jwks`,
}),
);
} else if (req.url === '/jwks') {
res.end(JSON.stringify({ keys: [publicJwk] }));
} else if (req.url === '/token') {
req.resume();
req.on('end', () => {
void (async () => {
const now = Math.floor(Date.now() / 1000);
const idToken = await new SignJWT({ ...nextClaims(currentNonce) })
.setProtectedHeader({ alg: 'RS256', kid: 'map-key' })
.setIssuedAt(now)
.setExpirationTime(now + 300)
.sign(signingKey);
res.end(JSON.stringify({ id_token: idToken }));
})();
});
} else {
res.statusCode = 404;
res.end();
}
})();
});
await new Promise<void>((resolve) => idp.listen(0, '127.0.0.1', resolve));
issuer = `http://127.0.0.1:${(idp.address() as AddressInfo).port}`;
process.env.OIDC_ISSUER = issuer;
process.env.OIDC_CLIENT_ID = 'dorfteich-map';
app = await createTestApp();
// A pond to map into, owned by an admin user (created via the service,
// grants via prisma BEFORE the first permission query — test-db rule).
const users = app.get(UsersService);
const admin = await users.createUser({
username: `map-admin-${suffix}`,
email: `map-admin-${suffix}@example.test`,
displayName: 'Map Admin',
password: 'mapping admin 123',
locale: 'en',
});
await users.markEmailVerified(admin.id);
adminId = admin.id;
const pond = await prisma.pond.create({
data: { slug: pondSlug, name: 'Mapped Pond', type: 'SHARED', ownerId: adminId },
});
pondId = pond.id;
await prisma.roleGrant.create({
data: {
pondId,
subjectType: 'USER',
subjectId: adminId,
role: 'POND_ADMIN',
scopeType: 'POND',
scopeId: null,
effect: 'ALLOW',
createdBy: adminId,
},
});
await app.get(InstanceSettingsService).set(
'idpMapping.rules',
[
{ claim: 'groups', value: 'wiki-editors', role: 'editor', pondSlug },
{ claim: 'groups', value: 'wiki-admins', role: 'site_admin' },
],
adminId,
);
});
afterAll(async () => {
delete process.env.OIDC_ISSUER;
delete process.env.OIDC_CLIENT_ID;
await new Promise<void>((resolve) => idp.close(() => resolve()));
await prisma.instanceSetting.deleteMany({ where: { key: 'idpMapping.rules' } });
await prisma.userIdentity.deleteMany({ where: { provider: `oidc:${issuer}` } });
await prisma.roleGrant.deleteMany({ where: { pondId } });
await prisma.page.deleteMany({
where: { pond: { owner: { username: { contains: suffix } } } },
});
await prisma.roleGrant.deleteMany({
where: { pond: { owner: { username: { contains: suffix } } } },
});
await prisma.pond.deleteMany({ where: { owner: { username: { contains: suffix } } } });
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
await prisma.$disconnect();
await app.close();
});
it('grants the mapped pond role on login and access actually works', async () => {
nextClaims = subjectClaims(['wiki-editors']);
const session = await loginViaIdp();
const grant = await prisma.roleGrant.findFirst({
where: { pondId, subjectType: 'USER', origin: 'idp' },
});
expect(grant).toMatchObject({ role: 'EDITOR', effect: 'ALLOW' });
// The permission model actually honours it (no raw-row bypass).
const pages = await api()
.get(`/api/v1/ponds/${pondId}/pages`)
.set('Cookie', session)
.expect(200);
expect(Array.isArray(pages.body)).toBe(true);
const audit = await prisma.auditEntry.findFirst({
where: { action: 'grant.created', targetId: pondId },
orderBy: { at: 'desc' },
});
expect(audit?.details).toMatchObject({ origin: 'idp_mapping' });
});
it('revokes the mapped grant on the next login without the claim — via the revocation path', async () => {
const notifier = app.get(PondAccessNotifier);
const notifySpy = vi.spyOn(notifier, 'notifyAccessChanged');
nextClaims = subjectClaims([]);
const session = await loginViaIdp();
try {
expect(await prisma.roleGrant.findFirst({ where: { pondId, origin: 'idp' } })).toBeNull();
// The removal travelled through the grant service: the collab
// revocation notify fired for this pond (the pg_notify access
// listener terminates live sessions — that path's own tests cover
// the socket close).
expect(notifySpy.mock.calls.some(([id]) => id === pondId)).toBe(true);
// …and the pond is out of reach again (404: existence hidden).
await api().get(`/api/v1/ponds/${pondId}/pages`).set('Cookie', session).expect(404);
} finally {
notifySpy.mockRestore();
}
});
it('never touches a manual grant, and re-creating over one is skipped (manual wins)', async () => {
const user = await prisma.user.findUnique({
where: { email: `mapped-${suffix}@idp.example` },
});
// A manual reader grant made by the pond admin.
await prisma.roleGrant.create({
data: {
pondId,
subjectType: 'USER',
subjectId: user!.id,
role: 'READER',
scopeType: 'POND',
scopeId: null,
effect: 'ALLOW',
createdBy: adminId,
origin: 'manual',
},
});
// Login without any mapped claim: the manual grant survives.
nextClaims = subjectClaims([]);
await loginViaIdp();
const manual = await prisma.roleGrant.findFirst({
where: { pondId, subjectId: user!.id, origin: 'manual' },
});
expect(manual).not.toBeNull();
expect(manual!.role).toBe('READER');
});
it('maps and revokes the site-admin flag — but never demotes a hand-promoted admin', async () => {
nextClaims = subjectClaims(['wiki-admins']);
await loginViaIdp();
let user = await prisma.user.findUnique({ where: { email: `mapped-${suffix}@idp.example` } });
expect(user).toMatchObject({ isSiteAdmin: true, isSiteAdminManaged: true });
nextClaims = subjectClaims([]);
await loginViaIdp();
user = await prisma.user.findUnique({ where: { email: `mapped-${suffix}@idp.example` } });
expect(user).toMatchObject({ isSiteAdmin: false, isSiteAdminManaged: false });
// Hand-promoted (managed=false): a claimless login must not demote.
await prisma.user.update({
where: { id: user!.id },
data: { isSiteAdmin: true, isSiteAdminManaged: false },
});
nextClaims = subjectClaims([]);
await loginViaIdp();
user = await prisma.user.findUnique({ where: { email: `mapped-${suffix}@idp.example` } });
expect(user!.isSiteAdmin).toBe(true);
});
});

View File

@ -1,161 +0,0 @@
import { Injectable } from '@nestjs/common';
import { User } from '@prisma/client';
import type { JWTPayload } from 'jose';
import { PinoLogger } from 'nestjs-pino';
import { AuditService } from '../audit/audit.service';
import { GrantsService } from '../grants/grants.service';
import { PrismaService } from '../prisma/prisma.service';
import { InstanceSettingsService } from '../settings/instance-settings.service';
/**
* IdP claim mapping (issue #217, ADR 0021): on every OIDC login the
* declarative rules in `idpMapping.rules` are evaluated against the ID
* token's claims and reconciled against the user's MAPPING-OWNED state:
*
* - Pond grants are created and revoked through {@link GrantsService}
* the same path as manual grants, so the permission cache is
* invalidated and live collab sessions are revalidated
* (`notifyAccessChanged` the collab access listener) exactly as on a
* manual change. No raw row writes.
* - The mapping only ever touches rows with `origin = 'idp'` and only
* demotes a site admin whose flag it itself set
* (`isSiteAdminManaged`) **manual wins**: hand-made grants and
* hand-promoted admins are never revoked by a missing claim.
* - Every change is audited (grant.created/grant.deleted with
* `origin: idp_mapping`; user.site_admin_set with the same marker).
*
* Reconciliation happens at login because that is when fresh claims
* exist; between logins the leaver case is the IdP's (disable there =
* no new login) plus the operator's account-disable flag.
*/
@Injectable()
export class ClaimMappingService {
constructor(
private readonly prisma: PrismaService,
private readonly grants: GrantsService,
private readonly settings: InstanceSettingsService,
private readonly audit: AuditService,
private readonly logger: PinoLogger,
) {
this.logger.setContext(ClaimMappingService.name);
}
async apply(user: User, payload: JWTPayload): Promise<void> {
const rules = await this.settings.get('idpMapping.rules');
if (rules.length === 0) return;
const matched = rules.filter((rule) => claimMatches(payload[rule.claim], rule.value));
await this.reconcileSiteAdmin(
user,
matched.some((rule) => rule.role === 'site_admin'),
);
// Desired pond grants, resolved slug → id (unknown slugs are a
// configuration error: logged, never fatal for the login).
const desired = new Map<string, 'pond_admin' | 'editor' | 'reader'>();
for (const rule of matched) {
if (rule.role === 'site_admin') continue;
const pond = await this.prisma.pond.findFirst({
where: { slug: rule.pondSlug!, deletedAt: null },
select: { id: true },
});
if (!pond) {
this.logger.warn({ pondSlug: rule.pondSlug }, 'idp mapping: unknown pond slug');
continue;
}
// Multiple rules for one pond: the strongest role wins.
const current = desired.get(pond.id);
if (!current || rank(rule.role) > rank(current)) desired.set(pond.id, rule.role);
}
const existing = await this.prisma.roleGrant.findMany({
where: { subjectType: 'USER', subjectId: user.id, origin: 'idp' },
});
for (const grant of existing) {
const wanted = desired.get(grant.pondId);
if (wanted && toDbRole(wanted) === grant.role) {
desired.delete(grant.pondId); // already in place
continue;
}
try {
await this.grants.deleteGrant(user, grant.pondId, grant.id, { origin: 'idp' });
} catch (error) {
// E.g. the last-Pond-Admin protection: the grant stays, the login
// proceeds — an operator decision is needed, not a lockout.
this.logger.warn(
{ grantId: grant.id, pondId: grant.pondId, err: error },
'idp mapping: grant revocation refused',
);
}
}
for (const [pondId, role] of desired) {
try {
await this.grants.createGrant(
user,
pondId,
{
subjectType: 'user',
subjectId: user.id,
role,
scopeType: 'pond',
scopeId: null,
effect: 'allow',
},
{ origin: 'idp' },
);
} catch (error) {
// A colliding MANUAL grant (grant_exists) is fine — manual wins,
// the mapping never replaces it with an owned copy.
this.logger.warn({ pondId, role, err: error }, 'idp mapping: grant creation skipped');
}
}
}
private async reconcileSiteAdmin(user: User, shouldBeAdmin: boolean): Promise<void> {
if (shouldBeAdmin && !user.isSiteAdmin) {
await this.prisma.user.update({
where: { id: user.id },
data: { isSiteAdmin: true, isSiteAdminManaged: true },
});
await this.audit.record({
action: 'user.site_admin_set',
actorId: user.id,
targetType: 'user',
targetId: user.id,
details: { isSiteAdmin: true, origin: 'idp_mapping' },
});
} else if (!shouldBeAdmin && user.isSiteAdmin && user.isSiteAdminManaged) {
// Only the mapping's own promotion is revocable by a missing claim.
await this.prisma.user.update({
where: { id: user.id },
data: { isSiteAdmin: false, isSiteAdminManaged: false },
});
await this.audit.record({
action: 'user.site_admin_set',
actorId: user.id,
targetType: 'user',
targetId: user.id,
details: { isSiteAdmin: false, origin: 'idp_mapping' },
});
}
}
}
/** A claim matches when it equals the value or, as an array, contains it. */
function claimMatches(claim: unknown, value: string): boolean {
if (Array.isArray(claim)) return claim.some((entry) => String(entry) === value);
if (claim === undefined || claim === null) return false;
return String(claim) === value;
}
function rank(role: 'pond_admin' | 'editor' | 'reader'): number {
return role === 'pond_admin' ? 3 : role === 'editor' ? 2 : 1;
}
function toDbRole(role: 'pond_admin' | 'editor' | 'reader'): 'POND_ADMIN' | 'EDITOR' | 'READER' {
return role === 'pond_admin' ? 'POND_ADMIN' : role === 'editor' ? 'EDITOR' : 'READER';
}

View File

@ -1,178 +0,0 @@
import { INestApplication } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { InstanceSettingsService } from '../settings/instance-settings.service';
import { SUPPRESS_ORIGIN_HEADER, createTestApp, sessionCookieOf } from '../testing/test-app';
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service';
/**
* CSRF origin check, fail closed (issue #189): a cookie-carrying mutation
* without `Origin` and `Referer` is rejected exactly like a mismatch, and
* the exception for non-browser clients is structural PAT/bearer requests
* carry no cookie and never reach the check. Cookie-authenticated requests
* never benefit from any header-based bypass.
*/
describe.skipIf(!hasTestDb)('csrf origin check (e2e, issue #189)', () => {
let app: INestApplication;
let prisma: PrismaClient;
const suffix = uniqueSuffix();
const password = 'csrf fail closed pass 1';
const ids: Record<string, string> = {};
const cookies: Record<string, string> = {};
let pondId: string;
let pondSlug: string;
let patToken: string;
const api = () => request(app.getHttpServer());
async function makeUser(handle: string): Promise<void> {
const users = app.get(UsersService);
const username = `csrf-${handle}-${suffix}`;
const user = await users.createUser({
username,
email: `${username}@example.org`,
displayName: `Csrf ${handle}`,
password,
locale: 'en',
});
await users.markEmailVerified(user.id);
ids[handle] = user.id;
cookies[handle] = sessionCookieOf(
await api()
.post('/api/v1/auth/login')
.send({ usernameOrEmail: username, password })
.expect(200),
);
}
beforeAll(async () => {
prisma = createTestPrisma();
await prisma.rateLimit.deleteMany({});
app = await createTestApp();
for (const handle of ['owner', 'siteadmin']) {
await makeUser(handle);
}
await prisma.user.update({ where: { id: ids.siteadmin! }, data: { isSiteAdmin: true } });
// Per-user quota override, never the instance default (shared database).
await api()
.put(`/api/v1/admin/quotas/user/${ids.owner!}/additional_ponds`)
.set('Cookie', cookies.siteadmin!)
.send({ value: 5 })
.expect(200);
// A pond opted into the public API, and a write-scope PAT for it.
const pond = await api()
.post('/api/v1/ponds')
.set('Cookie', cookies.owner!)
.send({ name: `CSRF Pond ${suffix}` })
.expect(201);
pondId = pond.body.id;
pondSlug = pond.body.slug;
await app.get(InstanceSettingsService).set('api.enabled', true, ids.siteadmin!);
await api()
.patch(`/api/v1/ponds/${pondId}`)
.set('Cookie', cookies.owner!)
.send({ apiEnabled: true })
.expect(200);
const pat = await api()
.post('/api/v1/users/me/api-tokens')
.set('Cookie', cookies.owner!)
.send({ name: 'csrf-write', scope: 'write' })
.expect(201);
patToken = pat.body.token;
});
afterAll(async () => {
const all = Object.values(ids);
await prisma.instanceSetting.deleteMany({ where: { key: 'api.enabled' } });
await prisma.quotaOverride.deleteMany({ where: { subjectId: { in: all } } });
await prisma.auditEntry.deleteMany({ where: { actorId: { in: all } } });
await prisma.apiToken.deleteMany({ where: { userId: { in: all } } });
const ponds = await prisma.pond.findMany({
where: { ownerId: { in: all } },
select: { id: true },
});
const pondIds = ponds.map((p) => p.id);
await prisma.pageVersion.deleteMany({ where: { page: { pondId: { in: pondIds } } } });
await prisma.page.deleteMany({ where: { pondId: { in: pondIds } } });
await prisma.roleGrant.deleteMany({ where: { pondId: { in: pondIds } } });
await prisma.pondUsage.deleteMany({ where: { pondId: { in: pondIds } } });
await prisma.pond.deleteMany({ where: { id: { in: pondIds } } });
await prisma.session.deleteMany({ where: { userId: { in: all } } });
await prisma.userIdentity.deleteMany({ where: { userId: { in: all } } });
await prisma.rateLimit.deleteMany({});
await prisma.user.deleteMany({ where: { id: { in: all } } });
await prisma.$disconnect();
await app.close();
});
it('rejects a cookie mutation that sends neither Origin nor Referer', async () => {
const res = await api()
.patch(`/api/v1/ponds/${pondId}`)
.set('Cookie', cookies.owner!)
.set(SUPPRESS_ORIGIN_HEADER, '1')
.send({ name: `CSRF Pond ${suffix}` })
.expect(403);
expect(res.body.code).toBe('csrf_origin_mismatch');
});
it('rejects a cookie mutation from a mismatching origin (kept behaviour)', async () => {
const res = await api()
.patch(`/api/v1/ponds/${pondId}`)
.set('Cookie', cookies.owner!)
.set('Origin', 'https://evil.example')
.send({ name: `CSRF Pond ${suffix}` })
.expect(403);
expect(res.body.code).toBe('csrf_origin_mismatch');
});
it('rejects a cookie mutation with an unparsable Origin instead of erroring', async () => {
const res = await api()
.patch(`/api/v1/ponds/${pondId}`)
.set('Cookie', cookies.owner!)
.set('Origin', 'not a url')
.send({ name: `CSRF Pond ${suffix}` })
.expect(403);
expect(res.body.code).toBe('csrf_origin_mismatch');
});
it('accepts a cookie mutation from the matching origin', async () => {
await api()
.patch(`/api/v1/ponds/${pondId}`)
.set('Cookie', cookies.owner!)
.send({ name: `CSRF Pond ${suffix}` })
.expect(200);
});
it('leaves cookie reads untouched — the check binds to mutations', async () => {
await api()
.get('/api/v1/auth/me')
.set('Cookie', cookies.owner!)
.set(SUPPRESS_ORIGIN_HEADER, '1')
.expect(200);
});
it('lets a PAT mutation through without either header — no cookie, no check', async () => {
await api()
.post(`/api/public/v1/ponds/${pondSlug}/pages`)
.set('Authorization', `Bearer ${patToken}`)
.set(SUPPRESS_ORIGIN_HEADER, '1')
.send({ title: `CSRF PAT page ${suffix}` })
.expect(201);
});
it('enforces the check when a request carries both cookie and bearer token', async () => {
// Cookie-authenticated requests never benefit from the bearer exception.
const res = await api()
.patch(`/api/v1/ponds/${pondId}`)
.set('Cookie', cookies.owner!)
.set('Authorization', `Bearer ${patToken}`)
.set(SUPPRESS_ORIGIN_HEADER, '1')
.send({ name: `CSRF Pond ${suffix}` })
.expect(403);
expect(res.body.code).toBe('csrf_origin_mismatch');
});
});

View File

@ -1,157 +0,0 @@
import 'reflect-metadata';
import { INestApplication } from '@nestjs/common';
import { PATH_METADATA } from '@nestjs/common/constants';
import { PrismaClient } from '@prisma/client';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createTestApp } from '../testing/test-app';
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service';
import { LOCAL_CREDENTIAL_KEY } from './auth.guard';
import { AuthController } from './auth.controller';
import { OidcController } from './oidc.controller';
import { SessionsService } from './sessions.service';
/**
* The hard local-auth switch (issue #216, ADR 0021): AUTH_LOCAL_ENABLED=false
* closes EVERY local credential flow with 404 enumerated, not assumed
* while sessions themselves, logout, and token issuance for
* externally-authenticated users keep working (the stated decision: PATs
* and feed tokens authorize API access under their own switches, they are
* not interactive sign-in). A fence asserts every auth route is either
* marked as a local flow or on the reviewed allowlist.
*/
describe.skipIf(!hasTestDb)('local-auth switch (e2e, issue #216)', () => {
let app: INestApplication;
let prisma: PrismaClient;
const suffix = uniqueSuffix();
/** Every local credential surface — the enumeration the issue demands. */
const LOCAL_ROUTES: { method: 'post'; path: string; body: Record<string, unknown> }[] = [
{ method: 'post', path: '/api/v1/auth/login', body: { usernameOrEmail: 'x', password: 'y' } },
{
method: 'post',
path: '/api/v1/auth/signup',
body: {
username: `switch-${suffix}`,
email: `switch-${suffix}@example.test`,
displayName: 'x',
password: 'ein langes passwort 123',
locale: 'en',
},
},
{ method: 'post', path: '/api/v1/auth/verify-email', body: { token: 'x' } },
{
method: 'post',
path: '/api/v1/auth/resend-verification',
body: { email: 'x@example.test' },
},
{ method: 'post', path: '/api/v1/auth/forgot-password', body: { email: 'x@example.test' } },
{
method: 'post',
path: '/api/v1/auth/reset-password',
body: { token: 'x', password: 'ein langes passwort 123' },
},
{
method: 'post',
path: '/api/v1/users/me/change-password',
body: { currentPassword: 'x', newPassword: 'ein langes passwort 123' },
},
];
const api = () => request(app.getHttpServer());
beforeAll(async () => {
prisma = createTestPrisma();
await prisma.rateLimit.deleteMany({});
process.env.AUTH_LOCAL_ENABLED = 'false';
app = await createTestApp();
});
afterAll(async () => {
delete process.env.AUTH_LOCAL_ENABLED;
await prisma.apiToken.deleteMany({ where: { user: { username: { contains: suffix } } } });
await prisma.feedToken.deleteMany({ where: { user: { username: { contains: suffix } } } });
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
await prisma.$disconnect();
await app.close();
});
it('answers 404 on every enumerated local credential route', async () => {
for (const route of LOCAL_ROUTES) {
const res = await api()[route.method](route.path).send(route.body);
expect(`${route.path}: ${res.status}`).toBe(`${route.path}: 404`);
}
});
it('reports local:false so the login screen hides the form', async () => {
const res = await api().get('/api/v1/auth/methods').expect(200);
expect(res.body.local).toBe(false);
});
it('keeps sessions, logout, and PAT/feed-token issuance working for externally-authenticated users', async () => {
// An externally-authenticated user is simulated by creating the session
// through the session service — exactly what the OIDC/proxy paths do.
const users = app.get(UsersService);
const user = await users.createUser({
username: `ext-${suffix}`,
email: `ext-${suffix}@example.test`,
displayName: 'External',
password: 'nie benutzt weil lokal aus',
locale: 'en',
});
await users.markEmailVerified(user.id);
const token = await app.get(SessionsService).create(user.id, undefined);
const cookie = `dt_session=${token}`;
const me = await api().get('/api/v1/auth/me').set('Cookie', cookie).expect(200);
expect(me.body.id).toBe(user.id);
// Stated decision (#216): token issuance is API authorization, not
// interactive sign-in — it stays available under its own switches.
await api()
.post('/api/v1/users/me/api-tokens')
.set('Cookie', cookie)
.send({ name: `switch-${suffix}`, scope: 'read' })
.expect(201);
await api()
.post('/api/v1/users/me/feed-tokens')
.set('Cookie', cookie)
.send({ name: `switch-${suffix}` })
.expect(201);
await api().post('/api/v1/auth/logout').set('Cookie', cookie).expect(204);
await api().get('/api/v1/auth/me').set('Cookie', cookie).expect(401);
});
it('fence: every auth route is either a marked local flow or on the reviewed allowlist', () => {
// Routes that must stay reachable with local auth off — reviewed here.
const allowlist = new Set([
'registration', // signup-mode discovery; harmless metadata
'methods', // the login screen's discovery endpoint
'logout', // ending a session is not a credential flow
'me', // session introspection
'login', // OidcController: IdP redirect
'link', // OidcController: explicit identity linking
'callback', // OidcController: IdP return leg
]);
for (const controller of [AuthController, OidcController]) {
for (const name of Object.getOwnPropertyNames(controller.prototype)) {
if (name === 'constructor') continue;
const handler = controller.prototype[name as keyof typeof controller.prototype] as (
...args: unknown[]
) => unknown;
const path = Reflect.getMetadata(PATH_METADATA, handler) as string | undefined;
if (path === undefined) continue; // not a route
const marked = Reflect.getMetadata(LOCAL_CREDENTIAL_KEY, handler) === true;
expect(
marked || allowlist.has(path),
`${controller.name}.${name} (path "${path}") is neither @LocalCredentialFlow nor allowlisted`,
).toBe(true);
}
}
});
});

View File

@ -1,106 +0,0 @@
import { Controller, Get, Query, Req, Res } from '@nestjs/common';
import type { Response } from 'express';
import { AppConfig } from '../config/app-config.service';
import { AuthenticatedOnly } from '../permissions/permission.decorators';
import { RateLimit } from '../rate-limit/rate-limit.guard';
import { AuthedRequest, Public, setSessionCookie } from './auth.guard';
import { OidcService } from './oidc.service';
import { sessionAbsoluteMs } from './sessions.service';
/** Carries state+nonce+PKCE verifier across the IdP round-trip signed
* (purpose-derived key), HttpOnly, Lax so the top-level callback
* navigation still sends it, and 10 minutes short-lived. */
const OIDC_STATE_COOKIE = 'dt_oidc';
/**
* OIDC endpoints (issue #214, ADR 0021). Browser-navigation shaped: `login`
* and `link` answer 302 to the IdP, the callback lands back here and
* redirects into the SPA errors become `/login?error=<code>` so the SPA
* can translate them.
*/
@AuthenticatedOnly()
@Controller('auth/oidc')
export class OidcController {
constructor(
private readonly oidc: OidcService,
private readonly config: AppConfig,
) {}
private stateCookie(response: Response, value: string): void {
response.cookie(OIDC_STATE_COOKIE, value, {
httpOnly: true,
sameSite: 'lax',
secure: this.config.env.NODE_ENV === 'production',
maxAge: 10 * 60 * 1000,
path: '/',
});
}
@Public()
@Get('login')
@RateLimit({ scope: 'oidc-login', limit: 30, windowSeconds: 60 })
async login(@Res() response: Response): Promise<void> {
this.oidc.assertEnabled();
const { url, stateToken } = await this.oidc.beginLogin();
this.stateCookie(response, stateToken);
response.redirect(url);
}
/** The deliberate account-linking flow (ADR 0021 §2): only a logged-in
* user attaches an IdP identity to their own account. */
@Get('link')
@RateLimit({ scope: 'oidc-login', limit: 30, windowSeconds: 60 })
async link(@Req() request: AuthedRequest, @Res() response: Response): Promise<void> {
this.oidc.assertEnabled();
const { url, stateToken } = await this.oidc.beginLogin(request.user!.id);
this.stateCookie(response, stateToken);
response.redirect(url);
}
@Public()
@Get('callback')
@RateLimit({ scope: 'oidc-callback', limit: 30, windowSeconds: 60 })
async callback(
@Query('code') code: string | undefined,
@Query('state') state: string | undefined,
@Query('error') idpError: string | undefined,
@Req() request: AuthedRequest,
@Res() response: Response,
): Promise<void> {
this.oidc.assertEnabled();
const base = this.config.env.APP_BASE_URL;
response.clearCookie(OIDC_STATE_COOKIE, { path: '/' });
const stateToken = (request.cookies as Record<string, string> | undefined)?.[OIDC_STATE_COOKIE];
if (idpError || !code || !state || !stateToken) {
response.redirect(`${base}/login?error=oidc_cancelled`);
return;
}
try {
const result = await this.oidc.completeLogin(
code,
state,
stateToken,
request.headers['user-agent'],
);
if (result.linked) {
response.redirect(`${base}/settings?oidc=linked`);
return;
}
setSessionCookie(
response,
result.sessionToken!,
this.config.env.NODE_ENV === 'production',
sessionAbsoluteMs(this.config.env),
);
response.redirect(`${base}/`);
} catch (error) {
const code_ =
typeof (error as { response?: { code?: string } })?.response?.code === 'string'
? (error as { response: { code: string } }).response.code
: 'oidc_failed';
response.redirect(`${base}/login?error=${encodeURIComponent(code_)}`);
}
}
}

View File

@ -1,351 +0,0 @@
import { createServer, type Server } from 'node:http';
import type { AddressInfo } from 'node:net';
import { INestApplication } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { SignJWT, exportJWK, generateKeyPair, type JWTPayload } from 'jose';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createTestApp, sessionCookieOf } from '../testing/test-app';
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service';
/**
* OIDC Authorization Code + PKCE against a local fake IdP (issue #214,
* ADR 0021): discovery, JWKS-validated ID tokens, state/nonce binding, PKCE
* verifier at the token endpoint, JIT account creation, the documented
* refusal to link silently by e-mail, and the explicit link flow. The fake
* IdP is protocol-shaped exactly like Keycloak's endpoints the Keycloak
* verification itself is a manual procedure (security.md §External
* authentication).
*/
describe.skipIf(!hasTestDb)('oidc login (e2e, issue #214)', () => {
let app: INestApplication;
let prisma: PrismaClient;
let idp: Server;
let issuer: string;
const suffix = uniqueSuffix();
let signingKey: CryptoKey;
let publicJwk: Record<string, unknown>;
let wrongKey: CryptoKey;
/** What the fake token endpoint returns next (set per test). */
let nextIdToken: (() => Promise<string>) | null = null;
/** The last body the token endpoint received (PKCE assertions). */
let lastTokenRequest: URLSearchParams | null = null;
const api = () => request(app.getHttpServer());
async function mintIdToken(
claims: JWTPayload,
options: { key?: CryptoKey; expired?: boolean } = {},
): Promise<string> {
const now = Math.floor(Date.now() / 1000);
return new SignJWT({ ...claims })
.setProtectedHeader({ alg: 'RS256', kid: 'test-key' })
.setIssuedAt(options.expired ? now - 7200 : now)
.setExpirationTime(options.expired ? now - 3600 : now + 300)
.sign(options.key ?? signingKey);
}
/** Runs /auth/oidc/login and returns the pieces the callback needs. */
async function beginLogin(cookie?: string) {
const req = api().get('/api/v1/auth/oidc/login');
const res = await (cookie ? req.set('Cookie', cookie) : req).expect(302);
const url = new URL(res.headers.location!);
const stateCookie = (res.headers['set-cookie'] as unknown as string[])
.find((c) => c.startsWith('dt_oidc='))!
.split(';')[0]!;
return {
state: url.searchParams.get('state')!,
nonce: url.searchParams.get('nonce')!,
challenge: url.searchParams.get('code_challenge')!,
stateCookie,
authorizeUrl: url,
};
}
async function callback(state: string, stateCookie: string) {
return api()
.get(`/api/v1/auth/oidc/callback?code=fake-code&state=${encodeURIComponent(state)}`)
.set('Cookie', stateCookie);
}
function redirectTarget(res: request.Response): string {
return res.headers.location!;
}
beforeAll(async () => {
prisma = createTestPrisma();
await prisma.rateLimit.deleteMany({});
let signingPublic: CryptoKey;
({ privateKey: signingKey, publicKey: signingPublic } = await generateKeyPair('RS256', {
extractable: true,
}));
({ privateKey: wrongKey } = await generateKeyPair('RS256', { extractable: true }));
publicJwk = { ...(await exportJWK(signingPublic)), kid: 'test-key', alg: 'RS256' };
idp = createServer((req, res) => {
void (async () => {
if (req.url === '/.well-known/openid-configuration') {
res.setHeader('content-type', 'application/json');
res.end(
JSON.stringify({
issuer,
authorization_endpoint: `${issuer}/authorize`,
token_endpoint: `${issuer}/token`,
jwks_uri: `${issuer}/jwks`,
}),
);
return;
}
if (req.url === '/jwks') {
res.setHeader('content-type', 'application/json');
res.end(JSON.stringify({ keys: [publicJwk] }));
return;
}
if (req.url === '/token') {
let body = '';
req.on('data', (chunk) => (body += chunk));
req.on('end', () => {
void (async () => {
lastTokenRequest = new URLSearchParams(body);
res.setHeader('content-type', 'application/json');
if (!nextIdToken) {
res.statusCode = 400;
res.end(JSON.stringify({ error: 'invalid_grant' }));
return;
}
res.end(JSON.stringify({ id_token: await nextIdToken(), token_type: 'Bearer' }));
})();
});
return;
}
res.statusCode = 404;
res.end();
})();
});
await new Promise<void>((resolve) => idp.listen(0, '127.0.0.1', resolve));
issuer = `http://127.0.0.1:${(idp.address() as AddressInfo).port}`;
process.env.OIDC_ISSUER = issuer;
process.env.OIDC_CLIENT_ID = 'dorfteich-test';
process.env.OIDC_PROVIDER_LABEL = 'Fake IdP';
app = await createTestApp();
});
afterAll(async () => {
delete process.env.OIDC_ISSUER;
delete process.env.OIDC_CLIENT_ID;
delete process.env.OIDC_PROVIDER_LABEL;
await new Promise<void>((resolve) => idp.close(() => resolve()));
await prisma.userIdentity.deleteMany({ where: { provider: `oidc:${issuer}` } });
await prisma.page.deleteMany({
where: { pond: { owner: { email: { contains: `${suffix}@idp.example` } } } },
});
await prisma.roleGrant.deleteMany({
where: { pond: { owner: { email: { contains: `${suffix}@idp.example` } } } },
});
await prisma.pond.deleteMany({
where: { owner: { email: { contains: `${suffix}@idp.example` } } },
});
await prisma.user.deleteMany({ where: { email: { contains: `${suffix}@idp.example` } } });
await prisma.user.deleteMany({ where: { username: { contains: `local-${suffix}` } } });
await prisma.$disconnect();
await app.close();
});
it('advertises the provider on /auth/methods', async () => {
const res = await api().get('/api/v1/auth/methods').expect(200);
expect(res.body).toEqual({ local: true, oidc: { label: 'Fake IdP' } });
});
it('logs in end to end: PKCE at the token endpoint, JIT user, identity, personal pond, session', async () => {
const { state, nonce, challenge, stateCookie, authorizeUrl } = await beginLogin();
expect(authorizeUrl.searchParams.get('code_challenge_method')).toBe('S256');
expect(authorizeUrl.searchParams.get('client_id')).toBe('dorfteich-test');
nextIdToken = () =>
mintIdToken({
iss: issuer,
aud: 'dorfteich-test',
sub: `subject-${suffix}`,
nonce,
email: `nadia-${suffix}@idp.example`,
email_verified: true,
preferred_username: `nadia-${suffix}`,
name: 'Nadia IdP',
});
const res = await callback(state, stateCookie);
expect(res.status).toBe(302);
expect(redirectTarget(res)).toMatch(/\/$/);
const session = sessionCookieOf(res);
expect(session).toContain('dt_session=');
// PKCE: the verifier travelled to the token endpoint and matches the
// challenge from the authorize redirect.
expect(lastTokenRequest?.get('grant_type')).toBe('authorization_code');
const verifier = lastTokenRequest?.get('code_verifier');
expect(verifier).toBeTruthy();
const { createHash } = await import('node:crypto');
expect(createHash('sha256').update(verifier!).digest('base64url')).toBe(challenge);
const user = await prisma.user.findUnique({
where: { email: `nadia-${suffix}@idp.example` },
});
expect(user).toMatchObject({ status: 'ACTIVE', displayName: 'Nadia IdP' });
const identity = await prisma.userIdentity.findUnique({
where: {
provider_subject: { provider: `oidc:${issuer}`, subject: `subject-${suffix}` },
},
});
expect(identity?.userId).toBe(user!.id);
const personal = await prisma.pond.findFirst({
where: { ownerId: user!.id, type: 'PERSONAL' },
});
expect(personal).not.toBeNull();
const me = await api().get('/api/v1/auth/me').set('Cookie', session).expect(200);
expect(me.body.email).toBe(`nadia-${suffix}@idp.example`);
});
it('reuses the existing account on the next login of the same subject', async () => {
const before = await prisma.user.count({ where: { email: { contains: `${suffix}@idp` } } });
const { state, nonce, stateCookie } = await beginLogin();
nextIdToken = () =>
mintIdToken({
iss: issuer,
aud: 'dorfteich-test',
sub: `subject-${suffix}`,
nonce,
email: `nadia-${suffix}@idp.example`,
email_verified: true,
});
const res = await callback(state, stateCookie);
expect(res.status).toBe(302);
expect(redirectTarget(res)).toMatch(/\/$/);
const after = await prisma.user.count({ where: { email: { contains: `${suffix}@idp` } } });
expect(after).toBe(before);
});
it('rejects a wrong state, a foreign nonce, a bad signature, wrong issuer/audience and an expired token', async () => {
// Wrong state: cookie from one round, state from nowhere.
const first = await beginLogin();
const bad = await callback('not-the-state', first.stateCookie);
expect(redirectTarget(bad)).toContain('error=oidc_state_invalid');
const cases: {
claims: (nonce: string) => JWTPayload;
options?: { key?: CryptoKey; expired?: boolean };
}[] = [
// Foreign nonce.
{ claims: () => baseClaims('other-nonce') },
// Signature from the wrong key.
{ claims: (n) => baseClaims(n), options: { key: wrongKey } },
// Wrong issuer.
{ claims: (n) => ({ ...baseClaims(n), iss: 'https://evil.example' }) },
// Wrong audience.
{ claims: (n) => ({ ...baseClaims(n), aud: 'someone-else' }) },
// Expired.
{ claims: (n) => baseClaims(n), options: { expired: true } },
];
function baseClaims(nonce: string): JWTPayload {
return {
iss: issuer,
aud: 'dorfteich-test',
sub: `reject-${suffix}`,
nonce,
email: `reject-${suffix}@idp.example`,
email_verified: true,
};
}
for (const testCase of cases) {
const { state, nonce, stateCookie } = await beginLogin();
nextIdToken = () => mintIdToken(testCase.claims(nonce), testCase.options);
const res = await callback(state, stateCookie);
expect(redirectTarget(res)).toContain('error=oidc_token_invalid');
}
// None of the rejected attempts created anything.
expect(
await prisma.user.findUnique({ where: { email: `reject-${suffix}@idp.example` } }),
).toBeNull();
});
it('refuses to adopt an existing local account by e-mail — and links it via the explicit flow', async () => {
const users = app.get(UsersService);
const password = 'lokales konto 123';
const local = await users.createUser({
username: `local-${suffix}`,
email: `local-${suffix}@idp.example`,
displayName: 'Local User',
password,
locale: 'en',
});
await users.markEmailVerified(local.id);
// Silent adoption refused (ADR 0021 §2 — account-takeover path).
const attempt = await beginLogin();
nextIdToken = () =>
mintIdToken({
iss: issuer,
aud: 'dorfteich-test',
sub: `local-subject-${suffix}`,
nonce: attempt.nonce,
email: `local-${suffix}@idp.example`,
email_verified: true,
});
const refused = await callback(attempt.state, attempt.stateCookie);
expect(redirectTarget(refused)).toContain('error=oidc_link_required');
// The explicit link flow, from a logged-in session.
const login = await api()
.post('/api/v1/auth/login')
.send({ usernameOrEmail: `local-${suffix}`, password })
.expect(200);
const sessionCookie = sessionCookieOf(login);
const linkRes = await api()
.get('/api/v1/auth/oidc/link')
.set('Cookie', sessionCookie)
.expect(302);
const linkUrl = new URL(linkRes.headers.location!);
const linkState = linkUrl.searchParams.get('state')!;
const linkNonce = linkUrl.searchParams.get('nonce')!;
const linkCookie = (linkRes.headers['set-cookie'] as unknown as string[])
.find((c) => c.startsWith('dt_oidc='))!
.split(';')[0]!;
nextIdToken = () =>
mintIdToken({
iss: issuer,
aud: 'dorfteich-test',
sub: `local-subject-${suffix}`,
nonce: linkNonce,
email: `local-${suffix}@idp.example`,
email_verified: true,
});
const linked = await callback(linkState, linkCookie);
expect(redirectTarget(linked)).toContain('oidc=linked');
const identity = await prisma.userIdentity.findUnique({
where: {
provider_subject: { provider: `oidc:${issuer}`, subject: `local-subject-${suffix}` },
},
});
expect(identity?.userId).toBe(local.id);
// From now on the IdP login lands in the linked account.
const again = await beginLogin();
nextIdToken = () =>
mintIdToken({
iss: issuer,
aud: 'dorfteich-test',
sub: `local-subject-${suffix}`,
nonce: again.nonce,
email: `local-${suffix}@idp.example`,
email_verified: true,
});
const res = await callback(again.state, again.stateCookie);
const session = sessionCookieOf(res);
const me = await api().get('/api/v1/auth/me').set('Cookie', session).expect(200);
expect(me.body.id).toBe(local.id);
});
});

View File

@ -1,359 +0,0 @@
import { createHash, randomBytes } from 'node:crypto';
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common';
import { slugify } from '@dorfteich/shared';
import { deriveTokenKey } from '@dorfteich/shared/token-crypto';
import { User } from '@prisma/client';
import { SignJWT, createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose';
import { PinoLogger } from 'nestjs-pino';
import { AuditService } from '../audit/audit.service';
import { AppConfig } from '../config/app-config.service';
import { PondsService } from '../ponds/ponds.service';
import { PrismaService } from '../prisma/prisma.service';
import { UsersService } from '../users/users.service';
import { ClaimMappingService } from './claim-mapping.service';
import { SessionsService } from './sessions.service';
/** The state cookie's signed payload lives this long ample for one
* round-trip to the IdP's login form. */
const STATE_TTL_SECONDS = 10 * 60;
/** Explicit asymmetric allowlist for ID-token signatures (no HS*, no
* `none`): Keycloak's default RS256 plus the common EC profile. */
const ID_TOKEN_ALGORITHMS = ['RS256', 'ES256'];
/** What we mint into the signed, HttpOnly state cookie before redirecting
* to the IdP: CSRF binding (`state`), replay binding (`nonce`), the PKCE
* verifier, and for the deliberate account-linking flow the session
* user the new identity must attach to. */
interface OidcStateClaims extends JWTPayload {
state: string;
nonce: string;
codeVerifier: string;
linkUserId?: string;
}
interface DiscoveryDocument {
issuer: string;
authorization_endpoint: string;
token_endpoint: string;
jwks_uri: string;
end_session_endpoint?: string;
}
/**
* OIDC Authorization Code with PKCE (issue #214, ADR 0021). Deliberately
* built on `jose` (the vetted library from #188) plus `fetch` no new
* dependency enters the supply chain for a security base function.
* Discovery-based: nothing here is Keycloak-specific; Keycloak is the
* reference IdP the flow is verified against (procedure in
* `docs/architecture/security.md` §External authentication).
*
* Identity linking follows ADR 0021 §2: `provider = "oidc:<issuer>"`,
* `subject` from the token. An existing local account is NEVER linked
* silently by e-mail that would be an account-takeover path. Instead the
* login is refused with `oidc_link_required`, and the user (logged in
* locally) links explicitly via `GET /auth/oidc/link`.
*/
@Injectable()
export class OidcService {
private discoveryCache: DiscoveryDocument | null = null;
private jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
constructor(
private readonly prisma: PrismaService,
private readonly users: UsersService,
private readonly sessions: SessionsService,
private readonly ponds: PondsService,
private readonly claimMapping: ClaimMappingService,
private readonly audit: AuditService,
private readonly config: AppConfig,
private readonly logger: PinoLogger,
) {
this.logger.setContext(OidcService.name);
}
/** OIDC is a deploy-level decision (ADR 0021): enabled iff issuer and
* client id are configured. */
get enabled(): boolean {
return Boolean(this.config.env.OIDC_ISSUER && this.config.env.OIDC_CLIENT_ID);
}
get providerLabel(): string {
return this.config.env.OIDC_PROVIDER_LABEL;
}
private get issuer(): string {
return this.config.env.OIDC_ISSUER!;
}
private get clientId(): string {
return this.config.env.OIDC_CLIENT_ID!;
}
private get redirectUri(): string {
return `${this.config.env.APP_BASE_URL}/api/v1/auth/oidc/callback`;
}
/** The identity provider key: one issuer, one provider namespace. */
private get provider(): string {
return `oidc:${this.issuer}`;
}
assertEnabled(): void {
// 404, not 403: consistent with the instance switches (`api.enabled`
// et al.) — an unconfigured surface hides its existence.
if (!this.enabled) throw new NotFoundException();
}
private async discover(): Promise<DiscoveryDocument> {
if (this.discoveryCache) return this.discoveryCache;
const url = `${this.issuer.replace(/\/$/, '')}/.well-known/openid-configuration`;
const response = await fetch(url).catch(() => null);
if (!response?.ok) {
throw new ServiceUnavailableException({ code: 'oidc_discovery_failed' });
}
const doc = (await response.json()) as DiscoveryDocument;
if (doc.issuer !== this.issuer) {
// RFC 8414 §3.3: the advertised issuer must match the configured one.
throw new ServiceUnavailableException({ code: 'oidc_discovery_failed' });
}
this.discoveryCache = doc;
this.jwks = createRemoteJWKSet(new URL(doc.jwks_uri));
return doc;
}
/** Builds the IdP redirect plus the signed state-cookie value. */
async beginLogin(linkUserId?: string): Promise<{ url: string; stateToken: string }> {
const doc = await this.discover();
const state = randomBytes(24).toString('base64url');
const nonce = randomBytes(24).toString('base64url');
const codeVerifier = randomBytes(48).toString('base64url');
const challenge = createHash('sha256').update(codeVerifier).digest('base64url');
const url = new URL(doc.authorization_endpoint);
url.searchParams.set('response_type', 'code');
url.searchParams.set('client_id', this.clientId);
url.searchParams.set('redirect_uri', this.redirectUri);
url.searchParams.set('scope', this.config.env.OIDC_SCOPES);
url.searchParams.set('state', state);
url.searchParams.set('nonce', nonce);
url.searchParams.set('code_challenge', challenge);
url.searchParams.set('code_challenge_method', 'S256');
const now = Math.floor(Date.now() / 1000);
const claims: OidcStateClaims = { state, nonce, codeVerifier };
if (linkUserId) claims.linkUserId = linkUserId;
const stateToken = await new SignJWT({ ...claims })
.setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
.setIssuedAt(now)
.setExpirationTime(now + STATE_TTL_SECONDS)
.sign(deriveTokenKey(this.config.env.COLLAB_TOKEN_SECRET, 'oidc-state'));
return { url: url.toString(), stateToken };
}
private async verifyStateToken(stateToken: string): Promise<OidcStateClaims> {
try {
const { payload } = await jwtVerify(
stateToken,
deriveTokenKey(this.config.env.COLLAB_TOKEN_SECRET, 'oidc-state'),
{ algorithms: ['HS256'] },
);
if (typeof payload.state !== 'string' || typeof payload.nonce !== 'string') throw new Error();
if (typeof payload.codeVerifier !== 'string') throw new Error();
return payload as OidcStateClaims;
} catch {
throw new BadRequestException({ code: 'oidc_state_invalid' });
}
}
/**
* The callback half: state check, code exchange, ID-token validation
* (signature via JWKS, issuer, audience, expiry and the nonce binding),
* then identity resolution. Returns the session token to set plus where
* the SPA should land.
*/
async completeLogin(
code: string,
state: string,
stateToken: string,
userAgent: string | undefined,
): Promise<{ sessionToken: string | null; linked: boolean }> {
const doc = await this.discover();
const stored = await this.verifyStateToken(stateToken);
if (state !== stored.state) {
throw new BadRequestException({ code: 'oidc_state_invalid' });
}
const body = new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: this.redirectUri,
client_id: this.clientId,
code_verifier: stored.codeVerifier,
});
// Confidential client: secret via client_secret_post (Keycloak default
// accepts it); a public client authenticates with PKCE alone.
if (this.config.env.OIDC_CLIENT_SECRET) {
body.set('client_secret', this.config.env.OIDC_CLIENT_SECRET);
}
const tokenResponse = await fetch(doc.token_endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
}).catch(() => null);
if (!tokenResponse?.ok) {
this.logger.warn({ status: tokenResponse?.status }, 'oidc: code exchange failed');
throw new BadRequestException({ code: 'oidc_exchange_failed' });
}
const tokens = (await tokenResponse.json()) as { id_token?: string };
if (!tokens.id_token) throw new BadRequestException({ code: 'oidc_exchange_failed' });
let payload: JWTPayload;
try {
({ payload } = await jwtVerify(tokens.id_token, this.jwks!, {
issuer: this.issuer,
audience: this.clientId,
algorithms: ID_TOKEN_ALGORITHMS,
}));
} catch (error) {
this.logger.warn({ err: error }, 'oidc: id token rejected');
throw new BadRequestException({ code: 'oidc_token_invalid' });
}
if (typeof payload.nonce !== 'string' || payload.nonce !== stored.nonce) {
throw new BadRequestException({ code: 'oidc_token_invalid' });
}
if (typeof payload.sub !== 'string' || payload.sub.length === 0) {
throw new BadRequestException({ code: 'oidc_token_invalid' });
}
if (stored.linkUserId) {
await this.linkIdentity(stored.linkUserId, payload.sub);
return { sessionToken: null, linked: true };
}
const user = await this.resolveUser(payload);
if (user.status === 'DISABLED') {
throw new BadRequestException({ code: 'account_disabled' });
}
// Claim mapping (issue #217): reconcile mapped grants and the managed
// site-admin flag against this login's fresh claims — before the
// session exists, so the first request already sees the new state.
await this.claimMapping.apply(user, payload);
const sessionToken = await this.sessions.create(user.id, userAgent);
await this.prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() } });
await this.audit.record({
action: 'auth.login_succeeded',
actorId: user.id,
details: { provider: this.provider },
});
return { sessionToken, linked: false };
}
/** The deliberate linking rule (ADR 0021 §2): only an authenticated user
* links an IdP identity to their own account never automatic by mail. */
private async linkIdentity(userId: string, subject: string): Promise<void> {
const existing = await this.prisma.userIdentity.findUnique({
where: { provider_subject: { provider: this.provider, subject } },
});
if (existing && existing.userId !== userId) {
throw new ConflictException({ code: 'oidc_identity_taken' });
}
if (!existing) {
await this.prisma.userIdentity.create({
data: { userId, provider: this.provider, subject },
});
await this.audit.record({
action: 'auth.identity_linked',
actorId: userId,
details: { provider: this.provider },
});
}
}
private async resolveUser(payload: JWTPayload): Promise<User> {
const identity = await this.prisma.userIdentity.findUnique({
where: { provider_subject: { provider: this.provider, subject: payload.sub! } },
});
if (identity) {
const user = await this.users.findById(identity.userId);
if (!user) throw new BadRequestException({ code: 'oidc_token_invalid' });
return user;
}
// First login of this subject: just-in-time creation. The IdP owns the
// account lifecycle (ADR 0021), so the account arrives ACTIVE and
// mail-verified — provided the IdP says the address is verified.
const email = typeof payload.email === 'string' ? payload.email.toLowerCase() : null;
if (!email) throw new BadRequestException({ code: 'oidc_email_missing' });
if (payload.email_verified === false) {
throw new BadRequestException({ code: 'oidc_email_unverified' });
}
const clash = await this.users.findByEmail(email);
if (clash) {
// The documented refusal: the local owner of this address must link
// explicitly (GET /auth/oidc/link) — silent adoption would be an
// account-takeover path (ADR 0021 §2).
throw new ConflictException({ code: 'oidc_link_required' });
}
const preferred =
typeof payload.preferred_username === 'string' && payload.preferred_username
? payload.preferred_username
: email.split('@')[0]!;
const displayName =
typeof payload.name === 'string' && payload.name.trim() ? payload.name.trim() : preferred;
const username = await this.uniqueUsername(slugify(preferred) || 'user');
const user = await this.prisma.$transaction(async (tx) => {
const created = await tx.user.create({
data: {
username,
email,
displayName,
locale: 'en',
status: 'ACTIVE',
emailVerifiedAt: new Date(),
},
});
await tx.userIdentity.create({
data: { userId: created.id, provider: this.provider, subject: payload.sub! },
});
return created;
});
// Same invariant as e-mail verification: every active account owns a
// personal pond (idempotent).
await this.ponds.ensurePersonalPond(user);
await this.audit.record({
action: 'auth.signup',
actorId: user.id,
details: { provider: this.provider },
});
return user;
}
private async uniqueUsername(base: string): Promise<string> {
const taken = new Set(
(
await this.prisma.user.findMany({
where: { OR: [{ username: base }, { username: { startsWith: `${base}-` } }] },
select: { username: true },
})
).map((row) => row.username),
);
if (!taken.has(base)) return base;
for (let n = 2; ; n += 1) {
const candidate = `${base}-${n}`;
if (!taken.has(candidate)) return candidate;
}
}
}

View File

@ -1,157 +0,0 @@
import { INestApplication } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createTestApp, sessionCookieOf } from '../testing/test-app';
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service';
const HEADER = 'x-auth-user';
/**
* Trusted reverse-proxy authentication (issue #215, ADR 0021): off by
* default (header fully ignored), identity only from a trusted TCP peer, a
* spoofing peer rejected AND audited, no privilege escalation past a
* riding-along session cookie, and the mTLS variant mapping a forwarded
* certificate DN attribute.
*/
describe.skipIf(!hasTestDb)('trusted-proxy identity (e2e, issue #215)', () => {
let prisma: PrismaClient;
const suffix = uniqueSuffix();
const password = 'proxy identitaet 123';
const PROXY_ENV = ['AUTH_PROXY_HEADER', 'AUTH_PROXY_TRUSTED_PEERS', 'AUTH_PROXY_MODE'] as const;
async function bootApp(env: Partial<Record<(typeof PROXY_ENV)[number], string>>) {
for (const key of PROXY_ENV) delete process.env[key];
Object.assign(process.env, env);
return createTestApp();
}
async function makeUser(app: INestApplication, handle: string) {
const users = app.get(UsersService);
const user = await users.createUser({
username: `${handle}-${suffix}`,
email: `${handle}-${suffix}@example.test`,
displayName: handle,
password,
locale: 'en',
});
await users.markEmailVerified(user.id);
return user;
}
beforeAll(async () => {
prisma = createTestPrisma();
await prisma.rateLimit.deleteMany({});
});
afterAll(async () => {
for (const key of PROXY_ENV) delete process.env[key];
await prisma.auditEntry.deleteMany({ where: { action: 'auth.proxy_rejected' } });
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
await prisma.$disconnect();
});
it('ignores the header entirely while the feature is off', async () => {
const app = await bootApp({});
try {
await makeUser(app, 'off');
await request(app.getHttpServer())
.get('/api/v1/auth/me')
.set(HEADER, `off-${suffix}`)
.expect(401);
} finally {
await app.close();
}
});
it('authenticates a trusted peer, maps by username, and never escalates past a session cookie', async () => {
const app = await bootApp({
AUTH_PROXY_HEADER: HEADER,
AUTH_PROXY_TRUSTED_PEERS: '127.0.0.1',
});
try {
const alice = await makeUser(app, 'alice');
const bob = await makeUser(app, 'bob');
const api = () => request(app.getHttpServer());
const me = await api().get('/api/v1/auth/me').set(HEADER, alice.username).expect(200);
expect(me.body.id).toBe(alice.id);
// Unknown identity: authenticated by nobody.
await api().get('/api/v1/auth/me').set(HEADER, `ghost-${suffix}`).expect(401);
// A session cookie riding along never escalates beyond the header
// identity: bob's cookie plus alice's header acts as alice.
const login = await api()
.post('/api/v1/auth/login')
.send({ usernameOrEmail: bob.username, password })
.expect(200);
const both = await api()
.get('/api/v1/auth/me')
.set('Cookie', sessionCookieOf(login))
.set(HEADER, alice.username)
.expect(200);
expect(both.body.id).toBe(alice.id);
// Without the header the same cookie still works normally.
const cookieOnly = await api()
.get('/api/v1/auth/me')
.set('Cookie', sessionCookieOf(login))
.expect(200);
expect(cookieOnly.body.id).toBe(bob.id);
} finally {
await app.close();
}
});
it('rejects and audits the header from an untrusted peer — even with a valid session', async () => {
const app = await bootApp({
AUTH_PROXY_HEADER: HEADER,
AUTH_PROXY_TRUSTED_PEERS: '203.0.113.9',
});
try {
const carol = await makeUser(app, 'carol');
const api = () => request(app.getHttpServer());
await api().get('/api/v1/auth/me').set(HEADER, carol.username).expect(403);
const audit = await prisma.auditEntry.findFirst({
where: { action: 'auth.proxy_rejected' },
orderBy: { at: 'desc' },
});
expect(audit?.details).toMatchObject({ header: HEADER });
const login = await api()
.post('/api/v1/auth/login')
.send({ usernameOrEmail: carol.username, password })
.expect(200);
// The spoofed header poisons the request even alongside a valid
// cookie — rejecting is safer than guessing which identity wins.
await api()
.get('/api/v1/auth/me')
.set('Cookie', sessionCookieOf(login))
.set(HEADER, carol.username)
.expect(403);
} finally {
await app.close();
}
});
it('maps the configured DN attribute in mtls-dn mode', async () => {
const app = await bootApp({
AUTH_PROXY_HEADER: HEADER,
AUTH_PROXY_TRUSTED_PEERS: '127.0.0.1',
AUTH_PROXY_MODE: 'mtls-dn',
});
try {
const dana = await makeUser(app, 'dana');
const me = await request(app.getHttpServer())
.get('/api/v1/auth/me')
.set(HEADER, `CN=${dana.username},OU=unit,O=example`)
.expect(200);
expect(me.body.id).toBe(dana.id);
} finally {
await app.close();
}
});
});

View File

@ -1,102 +0,0 @@
import { ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common';
import { User } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino';
import { AuditService } from '../audit/audit.service';
import { AppConfig } from '../config/app-config.service';
import { UsersService } from '../users/users.service';
import type { AuthedRequest } from './auth.guard';
/**
* Trusted reverse-proxy authentication (issue #215, ADR 0021): the
* perimeter (proxy or mTLS terminator) authenticates and forwards the
* identity in a configured header; the application trusts that header ONLY
* when the request's TCP peer is on the configured allowlist.
*
* The trust boundary, stated plainly (security.md §External
* authentication): everything upstream of the configured peers is the
* operator's responsibility; the application's contribution is that the
* header is worthless from anywhere else a header from an untrusted peer
* rejects the request outright and lands in the audit trail
* (`auth.proxy_rejected`), because someone is attempting a spoof.
*
* Deliberately NO just-in-time creation here: the header carries no
* verified e-mail, so accounts must already exist (the IdP/OIDC path or an
* admin creates them) and are mapped by username or e-mail explicit
* configuration, never guessed.
*/
@Injectable()
export class ProxyIdentityService {
constructor(
private readonly users: UsersService,
private readonly audit: AuditService,
private readonly config: AppConfig,
private readonly logger: PinoLogger,
) {
this.logger.setContext(ProxyIdentityService.name);
}
/** Enabled only with BOTH the header name and a non-empty allowlist. */
get enabled(): boolean {
return Boolean(
this.config.env.AUTH_PROXY_HEADER && this.config.env.AUTH_PROXY_TRUSTED_PEERS.length > 0,
);
}
/**
* Resolves the request's proxy identity, or null when the feature is off
* or the header is absent. Throws 403 (audited) for an untrusted peer
* carrying the header, 401 for an unknown identity.
*/
async resolve(request: AuthedRequest): Promise<User | null> {
if (!this.enabled) return null;
const headerName = this.config.env.AUTH_PROXY_HEADER!.toLowerCase();
const raw = request.headers[headerName];
const value = Array.isArray(raw) ? raw[0] : raw;
if (!value) return null;
const peer = normalizePeer(request.socket.remoteAddress ?? '');
const trusted = this.config.env.AUTH_PROXY_TRUSTED_PEERS.map(normalizePeer);
if (!trusted.includes(peer)) {
// A spoof attempt, not a misconfiguration: reject and evidence it.
await this.audit.record({
action: 'auth.proxy_rejected',
details: { peer, header: headerName },
});
throw new ForbiddenException({ code: 'proxy_peer_untrusted' });
}
const identity = this.extractIdentity(value);
if (!identity) throw new UnauthorizedException({ code: 'proxy_identity_unknown' });
const user =
this.config.env.AUTH_PROXY_MAP === 'email'
? await this.users.findByEmail(identity)
: await this.users.findByUsernameOrEmail(identity);
if (!user || user.status !== 'ACTIVE') {
throw new UnauthorizedException({ code: 'proxy_identity_unknown' });
}
return user;
}
/** `plain`: the value is the identity. `mtls-dn`: the value is a client
* certificate subject DN as forwarded by the TLS terminator; the identity
* is the configured attribute (default CN). */
private extractIdentity(value: string): string | null {
if (this.config.env.AUTH_PROXY_MODE === 'plain') return value.trim() || null;
const attribute = this.config.env.AUTH_PROXY_DN_ATTRIBUTE.toLowerCase();
for (const part of value.split(/[,/]/)) {
const [key, ...rest] = part.split('=');
if (key?.trim().toLowerCase() === attribute) {
const extracted = rest.join('=').trim();
return extracted || null;
}
}
return null;
}
}
/** `::ffff:127.0.0.1` and `127.0.0.1` are the same peer. */
function normalizePeer(address: string): string {
return address.replace(/^::ffff:/i, '').trim();
}

View File

@ -1,102 +0,0 @@
import { afterAll, describe, expect, it } from 'vitest';
import { AppConfig } from '../config/app-config.service';
import { PrismaService } from '../prisma/prisma.service';
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service';
import { SessionsService, hashSessionToken } from './sessions.service';
const HOUR = 60 * 60 * 1000;
/** A config stub with just the session bounds (absolute 2 h, idle 1 h). */
function configWith(absoluteHours: number, idleHours: number): AppConfig {
return {
env: { SESSION_ABSOLUTE_HOURS: absoluteHours, SESSION_IDLE_HOURS: idleHours },
} as AppConfig;
}
describe.skipIf(!hasTestDb)('SessionsService bounds (database, issue #190)', () => {
const prisma = hasTestDb ? (createTestPrisma() as unknown as PrismaService) : null!;
const sessions = hasTestDb ? new SessionsService(prisma, configWith(2, 1)) : null!;
const suffix = uniqueSuffix();
let userId: string;
async function makeUser(): Promise<string> {
if (userId) return userId;
const users = new UsersService(prisma);
const user = await users.createUser({
username: `sess-${suffix}`,
email: `sess-${suffix}@example.org`,
displayName: 'Sess Test',
password: 'session bounds pass 1',
locale: 'en',
});
userId = user.id;
return userId;
}
/** Creates a session and rewrites its timestamps to simulate age. */
async function sessionAgedTo(expiresInMs: number, lastSeenAgoMs: number): Promise<string> {
const raw = await sessions.create(await makeUser(), 'test-agent');
await prisma.session.update({
where: { id: hashSessionToken(raw) },
data: {
expiresAt: new Date(Date.now() + expiresInMs),
lastSeenAt: new Date(Date.now() - lastSeenAgoMs),
},
});
return raw;
}
afterAll(async () => {
if (!hasTestDb) return;
await prisma.session.deleteMany({ where: { userId } });
await prisma.userIdentity.deleteMany({ where: { userId } });
await prisma.user.deleteMany({ where: { id: userId } });
await prisma.$disconnect();
});
it('sets the absolute bound at creation', async () => {
const raw = await sessions.create(await makeUser(), 'test-agent');
const row = await prisma.session.findUniqueOrThrow({ where: { id: hashSessionToken(raw) } });
const msLeft = row.expiresAt.getTime() - Date.now();
expect(msLeft).toBeGreaterThan(1.9 * HOUR);
expect(msLeft).toBeLessThanOrEqual(2 * HOUR);
await sessions.destroyByRawToken(raw);
});
it('rejects a session past its absolute bound and removes the row', async () => {
const raw = await sessionAgedTo(-1000, 0);
expect(await sessions.validate(raw)).toBeNull();
expect(await prisma.session.findUnique({ where: { id: hashSessionToken(raw) } })).toBeNull();
});
it('rejects a session idle past the idle bound even before its absolute bound', async () => {
const raw = await sessionAgedTo(HOUR, 1.5 * HOUR);
expect(await sessions.validate(raw)).toBeNull();
expect(await prisma.session.findUnique({ where: { id: hashSessionToken(raw) } })).toBeNull();
});
it('renews the idle bound on active use but never extends the absolute bound', async () => {
const raw = await sessionAgedTo(HOUR, 0.5 * HOUR);
const before = await prisma.session.findUniqueOrThrow({
where: { id: hashSessionToken(raw) },
});
expect(await sessions.validate(raw)).not.toBeNull();
const after = await prisma.session.findUniqueOrThrow({ where: { id: hashSessionToken(raw) } });
expect(after.lastSeenAt.getTime()).toBeGreaterThan(before.lastSeenAt.getTime());
expect(after.expiresAt.getTime()).toBe(before.expiresAt.getTime());
await sessions.destroyByRawToken(raw);
});
it('hides idle-expired sessions from the session list', async () => {
const live = await sessionAgedTo(HOUR, 0);
const idle = await sessionAgedTo(HOUR, 1.5 * HOUR);
const listed = await sessions.listForUser(userId);
const ids = listed.map((s) => s.id);
expect(ids).toContain(hashSessionToken(live));
expect(ids).not.toContain(hashSessionToken(idle));
await sessions.destroyByRawToken(live);
await sessions.destroyByRawToken(idle);
});
});

View File

@ -3,52 +3,24 @@ import { createHash, randomBytes } from 'node:crypto';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Session, User } from '@prisma/client'; import { Session, User } from '@prisma/client';
import type { ApiEnv } from '@dorfteich/shared';
import { AppConfig } from '../config/app-config.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // sliding 30 days
const REFRESH_AT_MOST_EVERY_MS = 60 * 60 * 1000; // avoid write storms
export interface ValidatedSession { export interface ValidatedSession {
session: Session; session: Session;
user: User; user: User;
} }
/** The absolute session bound — also the cookie `maxAge` (auth.guard.ts). */
export function sessionAbsoluteMs(env: ApiEnv): number {
return env.SESSION_ABSOLUTE_HOURS * 60 * 60 * 1000;
}
/** /**
* Opaque server-side sessions (ADR 0007). The cookie value is 32 random * Opaque server-side sessions (ADR 0007). The cookie value is 32 random
* bytes; the database stores only its SHA-256 hash as the row id, so a * bytes; the database stores only its SHA-256 hash as the row id, so a
* database leak cannot be replayed as cookies. * database leak cannot be replayed as cookies.
*
* Two configurable bounds (issue #190): `expiresAt` is the ABSOLUTE limit,
* set once at creation and never extended; the IDLE limit is enforced
* server-side against `lastSeenAt`, which active use renews. The old
* sliding 30-day expiry is gone activity keeps a session alive only up
* to the absolute bound.
*/ */
@Injectable() @Injectable()
export class SessionsService { export class SessionsService {
constructor( constructor(private readonly prisma: PrismaService) {}
private readonly prisma: PrismaService,
private readonly config: AppConfig,
) {}
private absoluteMs(): number {
return sessionAbsoluteMs(this.config.env);
}
private idleMs(): number {
// An idle bound above the absolute one would never fire anyway.
return Math.min(this.config.env.SESSION_IDLE_HOURS * 60 * 60 * 1000, this.absoluteMs());
}
/** `lastSeenAt` write throttle: fine-grained enough for the idle bound. */
private refreshAtMostEveryMs(): number {
return Math.min(60 * 60 * 1000, Math.floor(this.idleMs() / 10));
}
async create(userId: string, userAgent: string | undefined): Promise<string> { async create(userId: string, userAgent: string | undefined): Promise<string> {
const raw = randomBytes(32).toString('base64url'); const raw = randomBytes(32).toString('base64url');
@ -56,7 +28,7 @@ export class SessionsService {
data: { data: {
id: hashSessionToken(raw), id: hashSessionToken(raw),
userId, userId,
expiresAt: new Date(Date.now() + this.absoluteMs()), expiresAt: new Date(Date.now() + SESSION_TTL_MS),
userAgent: summarizeUserAgent(userAgent), userAgent: summarizeUserAgent(userAgent),
}, },
}); });
@ -68,32 +40,20 @@ export class SessionsService {
where: { id: hashSessionToken(raw) }, where: { id: hashSessionToken(raw) },
include: { user: true }, include: { user: true },
}); });
if (!session) return null; if (!session || session.expiresAt <= new Date()) return null;
const now = Date.now();
const idleExpired = now - session.lastSeenAt.getTime() >= this.idleMs();
if (session.expiresAt.getTime() <= now || idleExpired) {
// Expired either way — remove the row so the session list stays truthful.
await this.prisma.session.deleteMany({ where: { id: session.id } });
return null;
}
if (session.user.status === 'DISABLED') return null; if (session.user.status === 'DISABLED') return null;
// Renew the idle bound (throttled against write storms); the absolute // Sliding expiration, refreshed at most once per hour.
// `expiresAt` is deliberately never touched. if (Date.now() - session.lastSeenAt.getTime() > REFRESH_AT_MOST_EVERY_MS) {
if (now - session.lastSeenAt.getTime() > this.refreshAtMostEveryMs()) {
await this.prisma.session.update({ await this.prisma.session.update({
where: { id: session.id }, where: { id: session.id },
data: { lastSeenAt: new Date(now) }, data: { lastSeenAt: new Date(), expiresAt: new Date(Date.now() + SESSION_TTL_MS) },
}); });
} }
const { user, ...bare } = session; const { user, ...bare } = session;
return { session: bare as Session, user }; return { session: bare as Session, user };
} }
private idleCutoff(now: number): Date {
return new Date(now - this.idleMs());
}
async destroyByRawToken(raw: string): Promise<void> { async destroyByRawToken(raw: string): Promise<void> {
await this.prisma.session.deleteMany({ where: { id: hashSessionToken(raw) } }); await this.prisma.session.deleteMany({ where: { id: hashSessionToken(raw) } });
} }
@ -113,13 +73,8 @@ export class SessionsService {
} }
listForUser(userId: string): Promise<Session[]> { listForUser(userId: string): Promise<Session[]> {
// Both bounds, so an idle-expired session never shows as active.
return this.prisma.session.findMany({ return this.prisma.session.findMany({
where: { where: { userId, expiresAt: { gt: new Date() } },
userId,
expiresAt: { gt: new Date() },
lastSeenAt: { gt: this.idleCutoff(Date.now()) },
},
orderBy: { lastSeenAt: 'desc' }, orderBy: { lastSeenAt: 'desc' },
}); });
} }

View File

@ -1,12 +1,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { import type { BackupConnectionTestResult, BackupSettingsView } from '@dorfteich/shared';
isBackupTargetAllowed,
type BackupConnectionTestResult,
type BackupSettingsView,
} from '@dorfteich/shared';
import { webdavCheck, type WebDavTarget } from '@dorfteich/shared/webdav'; import { webdavCheck, type WebDavTarget } from '@dorfteich/shared/webdav';
import { AppConfig } from '../config/app-config.service';
import { SecretStoreService } from '../config/secret-store.service'; import { SecretStoreService } from '../config/secret-store.service';
import { InstanceSettingsService } from '../settings/instance-settings.service'; import { InstanceSettingsService } from '../settings/instance-settings.service';
@ -24,27 +19,12 @@ export class BackupTargetService {
constructor( constructor(
private readonly settings: InstanceSettingsService, private readonly settings: InstanceSettingsService,
private readonly secretStore: SecretStoreService, private readonly secretStore: SecretStoreService,
private readonly config: AppConfig,
) {} ) {}
/** Deploy-level allowlist (issue #192): empty = remote targets disabled. */
allowlist(): string[] {
return this.config.env.BACKUP_ALLOWED_TARGETS;
}
remoteAllowed(): boolean {
return this.allowlist().length > 0;
}
targetAllowed(target: string): boolean {
return isBackupTargetAllowed(this.allowlist(), target);
}
async settingsView(): Promise<BackupSettingsView> { async settingsView(): Promise<BackupSettingsView> {
return { return {
localRetentionDays: await this.settings.get('backup.localRetentionDays'), localRetentionDays: await this.settings.get('backup.localRetentionDays'),
remoteRetentionDays: await this.settings.get('backup.remoteRetentionDays'), remoteRetentionDays: await this.settings.get('backup.remoteRetentionDays'),
remoteTargets: { allowed: this.remoteAllowed(), allowlist: this.allowlist() },
nextcloud: { nextcloud: {
enabled: await this.settings.get('backup.nextcloud.enabled'), enabled: await this.settings.get('backup.nextcloud.enabled'),
baseUrl: await this.settings.get('backup.nextcloud.baseUrl'), baseUrl: await this.settings.get('backup.nextcloud.baseUrl'),
@ -65,9 +45,6 @@ export class BackupTargetService {
const password = this.storedPassword(); const password = this.storedPassword();
const { enabled, baseUrl, username, folder } = view.nextcloud; const { enabled, baseUrl, username, folder } = view.nextcloud;
if (!enabled || !baseUrl || !username || !password) return null; if (!enabled || !baseUrl || !username || !password) return null;
// Policy backstop (issue #192): a configured target outside the deploy
// allowlist behaves like no target at all.
if (!this.targetAllowed(baseUrl)) return null;
return { baseUrl, username, password, folder }; return { baseUrl, username, password, folder };
} }

View File

@ -1,50 +0,0 @@
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { Injectable } from '@nestjs/common';
import { AppConfig } from '../config/app-config.service';
/**
* Filesystem binding for branding assets (issue #306; pond overrides #307).
*
* One flat directory of PNGs named by a caller-supplied key
* (`instance-logo-light`, later `pond-<id>-favicon-32`). Flat because there
* are a handful of files per instance and the backup archives the directory
* as a whole a tree would buy nothing and cost a traversal question.
*
* The key is constrained here rather than trusted from the route: it is the
* only thing between a request parameter and a path.
*/
@Injectable()
export class BrandingStorageService {
constructor(private readonly config: AppConfig) {}
/** Lowercase, digits and dashes only no dot, so no `..`, and no slash,
* so the file cannot leave the directory whatever a caller sends. */
private pathFor(key: string): string {
if (!/^[a-z0-9-]{1,120}$/.test(key)) throw new Error(`invalid branding key: ${key}`);
return join(this.config.env.BRANDING_DIR, `${key}.png`);
}
async save(key: string, bytes: Buffer): Promise<void> {
await mkdir(this.config.env.BRANDING_DIR, { recursive: true });
await writeFile(this.pathFor(key), bytes);
}
/** The bytes, or null when the file is absent a missing asset is a normal
* state here (nothing uploaded, or metadata and disk drifted after a
* partial restore), and every caller has a fallback. */
async read(key: string): Promise<Buffer | null> {
try {
return await readFile(this.pathFor(key));
} catch {
return null;
}
}
/** Idempotent: removing what is not there is success. */
async remove(key: string): Promise<void> {
await rm(this.pathFor(key), { force: true });
}
}

View File

@ -1,253 +0,0 @@
import {
BadRequestException,
Controller,
Delete,
Get,
NotFoundException,
Param,
Post,
Query,
Req,
Res,
UploadedFiles,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { AnyFilesInterceptor } from '@nestjs/platform-express';
import {
BrandingView,
FAVICON_SIZES,
FaviconSize,
LOGO_VARIANTS,
LogoVariant,
MAX_BRANDING_BYTES,
PondBranding,
} from '@dorfteich/shared';
import type { Response } from 'express';
import { SiteAdminGuard } from '../admin/site-admin.guard';
import { AuthedRequest, Public } from '../auth/auth.guard';
import { RequiresPondRole } from '../permissions/permission.decorators';
import { PrismaService } from '../prisma/prisma.service';
import { BrandingService } from './branding.service';
function parseVariant(value: unknown): LogoVariant {
if (!LOGO_VARIANTS.includes(value as LogoVariant)) {
throw new BadRequestException({ code: 'bad_request' });
}
return value as LogoVariant;
}
/**
* Public branding surface (issue #306).
*
* Unauthenticated by design and worth stating plainly in the admin UI: the
* login screen carries the branding and the browser fetches the favicon before
* anyone signs in, so an operator's logo IS visible to anonymous visitors.
*/
@Controller('branding')
export class BrandingController {
constructor(private readonly branding: BrandingService) {}
@Public()
@Get()
view(): Promise<BrandingView> {
return this.branding.view();
}
@Public()
@Get('logo')
async logo(
@Query('variant') variant: string | undefined,
@Query('pond') pondId: string | undefined,
@Res() res: Response,
): Promise<void> {
const wanted = parseVariant(variant ?? 'light');
// A pond scope serves the pond's own bytes and nothing else: the caller
// already resolved WHICH level applies (`resolveBranding`), so silently
// falling back here would mix variants across levels — exactly what #307
// forbids.
const bytes = pondId
? await this.branding.pondLogoBytes(pondId, wanted)
: await this.branding.logoBytes(wanted);
// No shipped default: without a logo the app renders the instance NAME as
// text, so an empty answer here is the honest one.
if (!bytes) {
res.status(404).json({ code: 'not_found', message: 'no logo' });
return;
}
res.setHeader('Content-Type', 'image/png');
// The caller puts the content hash in the query string, so a given URL
// never changes what it points at.
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
res.send(bytes);
}
@Public()
@Get('favicon')
async favicon(
@Query('size') size: string | undefined,
@Query('pond') pondId: string | undefined,
@Res() res: Response,
): Promise<void> {
const wanted = Number(size ?? 32);
if (!(FAVICON_SIZES as readonly number[]).includes(wanted)) {
throw new BadRequestException({ code: 'bad_request' });
}
const pondBytes = pondId
? await this.branding.pondFaviconBytes(pondId, wanted as FaviconSize)
: null;
const { bytes, uploaded } = pondBytes
? { bytes: pondBytes, uploaded: true }
: await this.branding.faviconBytes(wanted as FaviconSize);
res.setHeader('Content-Type', 'image/png');
// The `<link rel="icon">` href is a constant in index.html, so this URL
// cannot carry a hash — revalidation is the only way a replaced favicon
// ever reaches a browser that already has one.
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('ETag', `"${uploaded ? 'custom' : 'default'}-${bytes.length}"`);
res.send(bytes);
}
}
/** Site-Admin management of the instance branding (issue #306). */
@Controller('admin/branding')
@UseGuards(SiteAdminGuard)
export class BrandingAdminController {
constructor(private readonly branding: BrandingService) {}
@Post('logo')
@UseInterceptors(AnyFilesInterceptor({ limits: { fileSize: MAX_BRANDING_BYTES } }))
async setLogo(
@Query('variant') variant: string | undefined,
@Req() request: AuthedRequest,
@UploadedFiles() files: Express.Multer.File[] | undefined,
): Promise<BrandingView> {
const file = files?.find((entry) => entry.fieldname === 'file');
if (!file) throw new BadRequestException({ code: 'branding_file_missing' });
return this.branding.setLogo(request.user!, parseVariant(variant ?? 'light'), file.buffer);
}
@Delete('logo')
clearLogo(
@Query('variant') variant: string | undefined,
@Req() request: AuthedRequest,
): Promise<BrandingView> {
return this.branding.clearLogo(request.user!, parseVariant(variant ?? 'light'));
}
@Post('favicon')
@UseInterceptors(AnyFilesInterceptor({ limits: { fileSize: MAX_BRANDING_BYTES } }))
async setFavicon(
@Req() request: AuthedRequest,
@UploadedFiles() files: Express.Multer.File[] | undefined,
): Promise<BrandingView> {
// Field names are the pixel sizes the browser rendered: `png-32`, `png-180`.
const byField = new Map((files ?? []).map((file) => [file.fieldname, file.buffer]));
const collected = {} as Record<FaviconSize, Buffer>;
for (const size of FAVICON_SIZES) {
const bytes = byField.get(`png-${size}`);
if (!bytes) throw new BadRequestException({ code: 'branding_file_missing' });
collected[size] = bytes;
}
return this.branding.setFavicon(request.user!, collected);
}
@Delete('favicon')
clearFavicon(@Req() request: AuthedRequest): Promise<BrandingView> {
return this.branding.clearFavicon(request.user!);
}
}
/**
* Pond-level branding (issue #307). The uploader here is an ordinary Pond
* Admin rather than the operator, so the security rules of #306 are not
* relaxed by a single line: SVG refused, magic bytes checked server-side,
* size caps enforced, content type pinned on serving, no image parsing.
*
* 404/403 policy: a user who cannot see the pond gets 404 from the pond-role
* guard, one who can see but not administer it gets 403.
*/
@Controller('ponds/:pondId/branding')
export class PondBrandingController {
constructor(
private readonly branding: BrandingService,
private readonly prisma: PrismaService,
) {}
/** The pond row the quota is charged to. */
private async pondOf(pondId: string): Promise<{ id: string; ownerId: string }> {
const pond = await this.prisma.pond.findUnique({
where: { id: pondId },
select: { id: true, ownerId: true },
});
if (!pond) throw new NotFoundException();
return pond;
}
@Get()
@RequiresPondRole('reader', { idParam: 'pondId' })
view(@Param('pondId') pondId: string): Promise<PondBranding> {
return this.branding.pondBranding(pondId);
}
@Post('logo')
@RequiresPondRole('pond_admin', { idParam: 'pondId' })
@UseInterceptors(AnyFilesInterceptor({ limits: { fileSize: MAX_BRANDING_BYTES } }))
async setLogo(
@Param('pondId') pondId: string,
@Query('variant') variant: string | undefined,
@Req() request: AuthedRequest,
@UploadedFiles() files: Express.Multer.File[] | undefined,
): Promise<PondBranding> {
const file = files?.find((entry) => entry.fieldname === 'file');
if (!file) throw new BadRequestException({ code: 'branding_file_missing' });
return this.branding.setPondLogo(
request.user!,
await this.pondOf(pondId),
parseVariant(variant ?? 'light'),
file.buffer,
);
}
@Delete('logo')
@RequiresPondRole('pond_admin', { idParam: 'pondId' })
async clearLogo(
@Param('pondId') pondId: string,
@Query('variant') variant: string | undefined,
@Req() request: AuthedRequest,
): Promise<PondBranding> {
return this.branding.clearPondLogo(
request.user!,
await this.pondOf(pondId),
parseVariant(variant ?? 'light'),
);
}
@Post('favicon')
@RequiresPondRole('pond_admin', { idParam: 'pondId' })
@UseInterceptors(AnyFilesInterceptor({ limits: { fileSize: MAX_BRANDING_BYTES } }))
async setFavicon(
@Param('pondId') pondId: string,
@Req() request: AuthedRequest,
@UploadedFiles() files: Express.Multer.File[] | undefined,
): Promise<PondBranding> {
const byField = new Map((files ?? []).map((file) => [file.fieldname, file.buffer]));
const collected = {} as Record<FaviconSize, Buffer>;
for (const size of FAVICON_SIZES) {
const bytes = byField.get(`png-${size}`);
if (!bytes) throw new BadRequestException({ code: 'branding_file_missing' });
collected[size] = bytes;
}
return this.branding.setPondFavicon(request.user!, await this.pondOf(pondId), collected);
}
@Delete('favicon')
@RequiresPondRole('pond_admin', { idParam: 'pondId' })
async clearFavicon(
@Param('pondId') pondId: string,
@Req() request: AuthedRequest,
): Promise<PondBranding> {
return this.branding.clearPondFavicon(request.user!, await this.pondOf(pondId));
}
}

View File

@ -1,250 +0,0 @@
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { INestApplication } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createTestApp, sessionCookieOf } from '../testing/test-app';
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service';
/**
* A real PNG of `size`×`size`, built the same way the shipped default is
* the api reads the IHDR, so the header has to be genuine.
*/
async function png(size: number): Promise<Buffer> {
const { deflateSync } = await import('node:zlib');
const crcTable = Array.from({ length: 256 }, (_, n) => {
let c = n;
for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
return c >>> 0;
});
const crc32 = (buf: Buffer): number => {
let c = 0xffffffff;
for (const byte of buf) c = crcTable[(c ^ byte) & 0xff]! ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
};
const chunk = (type: string, data: Buffer): Buffer => {
const length = Buffer.alloc(4);
length.writeUInt32BE(data.length);
const body = Buffer.concat([Buffer.from(type, 'ascii'), data]);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(body));
return Buffer.concat([length, body, crc]);
};
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(size, 0);
ihdr.writeUInt32BE(size, 4);
ihdr[8] = 8;
ihdr[9] = 6;
const raw = Buffer.alloc(size * (size * 4 + 1));
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
chunk('IHDR', ihdr),
chunk('IDAT', deflateSync(raw)),
chunk('IEND', Buffer.alloc(0)),
]);
}
describe.skipIf(!hasTestDb)('instance branding (e2e, issue #306)', () => {
let app: INestApplication;
let prisma: PrismaClient;
let brandingDir: string;
const suffix = uniqueSuffix();
const password = 'markenzeichen mit teich 1';
const admin = { username: `ba-${suffix}` };
const plain = { username: `bp-${suffix}` };
let adminCookie: string;
let plainCookie: string;
const api = () => request(app.getHttpServer());
beforeAll(async () => {
prisma = createTestPrisma();
await prisma.rateLimit.deleteMany({});
// A real directory: the point is that bytes land somewhere and come back.
brandingDir = await mkdtemp(join(tmpdir(), 'dorfteich-branding-'));
process.env.BRANDING_DIR = brandingDir;
app = await createTestApp();
const users = app.get(UsersService);
const adminUser = await users.createUser({
username: admin.username,
email: `${admin.username}@example.org`,
displayName: `Branding Admin ${suffix}`,
password,
locale: 'en',
});
await users.markEmailVerified(adminUser.id);
await prisma.user.update({ where: { id: adminUser.id }, data: { isSiteAdmin: true } });
const plainUser = await users.createUser({
username: plain.username,
email: `${plain.username}@example.org`,
displayName: `Branding Plain ${suffix}`,
password,
locale: 'en',
});
await users.markEmailVerified(plainUser.id);
const login = async (username: string): Promise<string> =>
sessionCookieOf(
await api()
.post('/api/v1/auth/login')
.send({ usernameOrEmail: username, password })
.expect(200),
);
adminCookie = await login(admin.username);
plainCookie = await login(plain.username);
});
afterAll(async () => {
await prisma.instanceSetting.deleteMany({
where: { key: { in: ['instance.logo', 'instance.logoDark', 'instance.favicon'] } },
});
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
await prisma.$disconnect();
await app.close();
await rm(brandingDir, { recursive: true, force: true });
delete process.env.BRANDING_DIR;
});
it('serves the shipped default favicon before anything is uploaded', async () => {
// The `<link rel="icon">` in index.html is a constant — this route must
// never 404, or the browser keeps its generic icon for good.
const res = await api().get('/api/v1/branding/favicon').expect(200);
expect(res.headers['content-type']).toContain('image/png');
expect(res.body.subarray(0, 8).toString('latin1')).toContain('PNG');
});
it('stores a logo, reports it, and serves the bytes without a session', async () => {
const bytes = await png(64);
const view = await api()
.post('/api/v1/admin/branding/logo?variant=light')
.set('Cookie', adminCookie)
.attach('file', bytes, 'logo.png')
.expect(201);
expect(view.body.logo).toMatchObject({ width: 64, height: 64 });
expect(view.body.logoDark).toBeNull();
// On disk, under the key the pond override (#307) will extend.
const onDisk = await readFile(join(brandingDir, 'instance-logo-light.png'));
expect(onDisk.length).toBe(bytes.length);
// Anonymous: the login screen carries the branding.
const served = await api().get('/api/v1/branding/logo?variant=light').expect(200);
expect(served.headers['content-type']).toContain('image/png');
const anon = await api().get('/api/v1/branding').expect(200);
expect(anon.body.logo.hash).toBe(view.body.logo.hash);
expect(anon.body.instanceName).toBeTruthy();
});
it('answers 404 for a logo variant that was never uploaded', async () => {
// No shipped default for the logo: without one the app renders the
// instance NAME, so an empty answer is the honest one.
await api().get('/api/v1/branding/logo?variant=dark').expect(404);
});
it('rejects an SVG with its own message, not a generic one', async () => {
const res = await api()
.post('/api/v1/admin/branding/logo?variant=light')
.set('Cookie', adminCookie)
.attach('file', Buffer.from('<?xml version="1.0"?><svg xmlns="..."><script/></svg>'), 'x.png')
.expect(400);
expect(res.body.code).toBe('branding_svg_rejected');
});
it('rejects bytes that are not a PNG at all', async () => {
const res = await api()
.post('/api/v1/admin/branding/logo?variant=light')
.set('Cookie', adminCookie)
.attach('file', Buffer.from('GIF89a and then some'), 'x.png')
.expect(400);
expect(res.body.code).toBe('branding_not_a_png');
});
it('rejects a logo larger than the maximum edge', async () => {
const res = await api()
.post('/api/v1/admin/branding/logo?variant=light')
.set('Cookie', adminCookie)
.attach('file', await png(600), 'x.png')
.expect(400);
expect(res.body.code).toBe('branding_image_too_large');
});
it('takes both favicon sizes together and serves each back', async () => {
await api()
.post('/api/v1/admin/branding/favicon')
.set('Cookie', adminCookie)
.attach('png-32', await png(32), 'f32.png')
.attach('png-180', await png(180), 'f180.png')
.expect(201);
for (const size of [32, 180]) {
const res = await api().get(`/api/v1/branding/favicon?size=${size}`).expect(200);
expect(res.body.length).toBe((await png(size)).length);
}
});
it('refuses a favicon whose bytes do not match the size they claim', async () => {
const res = await api()
.post('/api/v1/admin/branding/favicon')
.set('Cookie', adminCookie)
.attach('png-32', await png(64), 'f32.png')
.attach('png-180', await png(180), 'f180.png')
.expect(400);
expect(res.body.code).toBe('branding_favicon_not_square');
});
it('clears an asset and falls back again', async () => {
await api().delete('/api/v1/admin/branding/favicon').set('Cookie', adminCookie).expect(200);
const view = await api().get('/api/v1/branding').expect(200);
expect(view.body.favicon).toBeNull();
// Back to the shipped default rather than a 404.
await api().get('/api/v1/branding/favicon').expect(200);
await api()
.delete('/api/v1/admin/branding/logo?variant=light')
.set('Cookie', adminCookie)
.expect(200);
await api().get('/api/v1/branding/logo?variant=light').expect(404);
});
it('keeps management away from a non-admin, but not reading', async () => {
await api()
.post('/api/v1/admin/branding/logo?variant=light')
.set('Cookie', plainCookie)
.attach('file', await png(32), 'x.png')
.expect(403);
await api().delete('/api/v1/admin/branding/favicon').set('Cookie', plainCookie).expect(403);
await api().get('/api/v1/branding').set('Cookie', plainCookie).expect(200);
});
it('audits every branding change with scope, asset and direction', async () => {
await api()
.post('/api/v1/admin/branding/logo?variant=dark')
.set('Cookie', adminCookie)
.attach('file', await png(48), 'logo.png')
.expect(201);
const entry = await prisma.auditEntry.findFirst({
where: { action: 'branding.changed', targetId: 'instance.logoDark' },
orderBy: { at: 'desc' },
});
expect(entry).not.toBeNull();
expect(entry!.details).toMatchObject({ scope: 'instance', asset: 'logoDark', change: 'set' });
});
it('refuses to write branding metadata through the settings endpoint', async () => {
// The metadata describes bytes on disk; hand-writing it would claim an
// asset that is not there, so the settings PATCH does not accept it.
const res = await api()
.patch('/api/v1/admin/settings')
.set('Cookie', adminCookie)
.send({ 'instance.logo': { hash: 'deadbeefdeadbeef', width: 10, height: 10 } })
.expect(400);
expect(res.body.code).toBe('bad_request');
});
});

View File

@ -1,23 +0,0 @@
import { Module } from '@nestjs/common';
import { PermissionsModule } from '../permissions/permissions.module';
import { QuotasModule } from '../quotas/quotas.module';
import {
BrandingAdminController,
BrandingController,
PondBrandingController,
} from './branding.controller';
import { BrandingStorageService } from './branding-storage.service';
import { BrandingService } from './branding.service';
/** Instance branding logo and favicon (issue #306). Exports the services so
* the pond-level override (#307) can build on the same storage and the same
* resolution path instead of a parallel one. */
@Module({
imports: [PermissionsModule, QuotasModule],
controllers: [BrandingController, BrandingAdminController, PondBrandingController],
providers: [BrandingService, BrandingStorageService],
exports: [BrandingService, BrandingStorageService],
})
export class BrandingModule {}

View File

@ -1,380 +0,0 @@
import { createHash } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import {
BrandingAsset,
BrandingView,
FAVICON_SIZES,
FaviconSize,
LOGO_VARIANTS,
LogoVariant,
PondBranding,
pondSettingsSchema,
MAX_BRANDING_BYTES,
MAX_LOGO_EDGE,
hasPngMagic,
looksLikeSvg,
pngDimensions,
} from '@dorfteich/shared';
import { User } from '@prisma/client';
import { AuditService } from '../audit/audit.service';
import { PrismaService } from '../prisma/prisma.service';
import { QuotaService } from '../quotas/quota.service';
import { InstanceSettingsService } from '../settings/instance-settings.service';
import { BrandingStorageService } from './branding-storage.service';
/** The settings key each instance asset's metadata lives under. */
const INSTANCE_KEYS = {
logoLight: 'instance.logo',
logoDark: 'instance.logoDark',
favicon: 'instance.favicon',
} as const;
/**
* Instance branding (issue #306): the logo shown at the top of the sidebar and
* the favicon served to the browser.
*
* The api stores and serves bytes; it never decodes them. Validation is the
* PNG signature, the IHDR dimensions and the size cap see
* `packages/shared/src/branding.ts` for why that line is drawn there.
*/
@Injectable()
export class BrandingService {
constructor(
private readonly settings: InstanceSettingsService,
private readonly storage: BrandingStorageService,
private readonly audit: AuditService,
private readonly prisma: PrismaService,
private readonly quotas: QuotaService,
) {}
static logoKey(variant: LogoVariant): string {
return `instance-logo-${variant}`;
}
static faviconKey(size: FaviconSize): string {
return `instance-favicon-${size}`;
}
/** Pond assets share the directory and the naming rules (issue #307); the
* pond id keeps them apart and makes purge a prefix delete. */
static pondLogoKey(pondId: string, variant: LogoVariant): string {
return `pond-${pondId}-logo-${variant}`;
}
static pondFaviconKey(pondId: string, size: FaviconSize): string {
return `pond-${pondId}-favicon-${size}`;
}
/** Every branding file a pond can own the purge deletes exactly this set
* (issue #307). The purge standard is absolute: after it, nothing
* referencing the pond survives, rows or files. */
static pondKeys(pondId: string): string[] {
return [
...LOGO_VARIANTS.map((variant) => BrandingService.pondLogoKey(pondId, variant)),
...FAVICON_SIZES.map((size) => BrandingService.pondFaviconKey(pondId, size)),
];
}
/**
* Rejects anything that is not a PNG within the caps, before a byte is
* written. SVG gets its own message: an operator who tried one deserves to
* learn that it is refused on purpose, not that "the file is broken".
*/
private assertUsablePng(bytes: Buffer, maxEdge: number): { width: number; height: number } {
if (bytes.length === 0) throw new BadRequestException({ code: 'branding_file_empty' });
if (bytes.length > MAX_BRANDING_BYTES) {
throw new BadRequestException({ code: 'branding_file_too_large' });
}
if (looksLikeSvg(bytes)) throw new BadRequestException({ code: 'branding_svg_rejected' });
if (!hasPngMagic(bytes)) throw new BadRequestException({ code: 'branding_not_a_png' });
const size = pngDimensions(bytes);
if (!size) throw new BadRequestException({ code: 'branding_not_a_png' });
if (size.width > maxEdge || size.height > maxEdge) {
throw new BadRequestException({ code: 'branding_image_too_large' });
}
return size;
}
/**
* Reserve the pond's storage for a branding asset, releasing what the asset
* it replaces occupied. Doing it in that order means replacing a logo with
* one of the same size costs nothing otherwise every re-upload would eat
* the quota again, which is how "a pond admin fills the disk with logos"
* happens.
*/
private async chargeQuota(
pond: { id: string; ownerId: string },
bytes: number,
previous: BrandingAsset | null,
): Promise<void> {
if (previous?.byteSize) await this.quotas.release(pond.id, previous.byteSize);
try {
await this.quotas.checkAndConsume(pond.id, pond.ownerId, bytes);
} catch (error) {
// Put the released reservation back: a refused upload must not leave
// the pond with MORE room than before.
if (previous?.byteSize) {
await this.quotas.checkAndConsume(pond.id, pond.ownerId, previous.byteSize);
}
throw error;
}
}
private assetOf(bytes: Buffer, size: { width: number; height: number }): BrandingAsset {
return {
// Short digest: it only has to change when the bytes change, and it
// travels in every logo URL.
hash: createHash('sha256').update(bytes).digest('hex').slice(0, 16),
byteSize: bytes.length,
...size,
};
}
async view(): Promise<BrandingView> {
const [logo, logoDark, favicon, instanceName] = await Promise.all([
this.settings.get(INSTANCE_KEYS.logoLight),
this.settings.get(INSTANCE_KEYS.logoDark),
this.settings.get(INSTANCE_KEYS.favicon),
this.settings.get('instance.name'),
]);
return { logo, logoDark, favicon, instanceName };
}
async setLogo(admin: User, variant: LogoVariant, bytes: Buffer): Promise<BrandingView> {
const size = this.assertUsablePng(bytes, MAX_LOGO_EDGE);
await this.storage.save(BrandingService.logoKey(variant), bytes);
await this.settings.set(
variant === 'dark' ? INSTANCE_KEYS.logoDark : INSTANCE_KEYS.logoLight,
this.assetOf(bytes, size),
admin.id,
);
await this.record(admin, variant === 'dark' ? 'logoDark' : 'logo', 'set');
return this.view();
}
async clearLogo(admin: User, variant: LogoVariant): Promise<BrandingView> {
await this.storage.remove(BrandingService.logoKey(variant));
await this.settings.set(
variant === 'dark' ? INSTANCE_KEYS.logoDark : INSTANCE_KEYS.logoLight,
null,
admin.id,
);
await this.record(admin, variant === 'dark' ? 'logoDark' : 'logo', 'cleared');
return this.view();
}
/**
* Both favicon sizes arrive together: the browser produced them from one
* source on the same canvas, and the api cannot resize. Storing them as a
* pair keeps the tab icon and the home-screen icon from ever showing two
* different images.
*/
async setFavicon(admin: User, files: Record<FaviconSize, Buffer>): Promise<BrandingView> {
const sizes = Object.entries(files).map(([declared, bytes]) => {
const size = this.assertUsablePng(bytes, 512);
const expected = Number(declared);
if (size.width !== expected || size.height !== expected) {
throw new BadRequestException({ code: 'branding_favicon_not_square' });
}
return { expected: expected as FaviconSize, bytes, size };
});
for (const entry of sizes) {
await this.storage.save(BrandingService.faviconKey(entry.expected), entry.bytes);
}
// The 32px variant identifies the pair — it is what the tab shows.
const small = sizes.find((entry) => entry.expected === 32)!;
await this.settings.set(INSTANCE_KEYS.favicon, this.assetOf(small.bytes, small.size), admin.id);
await this.record(admin, 'favicon', 'set');
return this.view();
}
async clearFavicon(admin: User): Promise<BrandingView> {
await this.storage.remove(BrandingService.faviconKey(32));
await this.storage.remove(BrandingService.faviconKey(180));
await this.settings.set(INSTANCE_KEYS.favicon, null, admin.id);
await this.record(admin, 'favicon', 'cleared');
return this.view();
}
/** The bytes to serve for a logo variant, or null when none is stored. */
logoBytes(variant: LogoVariant): Promise<Buffer | null> {
return this.storage.read(BrandingService.logoKey(variant));
}
/**
* The favicon bytes: the uploaded one, else the shipped default. The
* `<link rel="icon">` in index.html is static, so this route must always
* answer with an image a 404 there would leave the browser's generic
* icon for good.
*/
async faviconBytes(size: FaviconSize): Promise<{ bytes: Buffer; uploaded: boolean }> {
const stored = await this.storage.read(BrandingService.faviconKey(size));
if (stored) return { bytes: stored, uploaded: true };
const bytes = await readFile(join(__dirname, '../../assets', `default-favicon-${size}.png`));
return { bytes, uploaded: false };
}
/** The pond's own branding, defaulted — one place reads the settings blob. */
async pondBranding(pondId: string): Promise<PondBranding> {
const pond = await this.prisma.pond.findUnique({
where: { id: pondId },
select: { settings: true },
});
if (!pond) throw new NotFoundException();
return pondSettingsSchema.parse(pond.settings ?? {}).branding;
}
private async writePondBranding(
actor: User,
pondId: string,
next: PondBranding,
asset: 'logo' | 'logoDark' | 'favicon',
change: 'set' | 'cleared',
): Promise<PondBranding> {
const pond = await this.prisma.pond.findUniqueOrThrow({
where: { id: pondId },
select: { settings: true },
});
const settings = pondSettingsSchema.parse(pond.settings ?? {});
await this.prisma.pond.update({
where: { id: pondId },
data: { settings: { ...settings, branding: next } as object },
});
await this.audit.record({
action: 'branding.changed',
actorId: actor.id,
targetType: 'pond',
targetId: pondId,
details: { scope: 'pond', pondId, asset, change },
});
return next;
}
/**
* A pond logo, charged to the pond's storage quota (issue #307).
*
* Without the charge, branding would be a way around the quota and
* replacing a logo repeatedly would let a pond admin consume disk with no
* ceiling. Charged BEFORE the write, like attachments, so a race never
* leaves bytes on the volume without a reservation; the bytes a replaced
* asset frees are released first, so re-uploading the same logo is free
* rather than cumulative.
*/
async setPondLogo(
actor: User,
pond: { id: string; ownerId: string },
variant: LogoVariant,
bytes: Buffer,
): Promise<PondBranding> {
const size = this.assertUsablePng(bytes, MAX_LOGO_EDGE);
const current = await this.pondBranding(pond.id);
const previous = variant === 'dark' ? current.logoDark : current.logo;
await this.chargeQuota(pond, bytes.length, previous);
await this.storage.save(BrandingService.pondLogoKey(pond.id, variant), bytes);
const asset = this.assetOf(bytes, size);
return this.writePondBranding(
actor,
pond.id,
variant === 'dark' ? { ...current, logoDark: asset } : { ...current, logo: asset },
variant === 'dark' ? 'logoDark' : 'logo',
'set',
);
}
async clearPondLogo(
actor: User,
pond: { id: string; ownerId: string },
variant: LogoVariant,
): Promise<PondBranding> {
const current = await this.pondBranding(pond.id);
const previous = variant === 'dark' ? current.logoDark : current.logo;
await this.storage.remove(BrandingService.pondLogoKey(pond.id, variant));
if (previous?.byteSize) await this.quotas.release(pond.id, previous.byteSize);
return this.writePondBranding(
actor,
pond.id,
variant === 'dark' ? { ...current, logoDark: null } : { ...current, logo: null },
variant === 'dark' ? 'logoDark' : 'logo',
'cleared',
);
}
async setPondFavicon(
actor: User,
pond: { id: string; ownerId: string },
files: Record<FaviconSize, Buffer>,
): Promise<PondBranding> {
const checked = Object.entries(files).map(([declared, bytes]) => {
const size = this.assertUsablePng(bytes, 512);
const expected = Number(declared);
if (size.width !== expected || size.height !== expected) {
throw new BadRequestException({ code: 'branding_favicon_not_square' });
}
return { expected: expected as FaviconSize, bytes, size };
});
const current = await this.pondBranding(pond.id);
const total = checked.reduce((sum, entry) => sum + entry.bytes.length, 0);
await this.chargeQuota(pond, total, current.favicon);
for (const entry of checked) {
await this.storage.save(BrandingService.pondFaviconKey(pond.id, entry.expected), entry.bytes);
}
const small = checked.find((entry) => entry.expected === 32)!;
// The pair is charged together, so the stored size is the pair's — that
// is what a later release has to give back.
const asset = { ...this.assetOf(small.bytes, small.size), byteSize: total };
return this.writePondBranding(actor, pond.id, { ...current, favicon: asset }, 'favicon', 'set');
}
async clearPondFavicon(
actor: User,
pond: { id: string; ownerId: string },
): Promise<PondBranding> {
const current = await this.pondBranding(pond.id);
for (const size of FAVICON_SIZES) {
await this.storage.remove(BrandingService.pondFaviconKey(pond.id, size));
}
if (current.favicon?.byteSize) await this.quotas.release(pond.id, current.favicon.byteSize);
return this.writePondBranding(
actor,
pond.id,
{ ...current, favicon: null },
'favicon',
'cleared',
);
}
/** Bytes for a pond asset null when the pond has none at that slot, which
* is what makes the caller fall back to the instance level. */
pondLogoBytes(pondId: string, variant: LogoVariant): Promise<Buffer | null> {
return this.storage.read(BrandingService.pondLogoKey(pondId, variant));
}
pondFaviconBytes(pondId: string, size: FaviconSize): Promise<Buffer | null> {
return this.storage.read(BrandingService.pondFaviconKey(pondId, size));
}
/** Removes every branding file of a pond (issue #307's purge obligation). */
async removePondAssets(pondId: string): Promise<void> {
for (const key of BrandingService.pondKeys(pondId)) await this.storage.remove(key);
}
private record(
admin: User,
asset: 'logo' | 'logoDark' | 'favicon',
action: 'set' | 'cleared',
): Promise<unknown> {
// `scope` is here from the start so the pond-level change (#307) is the
// same event with a different scope, not a second id in the catalogue.
return this.audit.record({
action: 'branding.changed',
actorId: admin.id,
targetType: 'setting',
targetId: `instance.${asset}`,
details: { scope: 'instance', asset, change: action },
});
}
}

View File

@ -1,256 +0,0 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { deflateSync } from 'node:zlib';
import { INestApplication } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { AuthTokensService } from '../auth/auth-tokens.service';
import { createTestApp, sessionCookieOf } from '../testing/test-app';
import {
createTestPrisma,
deletePondsWhere,
grantOwnerAdmin,
hasTestDb,
uniqueSuffix,
} from '../testing/test-db';
import { TrashService } from '../trash/trash.service';
import { UsersService } from '../users/users.service';
import { BrandingService } from './branding.service';
import { BrandingStorageService } from './branding-storage.service';
const crcTable = Array.from({ length: 256 }, (_, n) => {
let c = n;
for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
return c >>> 0;
});
function crc32(buf: Buffer): number {
let c = 0xffffffff;
for (const byte of buf) c = crcTable[(c ^ byte) & 0xff]! ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
function chunk(type: string, data: Buffer): Buffer {
const length = Buffer.alloc(4);
length.writeUInt32BE(data.length);
const body = Buffer.concat([Buffer.from(type, 'ascii'), data]);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(body));
return Buffer.concat([length, body, crc]);
}
/** A real PNG — the api reads the IHDR, so the header has to be genuine. */
function png(size: number): Buffer {
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(size, 0);
ihdr.writeUInt32BE(size, 4);
ihdr[8] = 8;
ihdr[9] = 6;
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
chunk('IHDR', ihdr),
chunk('IDAT', deflateSync(Buffer.alloc(size * (size * 4 + 1)))),
chunk('IEND', Buffer.alloc(0)),
]);
}
describe.skipIf(!hasTestDb)('pond branding (e2e, issue #307)', () => {
let app: INestApplication;
let prisma: PrismaClient;
let storage: BrandingStorageService;
let brandingDir: string;
const suffix = uniqueSuffix();
const password = 'teichmarke mit eigenem logo 1';
const owner = { username: `pb-${suffix}` };
const member = { username: `pbm-${suffix}` };
let ownerCookie: string;
let memberCookie: string;
let pondId: string;
const api = () => request(app.getHttpServer());
beforeAll(async () => {
prisma = createTestPrisma();
await prisma.rateLimit.deleteMany({});
brandingDir = await mkdtemp(join(tmpdir(), 'dorfteich-pondbranding-'));
process.env.BRANDING_DIR = brandingDir;
app = await createTestApp();
storage = app.get(BrandingStorageService);
const users = app.get(UsersService);
const tokens = app.get(AuthTokensService);
// Verification through the endpoint, not `markEmailVerified`: only this
// path creates the personal pond these tests brand.
const verify = async (userId: string): Promise<void> => {
await api()
.post('/api/v1/auth/verify-email')
.send({ token: await tokens.issue(userId, 'EMAIL_VERIFICATION', 600) })
.expect(204);
};
const ownerUser = await users.createUser({
username: owner.username,
email: `${owner.username}@example.org`,
displayName: `Pond Branding Owner ${suffix}`,
password,
locale: 'en',
});
await verify(ownerUser.id);
const memberUser = await users.createUser({
username: member.username,
email: `${member.username}@example.org`,
displayName: `Pond Branding Member ${suffix}`,
password,
locale: 'en',
});
await verify(memberUser.id);
const login = async (username: string): Promise<string> =>
sessionCookieOf(
await api()
.post('/api/v1/auth/login')
.send({ usernameOrEmail: username, password })
.expect(200),
);
ownerCookie = await login(owner.username);
memberCookie = await login(member.username);
pondId = (
await prisma.pond.findFirstOrThrow({ where: { ownerId: ownerUser.id, type: 'PERSONAL' } })
).id;
// A reader on the same pond: may see it, may not administer it. Through
// the API, not a raw row — the permission cache would not see the row
// (the documented rule for grants in tests).
await api()
.post(`/api/v1/ponds/${pondId}/grants`)
.set('Cookie', ownerCookie)
.send({
subjectType: 'user',
subjectId: memberUser.id,
role: 'reader',
scopeType: 'pond',
effect: 'allow',
})
.expect(201);
});
afterAll(async () => {
await prisma.roleGrant.deleteMany({
where: { pond: { owner: { username: { contains: suffix } } } },
});
await deletePondsWhere(prisma, { owner: { username: { contains: suffix } } });
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
await prisma.$disconnect();
await app.close();
await rm(brandingDir, { recursive: true, force: true });
delete process.env.BRANDING_DIR;
});
it('stores a pond logo, reports it, and serves it under the pond scope', async () => {
const view = await api()
.post(`/api/v1/ponds/${pondId}/branding/logo?variant=light`)
.set('Cookie', ownerCookie)
.attach('file', png(64), 'logo.png')
.expect(201);
expect(view.body.logo).toMatchObject({ width: 64, height: 64 });
const served = await api()
.get(`/api/v1/branding/logo?variant=light&pond=${pondId}`)
.expect(200);
expect(served.headers['content-type']).toContain('image/png');
// Without the pond scope the instance level answers — 404 here, since no
// instance logo is set. The two levels never leak into each other.
await api().get('/api/v1/branding/logo?variant=light').expect(404);
});
it('charges the pond quota and gives the bytes back when the logo is replaced', async () => {
const usageOf = async (): Promise<number> =>
Number(
(
await prisma.pondUsage.findUnique({
where: { pondId },
select: { storageBytesUsed: true },
})
)?.storageBytesUsed ?? 0,
);
const before = await usageOf();
const big = png(120);
await api()
.post(`/api/v1/ponds/${pondId}/branding/logo?variant=dark`)
.set('Cookie', ownerCookie)
.attach('file', big, 'logo.png')
.expect(201);
const afterUpload = await usageOf();
expect(afterUpload).toBe(before + big.length);
// Replacing releases the old reservation first — otherwise re-uploading
// the same logo would eat the quota again and again.
await api()
.post(`/api/v1/ponds/${pondId}/branding/logo?variant=dark`)
.set('Cookie', ownerCookie)
.attach('file', big, 'logo.png')
.expect(201);
expect(await usageOf()).toBe(afterUpload);
await api()
.delete(`/api/v1/ponds/${pondId}/branding/logo?variant=dark`)
.set('Cookie', ownerCookie)
.expect(200);
expect(await usageOf()).toBe(before);
});
it('refuses SVG at the pond level too — the rules do not relax for a pond admin', async () => {
const res = await api()
.post(`/api/v1/ponds/${pondId}/branding/logo?variant=light`)
.set('Cookie', ownerCookie)
.attach('file', Buffer.from('<svg xmlns="x"><script/></svg>'), 'x.png')
.expect(400);
expect(res.body.code).toBe('branding_svg_rejected');
});
it('lets a member read the pond branding but not change it', async () => {
await api().get(`/api/v1/ponds/${pondId}/branding`).set('Cookie', memberCookie).expect(200);
await api()
.post(`/api/v1/ponds/${pondId}/branding/logo?variant=light`)
.set('Cookie', memberCookie)
.attach('file', png(32), 'x.png')
.expect(403);
await api()
.delete(`/api/v1/ponds/${pondId}/branding/favicon`)
.set('Cookie', memberCookie)
.expect(403);
});
it('purging the pond removes its branding files', async () => {
// A pond of its own, so the purge does not take the shared fixture with it.
const ownerRow = await prisma.user.findFirstOrThrow({ where: { username: owner.username } });
const created = await prisma.pond.create({
data: {
name: `Purge Branding ${suffix}`,
slug: `purge-branding-${suffix}`,
type: 'SHARED',
ownerId: ownerRow.id,
},
});
// Raw grant row, before this pond's first permission query — the
// documented exception to "grants through the API".
await grantOwnerAdmin(prisma, created.id, ownerRow.id);
await api()
.post(`/api/v1/ponds/${created.id}/branding/logo?variant=light`)
.set('Cookie', ownerCookie)
.attach('file', png(48), 'logo.png')
.expect(201);
expect(await storage.read(BrandingService.pondLogoKey(created.id, 'light'))).not.toBeNull();
await prisma.pond.update({ where: { id: created.id }, data: { deletedAt: new Date() } });
const trash = app.get(TrashService);
await trash.purgePondNow(ownerRow, created.id);
// The purge standard is absolute: after it nothing referencing the pond
// survives — rows OR files.
expect(await storage.read(BrandingService.pondLogoKey(created.id, 'light'))).toBeNull();
});
});

View File

@ -1,25 +0,0 @@
import { describe, expect, it } from 'vitest';
import { maskTokenParam } from './mask-token-param';
describe('maskTokenParam (issue #191)', () => {
it('masks a token as the only query parameter', () => {
expect(maskTokenParam('/api/v1/public/p/feed.xml?token=dt_feed_abc123')).toBe(
'/api/v1/public/p/feed.xml?token=[redacted]',
);
});
it('masks a token between other parameters and stops at delimiters', () => {
expect(maskTokenParam('/x?a=1&token=secret&b=2')).toBe('/x?a=1&token=[redacted]&b=2');
expect(maskTokenParam('/x?token=secret#frag')).toBe('/x?token=[redacted]#frag');
});
it('leaves URLs without a token parameter untouched', () => {
expect(maskTokenParam('/api/v1/ponds?filter=token')).toBe('/api/v1/ponds?filter=token');
expect(maskTokenParam('/api/v1/readyz')).toBe('/api/v1/readyz');
});
it('passes undefined through', () => {
expect(maskTokenParam(undefined)).toBeUndefined();
});
});

View File

@ -1,9 +0,0 @@
/**
* Masks credential-bearing `token` query parameters before a URL reaches
* the request log (issue #191): feed tokens travel in the query string
* because feed readers cannot send headers, and the api's own log must
* not become the place where that long-lived credential is stored.
*/
export function maskTokenParam<T extends string | undefined>(url: T): T {
return url?.replace(/([?&]token=)[^&#]*/gi, '$1[redacted]') as T;
}

View File

@ -1,74 +0,0 @@
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { AppModule } from '../app.module';
// Boots the AppModule without a database (like health.e2e.test.ts): the
// middleware under test runs before any route logic, so the always-on
// healthz endpoint is a representative response (issue #197).
describe('security response headers & CORS (e2e, issue #197)', () => {
let app: INestApplication;
const appOrigin = 'http://localhost:5173'; // APP_BASE_URL default origin
beforeAll(async () => {
process.env.NODE_ENV = 'test';
process.env.DATABASE_URL ??= 'postgresql://nobody:nothing@127.0.0.1:59999/absent';
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
app = moduleRef.createNestApplication();
app.setGlobalPrefix('api/v1');
await app.init();
});
afterAll(async () => {
await app.close();
});
it('stamps the full header set on a representative response', async () => {
const res = await request(app.getHttpServer()).get('/api/v1/healthz').expect(200);
expect(res.headers['strict-transport-security']).toBe('max-age=31536000');
expect(res.headers['x-content-type-options']).toBe('nosniff');
expect(res.headers['referrer-policy']).toBe('no-referrer');
// SAMEORIGIN, not DENY — the plugin sandbox frame is embedded
// same-origin (plugins.e2e.db.test.ts asserts the frame side).
expect(res.headers['x-frame-options']).toBe('SAMEORIGIN');
expect(res.headers['permissions-policy']).toBe(
'camera=(), microphone=(), geolocation=(), payment=(), usb=()',
);
});
it('stamps the headers on error responses too (unknown route)', async () => {
const res = await request(app.getHttpServer()).get('/api/v1/does-not-exist').expect(404);
expect(res.headers['x-content-type-options']).toBe('nosniff');
expect(res.headers['x-frame-options']).toBe('SAMEORIGIN');
});
it('grants a foreign origin nothing (no ACAO), while varying on Origin', async () => {
const res = await request(app.getHttpServer())
.get('/api/v1/healthz')
.set('Origin', 'https://attacker.example')
.expect(200);
expect(res.headers['access-control-allow-origin']).toBeUndefined();
expect(res.headers['access-control-allow-credentials']).toBeUndefined();
expect(res.headers.vary).toContain('Origin');
});
it("echoes only the app's own origin, with the credentials rule stated", async () => {
const res = await request(app.getHttpServer())
.get('/api/v1/healthz')
.set('Origin', appOrigin)
.expect(200);
expect(res.headers['access-control-allow-origin']).toBe(appOrigin);
expect(res.headers['access-control-allow-credentials']).toBe('true');
});
it('leaves a foreign preflight ungranted (no CORS response headers)', async () => {
const res = await request(app.getHttpServer())
.options('/api/v1/healthz')
.set('Origin', 'https://attacker.example')
.set('Access-Control-Request-Method', 'POST');
expect(res.headers['access-control-allow-origin']).toBeUndefined();
expect(res.headers['access-control-allow-methods']).toBeUndefined();
});
});

View File

@ -1,54 +0,0 @@
import { Injectable, NestMiddleware } from '@nestjs/common';
import type { NextFunction, Request, Response } from 'express';
import { AppConfig } from '../config/app-config.service';
/**
* Security response headers and the CORS stance for every api response
* (issue #197). Hand-rolled instead of `helmet`: the header set is small
* enough to own, every value below is a deliberate decision, and the api
* gains no transitive dependency. Wired via the AppModule's
* MiddlewareConsumer so the test harness (createTestApp) exercises the
* exact production middleware rationale per header in
* docs/architecture/security.md §Security response headers & CORS.
*/
@Injectable()
export class SecurityHeadersMiddleware implements NestMiddleware {
/** The one origin the SPA is served from; the only origin CORS ever echoes. */
private readonly allowedOrigin: string;
constructor(config: AppConfig) {
this.allowedOrigin = new URL(config.env.APP_BASE_URL).origin;
}
use(req: Request, res: Response, next: NextFunction): void {
// No includeSubDomains: the api cannot speak for sibling subdomains it
// does not control (e.g. a support desk on the same apex). Browsers
// ignore HSTS over plain http, so sending it unconditionally is safe.
res.setHeader('Strict-Transport-Security', 'max-age=31536000');
res.setHeader('X-Content-Type-Options', 'nosniff');
// Page paths are permission-scoped knowledge — leak them to no one.
res.setHeader('Referrer-Policy', 'no-referrer');
// SAMEORIGIN, deliberately not DENY: the plugin sandbox (ADR 0008)
// embeds /api/v1/plugins/<id>/<version>/frame same-origin, and the
// frame's own CSP carries no frame-ancestors — this header governs.
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
// Deny the powerful features outright; nothing in the app uses them.
res.setHeader(
'Permissions-Policy',
'camera=(), microphone=(), geolocation=(), payment=(), usb=()',
);
// CORS: no foreign origin is granted anything — only the app's own
// origin is ever echoed (where browsers do not consult CORS anyway, as
// same-origin; the echo states the decision rather than enabling a
// caller). Same-origin requests never preflight, so no OPTIONS
// handling is needed. Vary on every response keeps caches honest.
res.vary('Origin');
if (req.headers.origin === this.allowedOrigin) {
res.setHeader('Access-Control-Allow-Origin', this.allowedOrigin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
}
next();
}
}

View File

@ -1,39 +0,0 @@
import { Controller, Delete, Get, Param, Put, Req } from '@nestjs/common';
import type { FavoriteStateView, PageFavoritesView } from '@dorfteich/shared';
import { AuthedRequest } from '../auth/auth.guard';
import { AuthenticatedOnly } from '../permissions/permission.decorators';
import { FavoritesService } from './favorites.service';
/** Star/unstar pages + the per-pond favorites of the account (issue #132). */
@Controller()
export class FavoritesController {
constructor(private readonly favorites: FavoritesService) {}
@Get('ponds/:pondId/favorites')
@AuthenticatedOnly()
async list(
@Param('pondId') pondId: string,
@Req() request: AuthedRequest,
): Promise<PageFavoritesView> {
return this.favorites.listForPond(request.user!, pondId);
}
@Put('pages/:id/favorite')
@AuthenticatedOnly()
async favorite(
@Param('id') id: string,
@Req() request: AuthedRequest,
): Promise<FavoriteStateView> {
return this.favorites.favorite(request.user!, id);
}
@Delete('pages/:id/favorite')
@AuthenticatedOnly()
async unfavorite(
@Param('id') id: string,
@Req() request: AuthedRequest,
): Promise<FavoriteStateView> {
return this.favorites.unfavorite(request.user!, id);
}
}

View File

@ -1,177 +0,0 @@
import { INestApplication } from '@nestjs/common';
import type { FavoriteStateView, PageFavoritesView } from '@dorfteich/shared';
import { PrismaClient, User } from '@prisma/client';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createTestApp, sessionCookieOf } from '../testing/test-app';
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service';
/**
* Personal favorites end to end (issue #132): per-user round-trip and
* isolation, read-gated starring (#60 semantics), idempotency, and the
* trash/restore/purge lifecycle (a star survives the trash, purge cascades
* it away).
*/
describe.skipIf(!hasTestDb)('favorites (e2e, issue #132)', () => {
let app: INestApplication;
let prisma: PrismaClient;
const suffix = uniqueSuffix();
const password = 'sterne fuer seiten 1';
const users: Record<string, User> = {};
const cookies: Record<string, string> = {};
let pondId: string;
const api = () => request(app.getHttpServer());
async function makeUser(handle: string): Promise<void> {
const service = app.get(UsersService);
const username = `fav-${handle}-${suffix}`;
const user = await service.createUser({
username,
email: `${username}@example.org`,
displayName: `Fav ${handle}`,
password,
locale: 'en',
});
await service.markEmailVerified(user.id);
users[handle] = user;
cookies[handle] = sessionCookieOf(
await api()
.post('/api/v1/auth/login')
.send({ usernameOrEmail: username, password })
.expect(200),
);
}
async function createPage(cookie: string, title: string): Promise<string> {
const res = await api()
.post(`/api/v1/ponds/${pondId}/pages`)
.set('Cookie', cookie)
.send({ title })
.expect(201);
return (res.body as { id: string }).id;
}
async function favoritesOf(cookie: string): Promise<string[]> {
const res = await api()
.get(`/api/v1/ponds/${pondId}/favorites`)
.set('Cookie', cookie)
.expect(200);
return (res.body as PageFavoritesView).pageIds;
}
beforeAll(async () => {
prisma = createTestPrisma();
await prisma.rateLimit.deleteMany({});
app = await createTestApp();
for (const handle of ['owner', 'member', 'outsider']) await makeUser(handle);
const pond = await prisma.pond.create({
data: {
slug: `fav-pond-${suffix}`,
name: 'Favorite Pond',
type: 'SHARED',
ownerId: users.owner!.id,
},
});
pondId = pond.id;
for (const [handle, role] of [
['owner', 'POND_ADMIN'],
['member', 'EDITOR'],
] as const) {
await prisma.roleGrant.create({
data: {
pondId,
subjectType: 'USER',
subjectId: users[handle]!.id,
role,
scopeType: 'POND',
effect: 'ALLOW',
createdBy: users.owner!.id,
},
});
}
});
afterAll(async () => {
const ids = Object.values(users).map((u) => u.id);
await prisma.pageFavorite.deleteMany({ where: { userId: { in: ids } } });
await prisma.roleGrant.deleteMany({ where: { pondId } });
await prisma.page.deleteMany({ where: { pondId } });
await prisma.pond.deleteMany({ where: { id: pondId } });
await prisma.auditEntry.deleteMany({ where: { actorId: { in: ids } } });
await prisma.session.deleteMany({ where: { userId: { in: ids } } });
await prisma.userIdentity.deleteMany({ where: { userId: { in: ids } } });
await prisma.user.deleteMany({ where: { id: { in: ids } } });
await prisma.$disconnect();
await app.close();
});
it('round-trips the star per user and stays idempotent', async () => {
const pageId = await createPage(cookies.owner!, 'Starred page');
const on = (
await api().put(`/api/v1/pages/${pageId}/favorite`).set('Cookie', cookies.member!).expect(200)
).body as FavoriteStateView;
expect(on.favorite).toBe(true);
// Starring twice is fine — still exactly one favorite.
await api().put(`/api/v1/pages/${pageId}/favorite`).set('Cookie', cookies.member!).expect(200);
expect(await favoritesOf(cookies.member!)).toEqual([pageId]);
// Personal, not pond-wide: the owner's list stays empty.
expect(await favoritesOf(cookies.owner!)).toEqual([]);
const off = (
await api()
.delete(`/api/v1/pages/${pageId}/favorite`)
.set('Cookie', cookies.member!)
.expect(200)
).body as FavoriteStateView;
expect(off.favorite).toBe(false);
expect(await favoritesOf(cookies.member!)).toEqual([]);
// Unstarring an unstarred page is a no-op, not an error.
await api()
.delete(`/api/v1/pages/${pageId}/favorite`)
.set('Cookie', cookies.member!)
.expect(200);
});
it('gates starring and the pond list behind read access (404, #60)', async () => {
const pageId = await createPage(cookies.owner!, 'Hidden page');
await api()
.put(`/api/v1/pages/${pageId}/favorite`)
.set('Cookie', cookies.outsider!)
.expect(404);
await api()
.get(`/api/v1/ponds/${pondId}/favorites`)
.set('Cookie', cookies.outsider!)
.expect(404);
});
it('hides trashed favorites, revives them on restore, cascades on purge', async () => {
const pageId = await createPage(cookies.owner!, 'Cycling page');
await api().put(`/api/v1/pages/${pageId}/favorite`).set('Cookie', cookies.member!).expect(200);
// Trash: the page drops out of the favorites list, the row stays.
await prisma.page.update({
where: { id: pageId },
data: { deletedAt: new Date(), deletedBy: users.owner!.id },
});
expect(await favoritesOf(cookies.member!)).toEqual([]);
expect(await prisma.pageFavorite.count({ where: { pageId } })).toBe(1);
// Restore: the star is back without re-starring.
await prisma.page.update({ where: { id: pageId }, data: { deletedAt: null, deletedBy: null } });
expect(await favoritesOf(cookies.member!)).toEqual([pageId]);
// Purge: the FK cascade removes the favorite rows for good.
await prisma.page.update({
where: { id: pageId },
data: { deletedAt: new Date(), deletedBy: users.owner!.id },
});
await api().delete(`/api/v1/pages/${pageId}/purge`).set('Cookie', cookies.owner!).expect(204);
expect(await prisma.pageFavorite.count({ where: { pageId } })).toBe(0);
});
});

View File

@ -1,13 +0,0 @@
import { Module } from '@nestjs/common';
import { PermissionsModule } from '../permissions/permissions.module';
import { FavoritesController } from './favorites.controller';
import { FavoritesService } from './favorites.service';
@Module({
imports: [PermissionsModule],
controllers: [FavoritesController],
providers: [FavoritesService],
})
export class FavoritesModule {}

View File

@ -1,57 +0,0 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import type { FavoriteStateView, PageFavoritesView } from '@dorfteich/shared';
import { User } from '@prisma/client';
import { PermissionService } from '../permissions/permission.service';
import { PrismaService } from '../prisma/prisma.service';
/**
* Personal page favorites (issue #132): a per-user star, deliberately NOT
* pond-wide (see the planning pivot on the issue). Starring needs read
* access to a live page (404 hides what the user cannot see, #60) it is
* a note-to-self, not a page modification, so write access is NOT required.
* Both directions are idempotent, mirroring the watches service.
*/
@Injectable()
export class FavoritesService {
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PermissionService,
) {}
async favorite(user: User, pageId: string): Promise<FavoriteStateView> {
const page = await this.prisma.page.findFirst({
where: { id: pageId, deletedAt: null },
select: { id: true, pondId: true },
});
if (!page || !(await this.permissions.canAccessPage(user, page, 'read'))) {
throw new NotFoundException();
}
await this.prisma.pageFavorite.upsert({
where: { userId_pageId: { userId: user.id, pageId } },
create: { userId: user.id, pageId },
update: {},
});
return { favorite: true };
}
async unfavorite(user: User, pageId: string): Promise<FavoriteStateView> {
await this.prisma.pageFavorite.deleteMany({ where: { userId: user.id, pageId } });
return { favorite: false };
}
/** The user's own favorites within one pond, sliced to pages they can
* still read a revoked page must not confirm its continued existence. */
async listForPond(user: User, pondId: string): Promise<PageFavoritesView> {
if (!(await this.permissions.canSeePond(user, pondId))) throw new NotFoundException();
const rows = await this.prisma.pageFavorite.findMany({
where: { userId: user.id, page: { pondId, deletedAt: null } },
select: { pageId: true, page: { select: { id: true, pondId: true } } },
});
const pageIds: string[] = [];
for (const row of rows) {
if (await this.permissions.canAccessPage(user, row.page, 'read')) pageIds.push(row.pageId);
}
return { pageIds };
}
}

View File

@ -1,152 +0,0 @@
import { createHash } from 'node:crypto';
import { INestApplication, InternalServerErrorException } from '@nestjs/common';
import { PrismaClient, User } from '@prisma/client';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { AuthTokensService } from '../auth/auth-tokens.service';
import { createTestApp } from '../testing/test-app';
import { createTestPrisma, deletePondsWhere, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service';
import { FileStorageService } from './file-storage.service';
import { FilesService } from './files.service';
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const pngBuffer = (payload: string): Buffer => Buffer.concat([PNG_SIGNATURE, Buffer.from(payload)]);
const sha256 = (buffer: Buffer): string => createHash('sha256').update(buffer).digest('hex');
/**
* Attachment integrity (issue #199): uploads store the SHA-256 of the
* written bytes, downloads verify it and fail closed (audited) on mismatch,
* and the nightly backfill hashes pre-#199 rows idempotently, reporting
* unreadable files instead of skipping them.
*/
describe.skipIf(!hasTestDb)('attachment integrity (e2e, issue #199)', () => {
let app: INestApplication;
let prisma: PrismaClient;
let files: FilesService;
let storage: FileStorageService;
let user: User;
let pondId: string;
const suffix = uniqueSuffix();
async function uploadPng(payload: string): Promise<{ id: string; bytes: Buffer }> {
const bytes = pngBuffer(payload);
const view = await files.upload(user, pondId, {
buffer: bytes,
size: bytes.length,
originalname: `${payload}.png`,
});
return { id: view.id, bytes };
}
beforeAll(async () => {
prisma = createTestPrisma();
app = await createTestApp();
files = app.get(FilesService);
storage = app.get(FileStorageService);
const users = app.get(UsersService);
user = await users.createUser({
username: `ines-integrity-${suffix}`,
email: `ines-integrity-${suffix}@example.org`,
displayName: `Ines Integrity ${suffix}`,
password: 'jedes byte bleibt wie es war 1',
locale: 'en',
});
// Verification via the endpoint (not markEmailVerified) because only the
// endpoint creates the personal pond the uploads go into.
const token = await app.get(AuthTokensService).issue(user.id, 'EMAIL_VERIFICATION', 600);
await request(app.getHttpServer())
.post('/api/v1/auth/verify-email')
.send({ token })
.expect(204);
const pond = await prisma.pond.findFirstOrThrow({ where: { ownerId: user.id } });
pondId = pond.id;
});
afterAll(async () => {
await prisma.auditEntry.deleteMany({
where: { action: 'file.integrity_failed', details: { path: ['pondId'], equals: pondId } },
});
await prisma.attachment.deleteMany({ where: { pondId } });
const where = { pond: { owner: { username: { contains: suffix } } } };
await prisma.roleGrant.deleteMany({ where });
await deletePondsWhere(prisma, { owner: { username: { contains: suffix } } });
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
await prisma.$disconnect();
await app.close();
});
it('stores the hash of the written bytes at upload', async () => {
const { id, bytes } = await uploadPng('honest-upload');
const row = await prisma.attachment.findUniqueOrThrow({ where: { id } });
expect(row.sha256).toBe(sha256(bytes));
});
it('serves an intact file and fails closed, audited, on a tampered one', async () => {
const { id, bytes } = await uploadPng('will-be-tampered');
// Intact: the download succeeds and streams the exact bytes.
const intact = await files.download(null, id, { actorId: null, sessionKey: 'anon' });
const chunks: Buffer[] = [];
for await (const chunk of intact.stream) chunks.push(chunk as Buffer);
expect(Buffer.concat(chunks).equals(bytes)).toBe(true);
// Tampered on disk (row untouched): fail closed with the dedicated code.
await storage.save(pondId, id, pngBuffer('evil-replacement'));
const failure = await files
.download(null, id, { actorId: null, sessionKey: 'anon' })
.catch((error: unknown) => error);
expect(failure).toBeInstanceOf(InternalServerErrorException);
expect((failure as InternalServerErrorException).getResponse()).toMatchObject({
code: 'attachment_integrity_failure',
});
// The mismatch is on the audit trail with both hashes.
const audit = await prisma.auditEntry.findFirst({
where: { action: 'file.integrity_failed', targetId: id },
});
expect(audit).not.toBeNull();
expect(audit!.details).toMatchObject({
expected: sha256(bytes),
actual: sha256(pngBuffer('evil-replacement')),
});
});
it('backfills missing hashes idempotently and reports unreadable files', async () => {
const readable = await uploadPng('backfill-me');
const unreadable = await uploadPng('bytes-will-vanish');
await prisma.attachment.updateMany({
where: { id: { in: [readable.id, unreadable.id] } },
data: { sha256: null },
});
await storage.delete(pondId, unreadable.id);
// A null-hash row is served unverified (pre-#199 status quo).
const unverified = await files.download(null, readable.id, {
actorId: null,
sessionKey: 'anon',
});
expect(unverified.attachment.sha256).toBeNull();
const first = await files.backfillHashes();
expect(first.hashed).toBeGreaterThanOrEqual(1);
expect(first.unreadable).toBeGreaterThanOrEqual(1);
const rehashed = await prisma.attachment.findUniqueOrThrow({ where: { id: readable.id } });
expect(rehashed.sha256).toBe(sha256(readable.bytes));
// The unreadable row keeps its null hash — reported, retried next run,
// never silently marked done.
const vanished = await prisma.attachment.findUniqueOrThrow({ where: { id: unreadable.id } });
expect(vanished.sha256).toBeNull();
// Idempotent: a second run finds nothing new to hash here.
const second = await files.backfillHashes();
const third = await prisma.attachment.findUniqueOrThrow({ where: { id: readable.id } });
expect(third.sha256).toBe(sha256(readable.bytes));
expect(second.unreadable).toBeGreaterThanOrEqual(1);
});
});

View File

@ -1,5 +1,5 @@
import { createReadStream } from 'node:fs'; import { createReadStream } from 'node:fs';
import { access, mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; import { access, mkdir, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path'; import { join } from 'node:path';
import type { Readable } from 'node:stream'; import type { Readable } from 'node:stream';
@ -30,13 +30,6 @@ export class FileStorageService {
return createReadStream(this.pathFor(pondId, fileId)); return createReadStream(this.pathFor(pondId, fileId));
} }
/** The complete stored bytes. Used where the caller must see the whole
* object before serving a single byte of it integrity verification
* (issue #199) cannot work on a stream that is already leaving. */
read(pondId: string, fileId: string): Promise<Buffer> {
return readFile(this.pathFor(pondId, fileId));
}
/** Whether the file's bytes are actually on disk. Used by the pond export to /** Whether the file's bytes are actually on disk. Used by the pond export to
* skip an attachment whose bytes are missing (data drift) rather than crash * skip an attachment whose bytes are missing (data drift) rather than crash
* the archive stream (issue #65). */ * the archive stream (issue #65). */
@ -53,37 +46,4 @@ export class FileStorageService {
async delete(pondId: string, fileId: string): Promise<void> { async delete(pondId: string, fileId: string): Promise<void> {
await rm(this.pathFor(pondId, fileId), { force: true }); await rm(this.pathFor(pondId, fileId), { force: true });
} }
/**
* Every stored file with its modification time, for the orphan sweep's
* volumedatabase direction (issue #194, ADR 0011). A missing uploads
* directory is an empty volume, not an error.
*/
async listStored(): Promise<{ pondId: string; fileId: string; mtimeMs: number }[]> {
const root = this.config.env.UPLOADS_DIR;
const result: { pondId: string; fileId: string; mtimeMs: number }[] = [];
let pondDirs: string[];
try {
pondDirs = await readdir(root);
} catch {
return result;
}
for (const pondId of pondDirs) {
let files: string[];
try {
files = await readdir(join(root, pondId));
} catch {
continue; // not a directory or vanished mid-walk
}
for (const fileId of files) {
try {
const info = await stat(join(root, pondId, fileId));
if (info.isFile()) result.push({ pondId, fileId, mtimeMs: info.mtimeMs });
} catch {
// vanished mid-walk — the next sweep sees the truth
}
}
}
return result;
}
} }

View File

@ -27,7 +27,6 @@ import {
RequiresPagePermission, RequiresPagePermission,
RequiresPondRole, RequiresPondRole,
} from '../permissions/permission.decorators'; } from '../permissions/permission.decorators';
import { readActorOf } from '../read-trail/read-actor';
import { FilesService } from './files.service'; import { FilesService } from './files.service';
@ -91,18 +90,14 @@ export class FilesController {
@Req() request: AuthedRequest, @Req() request: AuthedRequest,
@Res({ passthrough: true }) response: Response, @Res({ passthrough: true }) response: Response,
): Promise<StreamableFile> { ): Promise<StreamableFile> {
const { attachment, stream, inline, downloadName } = await this.files.download( const { attachment, stream, inline } = await this.files.download(request.user ?? null, fileId);
request.user ?? null,
fileId,
readActorOf(request),
);
response.set('X-Content-Type-Options', 'nosniff'); response.set('X-Content-Type-Options', 'nosniff');
// Attachments are immutable — a new upload always gets a new id. // Attachments are immutable — a new upload always gets a new id.
response.set('Cache-Control', 'private, max-age=31536000, immutable'); response.set('Cache-Control', 'private, max-age=31536000, immutable');
const kind = inline ? 'inline' : 'attachment'; const kind = inline ? 'inline' : 'attachment';
return new StreamableFile(stream, { return new StreamableFile(stream, {
type: attachment.mimeType, type: attachment.mimeType,
disposition: `${kind}; filename="${encodeURIComponent(downloadName)}"`, disposition: `${kind}; filename="${encodeURIComponent(attachment.fileName)}"`,
}); });
} }

View File

@ -301,6 +301,7 @@ describe.skipIf(!hasTestDb)('files (e2e, issue #27)', () => {
await api().get(`/api/v1/media/${uploaded.body.id}`).set('Cookie', ownerCookie).expect(200); await api().get(`/api/v1/media/${uploaded.body.id}`).set('Cookie', ownerCookie).expect(200);
const stillThere = await prisma.attachment.findUnique({ where: { id: uploaded.body.id } }); const stillThere = await prisma.attachment.findUnique({ where: { id: uploaded.body.id } });
expect(stillThere).not.toBeNull(); expect(stillThere).not.toBeNull();
expect(stillThere?.deletedAt).toBeNull();
}); });
it('lists a page attachment for the page and links it (#61)', async () => { it('lists a page attachment for the page and links it (#61)', async () => {
@ -327,121 +328,6 @@ describe.skipIf(!hasTestDb)('files (e2e, issue #27)', () => {
expect(item.pageTitle).toBe(`Page Files ${suffix}`); expect(item.pageTitle).toBe(`Page Files ${suffix}`);
}); });
it('prefixes downloads of classified attachments; unset pageId fails closed (issue #212)', async () => {
const page = await api()
.post(`/api/v1/ponds/${pondId}/pages`)
.set('Cookie', ownerCookie)
.send({ title: `Classified Files ${suffix}` })
.expect(201);
const uploaded = await api()
.post(`/api/v1/pages/${page.body.id}/files`)
.set('Cookie', ownerCookie)
.attach('file', Buffer.from('%PDF-1.4 classified content'), 'geheim.pdf')
.expect(201);
// Unclassified page: unchanged filename.
const openServed = await api()
.get(`/api/v1/media/${uploaded.body.id}`)
.set('Cookie', ownerCookie)
.buffer(true)
.parse(binaryParser as unknown as ParseCallback)
.expect(200);
expect(openServed.headers['content-disposition']).toContain('filename="geheim.pdf"');
// Classified page: the documented VS-NfD_ prefix.
await prisma.page.update({
where: { id: page.body.id as string },
data: { classification: 'VS_NFD' },
});
const served = await api()
.get(`/api/v1/media/${uploaded.body.id}`)
.set('Cookie', ownerCookie)
.buffer(true)
.parse(binaryParser as unknown as ParseCallback)
.expect(200);
expect(served.headers['content-disposition']).toContain('filename="VS-NfD_geheim.pdf"');
// pageId unset (paste-then-insert): fails closed to the pond's highest
// level — the pond now contains a classified page, so the orphan upload
// is served with the prefix too.
const orphan = await api()
.post(`/api/v1/ponds/${pondId}/files`)
.set('Cookie', ownerCookie)
.attach('file', Buffer.from('%PDF-1.4 orphan bytes'), 'lose-datei.pdf')
.expect(201);
const orphanServed = await api()
.get(`/api/v1/media/${orphan.body.id}`)
.set('Cookie', ownerCookie)
.buffer(true)
.parse(binaryParser as unknown as ParseCallback)
.expect(200);
expect(orphanServed.headers['content-disposition']).toContain(
'filename="VS-NfD_lose-datei.pdf"',
);
// Back to all-open: the orphan serves unprefixed again.
await prisma.page.update({
where: { id: page.body.id as string },
data: { classification: 'UNCLASSIFIED' },
});
const openOrphan = await api()
.get(`/api/v1/media/${orphan.body.id}`)
.set('Cookie', ownerCookie)
.buffer(true)
.parse(binaryParser as unknown as ParseCallback)
.expect(200);
expect(openOrphan.headers['content-disposition']).toContain('filename="lose-datei.pdf"');
});
it('blocks uploads to classified pages server-side when the policy says so (issue #213)', async () => {
const settings = app.get(InstanceSettingsService);
const page = await api()
.post(`/api/v1/ponds/${pondId}/pages`)
.set('Cookie', ownerCookie)
.send({ title: `Blocked Uploads ${suffix}` })
.expect(201);
await prisma.page.update({
where: { id: page.body.id as string },
data: { classification: 'VS_NFD' },
});
// Default policy `warn`: the upload is allowed (the UI shows the notice).
await api()
.post(`/api/v1/pages/${page.body.id}/files`)
.set('Cookie', ownerCookie)
.attach('file', Buffer.from('%PDF-1.4 warned upload'), 'warned.pdf')
.expect(201);
await settings.set('classification.uploadPolicy', 'block', 'test');
try {
// Enforced server-side, not only in the UI.
const blocked = await api()
.post(`/api/v1/pages/${page.body.id}/files`)
.set('Cookie', ownerCookie)
.attach('file', Buffer.from('%PDF-1.4 blocked upload'), 'blocked.pdf')
.expect(403);
expect((blocked.body as { code: string }).code).toBe('classified_upload_blocked');
// Unclassified pages stay uploadable under `block`.
const open = await api()
.post(`/api/v1/ponds/${pondId}/pages`)
.set('Cookie', ownerCookie)
.send({ title: `Open Uploads ${suffix}` })
.expect(201);
await api()
.post(`/api/v1/pages/${open.body.id}/files`)
.set('Cookie', ownerCookie)
.attach('file', Buffer.from('%PDF-1.4 open upload'), 'open.pdf')
.expect(201);
} finally {
await settings.set('classification.uploadPolicy', 'warn', 'test');
await prisma.instanceSetting.deleteMany({
where: { key: 'classification.uploadPolicy' },
});
}
});
it('pond file manager reports usage, orphans, and page links (#61)', async () => { it('pond file manager reports usage, orphans, and page links (#61)', async () => {
const page = await api() const page = await api()
.post(`/api/v1/ponds/${pondId}/pages`) .post(`/api/v1/ponds/${pondId}/pages`)

View File

@ -1,43 +1,16 @@
import { Module, OnModuleInit } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { CommonModule } from '../common/common.module';
import { PondsModule } from '../ponds/ponds.module'; import { PondsModule } from '../ponds/ponds.module';
import { QuotasModule } from '../quotas/quotas.module'; import { QuotasModule } from '../quotas/quotas.module';
import { SchedulerModule } from '../scheduler/scheduler.module';
import { SchedulerService } from '../scheduler/scheduler.service';
import { FileStorageService } from './file-storage.service'; import { FileStorageService } from './file-storage.service';
import { FilesController } from './files.controller'; import { FilesController } from './files.controller';
import { FilesService } from './files.service'; import { FilesService } from './files.service';
import { OrphanSweepService } from './orphan-sweep.service';
/** Nightly, per operations.md's maintenance-jobs table (issue #194). */
const ORPHAN_SWEEP_CADENCE_SECONDS = 24 * 60 * 60;
@Module({ @Module({
imports: [CommonModule, PondsModule, QuotasModule, SchedulerModule], imports: [PondsModule, QuotasModule],
controllers: [FilesController], controllers: [FilesController],
providers: [FilesService, FileStorageService, OrphanSweepService], providers: [FilesService, FileStorageService],
exports: [FileStorageService, FilesService], exports: [FileStorageService, FilesService],
}) })
export class FilesModule implements OnModuleInit { export class FilesModule {}
constructor(
private readonly scheduler: SchedulerService,
private readonly sweep: OrphanSweepService,
private readonly files: FilesService,
) {}
onModuleInit(): void {
this.scheduler.register({
name: 'orphan-file-sweep',
cadenceSeconds: ORPHAN_SWEEP_CADENCE_SECONDS,
run: async () => {
await this.sweep.sweep();
// Same nightly volume walk, same domain: hash rows that predate
// #199 until none remain (idempotent, bounded batch) — a separate
// scheduled job would outlive its purpose.
await this.files.backfillHashes();
},
});
}
}

View File

@ -1,11 +1,9 @@
import { createHash, randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { Readable } from 'node:stream'; import type { Readable } from 'node:stream';
import { import {
BadRequestException, BadRequestException,
ForbiddenException,
Injectable, Injectable,
InternalServerErrorException,
NotFoundException, NotFoundException,
PayloadTooLargeException, PayloadTooLargeException,
} from '@nestjs/common'; } from '@nestjs/common';
@ -14,19 +12,15 @@ import {
AttachmentListItemView, AttachmentListItemView,
AttachmentView, AttachmentView,
PondFilesView, PondFilesView,
PageClassification,
SVG_MIME_TYPE, SVG_MIME_TYPE,
classificationFilenamePrefix,
fileExtension, fileExtension,
isImageMimeType, isImageMimeType,
} from '@dorfteich/shared'; } from '@dorfteich/shared';
import { Attachment, User } from '@prisma/client'; import { Attachment, User } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino'; import { PinoLogger } from 'nestjs-pino';
import { AuditService } from '../audit/audit.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { QuotaService } from '../quotas/quota.service'; import { QuotaService } from '../quotas/quota.service';
import { ReadTrailService, type ReadActor } from '../read-trail/read-trail.service';
import { InstanceSettingsService } from '../settings/instance-settings.service'; import { InstanceSettingsService } from '../settings/instance-settings.service';
import { FileStorageService } from './file-storage.service'; import { FileStorageService } from './file-storage.service';
@ -40,11 +34,6 @@ export interface FileDownload {
* else office files, PDFs, and SVG is always sent as a download so it * else office files, PDFs, and SVG is always sent as a download so it
* can never execute inline (ADR 0011, security.md §Uploads). */ * can never execute inline (ADR 0011, security.md §Uploads). */
inline: boolean; inline: boolean;
/** The filename for the Content-Disposition (issue #212, ADR 0022): the
* original name, prefixed `VS-NfD_` when the attachment's effective
* classification is vs_nfd the one marker an arbitrary binary can
* carry. The file's CONTENT stays unmarked (documented residual risk). */
downloadName: string;
} }
/** What the upload bytes resolved to after allowlist + SVG handling. */ /** What the upload bytes resolved to after allowlist + SVG handling. */
@ -62,8 +51,6 @@ export class FilesService {
private readonly quotas: QuotaService, private readonly quotas: QuotaService,
private readonly storage: FileStorageService, private readonly storage: FileStorageService,
private readonly settings: InstanceSettingsService, private readonly settings: InstanceSettingsService,
private readonly audit: AuditService,
private readonly readTrail: ReadTrailService,
private readonly logger: PinoLogger, private readonly logger: PinoLogger,
) { ) {
this.logger.setContext(FilesService.name); this.logger.setContext(FilesService.name);
@ -166,9 +153,6 @@ export class FilesService {
sizeBytes, sizeBytes,
storagePath: `${pond.id}/${id}`, storagePath: `${pond.id}/${id}`,
uploadedBy: user.id, uploadedBy: user.id,
// Integrity hash (issue #199): computed from the exact in-memory
// bytes that were just written — never by re-reading the disk.
sha256: createHash('sha256').update(resolved.buffer).digest('hex'),
}, },
}); });
this.logger.info( this.logger.info(
@ -208,140 +192,23 @@ export class FilesService {
): Promise<AttachmentView> { ): Promise<AttachmentView> {
const page = await this.prisma.page.findFirst({ where: { id: pageId } }); const page = await this.prisma.page.findFirst({ where: { id: pageId } });
if (!page) throw new NotFoundException(); if (!page) throw new NotFoundException();
// Attaching to a classified page (issue #213, ADR 0022): the file will
// inherit a classification its content cannot carry (#212). The UI warns;
// the instance can harden the warning into a server-side block — enforced
// HERE, not only client-side.
if (page.classification === 'VS_NFD') {
const policy = await this.settings.get('classification.uploadPolicy');
if (policy === 'block') {
throw new ForbiddenException({ code: 'classified_upload_blocked' });
}
}
return this.upload(user, page.pondId, file, page.id); return this.upload(user, page.pondId, file, page.id);
} }
/** async download(_user: User | null, id: string): Promise<FileDownload> {
* Serve an attachment, verifying its integrity first (issue #199): the
* whole object is read and hashed BEFORE the first byte leaves a stream
* cannot be un-sent, so verification must precede serving. Memory is
* bounded by the `max_file_bytes` quota that gated the upload. A mismatch
* fails closed with its own error code and lands in the audit trail (a
* security event, not content activity); the operator's move is a restore
* from backup (runbook). Rows that predate #199 (sha256 still null until
* the nightly backfill reaches them) are served unverified that is the
* pre-#199 status quo, not a downgrade.
*/
async download(_user: User | null, id: string, read: ReadActor): Promise<FileDownload> {
const attachment = await this.prisma.attachment.findFirst({ where: { id } }); const attachment = await this.prisma.attachment.findFirst({ where: { id } });
if (!attachment) throw new NotFoundException(); if (!attachment) throw new NotFoundException();
const buffer = await this.storage.read(attachment.pondId, attachment.id).catch(() => null);
if (!buffer) throw new NotFoundException();
if (attachment.sha256) {
const actual = createHash('sha256').update(buffer).digest('hex');
if (actual !== attachment.sha256) {
await this.audit.record({
action: 'file.integrity_failed',
targetType: 'attachment',
targetId: attachment.id,
details: { pondId: attachment.pondId, expected: attachment.sha256, actual },
});
throw new InternalServerErrorException({ code: 'attachment_integrity_failure' });
}
}
const classification = await this.effectiveClassification(attachment);
// Read trail (issue #222): a download whose effective classification is
// vs_nfd (#212 semantics — page level, pond max when page-less) is a read
// of classified content. `pageId` may be null for pond-level files; the
// attachment id in `details` keeps the object identifiable.
if (classification === 'vs_nfd') {
await this.readTrail.record({
...read,
pageId: attachment.pageId,
pondId: attachment.pondId,
channel: 'attachment',
details: { attachmentId: attachment.id },
});
}
return { return {
attachment, attachment,
stream: Readable.from(buffer), stream: this.storage.createReadStream(attachment.pondId, attachment.id),
inline: isImageMimeType(attachment.mimeType), inline: isImageMimeType(attachment.mimeType),
downloadName: `${classificationFilenamePrefix(classification)}${attachment.fileName}`,
}; };
} }
/**
* The classification an attachment inherits (issue #212, ADR 0022): its
* page's level. An attachment whose `pageId` is still unset
* (paste-then-insert, pond-level files) FAILS CLOSED to the highest level
* of any live page in its pond it could belong to any of them, so it is
* treated as classified as the most classified candidate. In an all-open
* pond that is `unclassified`, so nothing gets marked noise.
*/
private async effectiveClassification(attachment: Attachment): Promise<PageClassification> {
if (attachment.pageId) {
const page = await this.prisma.page.findUnique({
where: { id: attachment.pageId },
select: { classification: true },
});
if (page) return page.classification.toLowerCase() as PageClassification;
// Page row gone but link set (race with purge): fall through to the
// pond-wide fail-closed answer below.
}
const classified = await this.prisma.page.findFirst({
where: { pondId: attachment.pondId, deletedAt: null, classification: 'VS_NFD' },
select: { id: true },
});
return classified ? 'vs_nfd' : 'unclassified';
}
/**
* Hash attachments that predate #199 (sha256 null), a bounded batch per
* nightly run until none remain idempotent by construction (hashed rows
* stop matching). An unreadable file is reported (log + count) and left
* null so the next run retries it; the orphan sweep is the mechanism that
* eventually explains truly missing bytes.
*/
async backfillHashes(limit = 1000): Promise<{ hashed: number; unreadable: number }> {
const rows = await this.prisma.attachment.findMany({
where: { sha256: null },
select: { id: true, pondId: true },
take: limit,
});
let hashed = 0;
let unreadable = 0;
for (const row of rows) {
let buffer: Buffer;
try {
buffer = await this.storage.read(row.pondId, row.id);
} catch (error) {
unreadable += 1;
this.logger.error(
{ attachmentId: row.id, pondId: row.pondId, err: error },
'attachment unreadable during hash backfill; will retry next run',
);
continue;
}
await this.prisma.attachment.update({
where: { id: row.id },
data: { sha256: createHash('sha256').update(buffer).digest('hex') },
});
hashed += 1;
}
if (rows.length > 0) {
this.logger.info(
{ hashed, unreadable, batch: rows.length, batchLimit: limit },
'audit: attachment hash backfill progress',
);
}
return { hashed, unreadable };
}
/** Attachments linked to a page, for its attachments section (#61). */ /** Attachments linked to a page, for its attachments section (#61). */
async listForPage(pageId: string): Promise<AttachmentListItemView[]> { async listForPage(pageId: string): Promise<AttachmentListItemView[]> {
const rows = await this.prisma.attachment.findMany({ const rows = await this.prisma.attachment.findMany({
where: { pageId }, where: { pageId, deletedAt: null },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
include: { uploader: true, page: true }, include: { uploader: true, page: true },
}); });
@ -352,7 +219,7 @@ export class FilesService {
async listForPond(pondId: string): Promise<PondFilesView> { async listForPond(pondId: string): Promise<PondFilesView> {
const [rows, usage, storageBytesLimit] = await Promise.all([ const [rows, usage, storageBytesLimit] = await Promise.all([
this.prisma.attachment.findMany({ this.prisma.attachment.findMany({
where: { pondId }, where: { pondId, deletedAt: null },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
include: { uploader: true, page: true }, include: { uploader: true, page: true },
}), }),

View File

@ -1,179 +0,0 @@
import { existsSync } from 'node:fs';
import { utimes, writeFile, mkdir } from 'node:fs/promises';
import { join } from 'node:path';
import { INestApplication } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createTestApp, sessionCookieOf } from '../testing/test-app';
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service';
import { OrphanSweepService } from './orphan-sweep.service';
const HOUR = 60 * 60 * 1000;
/**
* Orphan-file sweep (issue #194): unclaimed attachments past the grace
* period are reclaimed (row, file, quota), fresh ones are protected
* (paste-then-insert), claimed ones are never touched the page
* attachments panel is a legitimate reference and stray files without a
* database row disappear once old enough.
*/
describe.skipIf(!hasTestDb)('orphan file sweep (e2e, issue #194)', () => {
let app: INestApplication;
let prisma: PrismaClient;
const suffix = uniqueSuffix();
const password = 'orphan sweep pass 1';
const ids: Record<string, string> = {};
const cookies: Record<string, string> = {};
let pondId: string;
let pageId: string;
const api = () => request(app.getHttpServer());
const fileOnDisk = (fondId: string, fileId: string) =>
join(process.env.UPLOADS_DIR!, fondId, fileId);
async function makeUser(handle: string, siteAdmin = false): Promise<void> {
const users = app.get(UsersService);
const username = `os-${handle}-${suffix}`;
const user = await users.createUser({
username,
email: `${username}@example.org`,
displayName: `Sweep ${handle}`,
password,
locale: 'en',
});
ids[handle] = user.id;
await users.markEmailVerified(user.id);
if (siteAdmin) {
await prisma.user.update({ where: { id: user.id }, data: { isSiteAdmin: true } });
}
cookies[handle] = sessionCookieOf(
await api()
.post('/api/v1/auth/login')
.send({ usernameOrEmail: username, password })
.expect(200),
);
}
/** A real upload via the pond route (pageId stays null = unclaimed). */
async function uploadUnclaimed(name: string): Promise<string> {
const res = await api()
.post(`/api/v1/ponds/${pondId}/files`)
.set('Cookie', cookies.owner!)
.attach('file', Buffer.from(`bytes of ${name}`), name)
.expect(201);
return res.body.id as string;
}
function backdate(attachmentId: string, ageMs: number): Promise<unknown> {
return prisma.attachment.update({
where: { id: attachmentId },
data: { createdAt: new Date(Date.now() - ageMs) },
});
}
beforeAll(async () => {
prisma = createTestPrisma();
await prisma.rateLimit.deleteMany({});
app = await createTestApp();
await makeUser('owner');
await makeUser('admin', true);
await api()
.put(`/api/v1/admin/quotas/user/${ids.owner!}/additional_ponds`)
.set('Cookie', cookies.admin!)
.send({ value: 5 })
.expect(200);
const pond = await api()
.post('/api/v1/ponds')
.set('Cookie', cookies.owner!)
.send({ name: `Sweep Pond ${suffix}` })
.expect(201);
pondId = pond.body.id;
const page = await api()
.post(`/api/v1/ponds/${pondId}/pages`)
.set('Cookie', cookies.owner!)
.send({ title: `Sweep Page ${suffix}` })
.expect(201);
pageId = page.body.id;
});
afterAll(async () => {
const all = Object.values(ids);
await prisma.quotaOverride.deleteMany({ where: { subjectId: { in: all } } });
await prisma.auditEntry.deleteMany({ where: { actorId: { in: all } } });
const ponds = await prisma.pond.findMany({
where: { ownerId: { in: all } },
select: { id: true },
});
const pondIds = ponds.map((p) => p.id);
await prisma.attachment.deleteMany({ where: { pondId: { in: pondIds } } });
await prisma.page.deleteMany({ where: { pondId: { in: pondIds } } });
await prisma.pond.deleteMany({ where: { id: { in: pondIds } } });
await prisma.watch.deleteMany({ where: { userId: { in: all } } });
await prisma.session.deleteMany({ where: { userId: { in: all } } });
await prisma.userIdentity.deleteMany({ where: { userId: { in: all } } });
await prisma.user.deleteMany({ where: { id: { in: all } } });
await prisma.$disconnect();
await app.close();
});
it('reclaims unclaimed attachments past the grace period, protects fresh and claimed ones', async () => {
const oldUnclaimed = await uploadUnclaimed('old-unclaimed.txt');
const freshUnclaimed = await uploadUnclaimed('fresh-unclaimed.txt');
const oldClaimed = await api()
.post(`/api/v1/pages/${pageId}/files`)
.set('Cookie', cookies.owner!)
.attach('file', Buffer.from('panel asset'), 'panel-asset.txt')
.expect(201);
await backdate(oldUnclaimed, 25 * HOUR);
await backdate(oldClaimed.body.id, 25 * HOUR);
const usageBefore = await prisma.pondUsage.findUnique({ where: { pondId } });
const reclaimedBytes = (
await prisma.attachment.findUniqueOrThrow({
where: { id: oldUnclaimed },
})
).sizeBytes;
const result = await app.get(OrphanSweepService).sweep();
expect(result.reclaimed).toBeGreaterThanOrEqual(1);
// The old unclaimed upload is gone: row, file, quota.
expect(await prisma.attachment.findUnique({ where: { id: oldUnclaimed } })).toBeNull();
expect(existsSync(fileOnDisk(pondId, oldUnclaimed))).toBe(false);
const usageAfter = await prisma.pondUsage.findUnique({ where: { pondId } });
expect(Number(usageBefore!.storageBytesUsed) - Number(usageAfter!.storageBytesUsed)).toBe(
reclaimedBytes,
);
// The fresh unclaimed upload survives (paste-then-insert grace).
expect(await prisma.attachment.findUnique({ where: { id: freshUnclaimed } })).not.toBeNull();
expect(existsSync(fileOnDisk(pondId, freshUnclaimed))).toBe(true);
// The claimed panel asset survives despite its age — never swept.
expect(
await prisma.attachment.findUnique({ where: { id: oldClaimed.body.id } }),
).not.toBeNull();
expect(existsSync(fileOnDisk(pondId, oldClaimed.body.id))).toBe(true);
});
it('removes stray files without a database row once they are old enough', async () => {
const dir = join(process.env.UPLOADS_DIR!, pondId);
await mkdir(dir, { recursive: true });
const oldStray = join(dir, `stray-old-${suffix}`);
const freshStray = join(dir, `stray-fresh-${suffix}`);
await writeFile(oldStray, 'stray bytes');
await writeFile(freshStray, 'stray bytes');
const past = new Date(Date.now() - 25 * HOUR);
await utimes(oldStray, past, past);
const result = await app.get(OrphanSweepService).sweep();
expect(existsSync(oldStray)).toBe(false);
expect(existsSync(freshStray)).toBe(true);
expect(result.strays).toBeGreaterThanOrEqual(1);
});
});

View File

@ -1,81 +0,0 @@
import { Injectable } from '@nestjs/common';
import { PinoLogger } from 'nestjs-pino';
import { ClockService } from '../common/clock.service';
import { PrismaService } from '../prisma/prisma.service';
import { QuotaService } from '../quotas/quota.service';
import { FileStorageService } from './file-storage.service';
/**
* Nightly orphan-file sweep (issue #194, ADR 0011) with two directions:
*
* 1. UNCLAIMED ROWS: an attachment whose `pageId` is still null after the
* grace period was claimed by nothing not by a collab persist (which
* claims every embedded image, apps/collab persistence), not by a page
* upload (#61), not by an import (`linkAttachmentsToPage`). The pond
* file manager flags exactly these as orphans; the sweep reclaims them
* (row + file + quota). The grace period protects paste-then-insert:
* an upload is unclaimed until the ~2 s-debounced persist runs.
*
* 2. STRAY FILES: bytes on the uploads volume without a database row
* (volumeDB drift, e.g. a crash between file write and row insert).
* Removed once older than the grace period; no quota to correct the
* reservation was rolled back with the failed upload.
*
* Deliberately NOT swept: claimed attachments whose page content no longer
* references them. The page attachments panel lists claimed files as
* user-managed objects inserting into the document is optional there
* so "not embedded" is not "unused"; auto-deleting would destroy panel
* assets. Humans clean those up in the panel or the pond file manager.
*/
const GRACE_MS = 24 * 60 * 60 * 1000;
@Injectable()
export class OrphanSweepService {
constructor(
private readonly prisma: PrismaService,
private readonly storage: FileStorageService,
private readonly quotas: QuotaService,
private readonly clock: ClockService,
private readonly logger: PinoLogger,
) {
this.logger.setContext(OrphanSweepService.name);
}
async sweep(): Promise<{ reclaimed: number; strays: number }> {
const cutoff = this.clock.now().getTime() - GRACE_MS;
const unclaimed = await this.prisma.attachment.findMany({
where: { pageId: null, createdAt: { lte: new Date(cutoff) } },
});
for (const attachment of unclaimed) {
// File first (idempotent), then row + quota — a crash in between
// leaves a row the next sweep finishes, never untracked bytes.
await this.storage.delete(attachment.pondId, attachment.id);
await this.prisma.attachment.deleteMany({ where: { id: attachment.id } });
await this.quotas.release(attachment.pondId, attachment.sizeBytes);
this.logger.info(
{ attachmentId: attachment.id, pondId: attachment.pondId },
'audit: orphaned attachment reclaimed',
);
}
let strays = 0;
const stored = await this.storage.listStored();
const ids = new Set(
(await this.prisma.attachment.findMany({ select: { id: true } })).map((a) => a.id),
);
for (const file of stored) {
if (ids.has(file.fileId) || file.mtimeMs > cutoff) continue;
await this.storage.delete(file.pondId, file.fileId);
strays += 1;
this.logger.info(
{ fileId: file.fileId, pondId: file.pondId },
'audit: stray file without database row removed',
);
}
return { reclaimed: unclaimed.length, strays };
}
}

View File

@ -1,55 +0,0 @@
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { Injectable } from '@nestjs/common';
import type { FontUploadFormat } from '@dorfteich/shared';
import { AppConfig } from '../config/app-config.service';
/**
* Filesystem binding for operator-uploaded fonts (issue #303, ADR 0016 §#303).
*
* The layout mirrors the baked-in catalog `<slug>/<slug>-<weight>.woff2`
* so the PDF exporter's `@font-face` builder needs no special case beyond
* choosing the directory.
*
* That directory is `CUSTOM_FONTS_DIR`, NOT `FONTS_DIR`: the latter is baked
* into the image, so anything written there disappears on the next deploy and
* never reaches a backup. This one is a sibling of the uploads and plugins
* mounts and travels in the restore set (`apps/backup/src/data-dirs.ts`).
*/
@Injectable()
export class CustomFontStorageService {
constructor(private readonly config: AppConfig) {}
private dirFor(slug: string): string {
return join(this.config.env.CUSTOM_FONTS_DIR, slug);
}
fileNameFor(slug: string, weight: number, format: FontUploadFormat): string {
return `${slug}-${weight}.${format}`;
}
pathFor(slug: string, weight: number, format: FontUploadFormat): string {
return join(this.dirFor(slug), this.fileNameFor(slug, weight, format));
}
async save(slug: string, weight: number, format: FontUploadFormat, bytes: Buffer): Promise<void> {
await mkdir(this.dirFor(slug), { recursive: true });
await writeFile(this.pathFor(slug, weight, format), bytes);
}
read(slug: string, weight: number, format: FontUploadFormat): Promise<Buffer> {
return readFile(this.pathFor(slug, weight, format));
}
/** Removes the family's whole directory. Missing is fine deletion must
* stay idempotent so a half-failed upload can still be cleaned up. */
async deleteFamily(slug: string): Promise<void> {
await rm(this.dirFor(slug), { recursive: true, force: true });
}
async deleteWeight(slug: string, weight: number, format: FontUploadFormat): Promise<void> {
await rm(this.pathFor(slug, weight, format), { force: true });
}
}

View File

@ -1,164 +0,0 @@
import {
BadRequestException,
Controller,
Delete,
Get,
HttpCode,
Param,
Post,
Req,
Res,
UploadedFiles,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { AnyFilesInterceptor } from '@nestjs/platform-express';
import {
CustomFontView,
FONT_WEIGHTS,
MAX_FONT_FILE_BYTES,
createCustomFontInputSchema,
} from '@dorfteich/shared';
import type { Response } from 'express';
import { SiteAdminGuard } from '../admin/site-admin.guard';
import { AuthedRequest, Public } from '../auth/auth.guard';
import { AuthenticatedOnly } from '../permissions/permission.decorators';
import { CustomFontStorageService } from './custom-font-storage.service';
import { CustomFontsService, WeightUpload } from './custom-fonts.service';
/** Multipart field names: `woff2-<weight>` and the optional `woff-<weight>`. */
const FILE_FIELD = /^(woff2|woff)-(\d{3})$/;
function parseUploads(files: Express.Multer.File[] | undefined): WeightUpload[] {
const byWeight = new Map<number, WeightUpload>();
for (const file of files ?? []) {
const match = FILE_FIELD.exec(file.fieldname);
if (!match) throw new BadRequestException({ code: 'font_unexpected_field' });
const weight = Number(match[2]);
if (!(FONT_WEIGHTS as readonly number[]).includes(weight)) {
throw new BadRequestException({ code: 'font_weight_invalid' });
}
const entry = byWeight.get(weight) ?? { weight, woff2: Buffer.alloc(0) };
if (match[1] === 'woff2') entry.woff2 = file.buffer;
else entry.woff = file.buffer;
byWeight.set(weight, entry);
}
// A WOFF without its WOFF2 would produce a weight the PDF path cannot
// embed — the exporter reads WOFF2 only.
for (const entry of byWeight.values()) {
if (entry.woff2.length === 0) throw new BadRequestException({ code: 'font_woff2_missing' });
}
return [...byWeight.values()].sort((a, b) => a.weight - b.weight);
}
/** Site-Admin management of operator-uploaded fonts (issue #303). */
@Controller('admin/fonts')
@UseGuards(SiteAdminGuard)
export class CustomFontsAdminController {
constructor(private readonly fonts: CustomFontsService) {}
@Get()
list(): Promise<CustomFontView[]> {
return this.fonts.list();
}
@Post()
@UseInterceptors(AnyFilesInterceptor({ limits: { fileSize: MAX_FONT_FILE_BYTES } }))
async create(
@Req() request: AuthedRequest,
@UploadedFiles() files: Express.Multer.File[] | undefined,
): Promise<CustomFontView> {
// The metadata rides as ordinary multipart fields next to the files.
const input = createCustomFontInputSchema.parse({
family: request.body?.family,
category: request.body?.category,
licence: request.body?.licence,
licenceUrl: request.body?.licenceUrl || null,
});
return this.fonts.create(request.user!, input, parseUploads(files));
}
@Post(':id/weights')
@UseInterceptors(AnyFilesInterceptor({ limits: { fileSize: MAX_FONT_FILE_BYTES } }))
async addWeight(
@Param('id') id: string,
@Req() request: AuthedRequest,
@UploadedFiles() files: Express.Multer.File[] | undefined,
): Promise<CustomFontView> {
const uploads = parseUploads(files);
if (uploads.length !== 1) throw new BadRequestException({ code: 'font_one_weight_expected' });
return this.fonts.addWeight(request.user!, id, uploads[0]!);
}
/** How many live ponds still use the family — shown before deleting. */
@Get(':id/usage')
async usage(@Param('id') id: string): Promise<{ pondsAffected: number }> {
const font = (await this.fonts.list()).find((entry) => entry.id === id);
if (!font) throw new BadRequestException({ code: 'not_found' });
return { pondsAffected: await this.fonts.pondsUsing(font.family) };
}
@Delete(':id')
@HttpCode(204)
async remove(@Param('id') id: string, @Req() request: AuthedRequest): Promise<void> {
await this.fonts.remove(request.user!, id);
}
}
/**
* Reading side of the uploaded fonts: the family list every signed-in user
* needs, and the bytes themselves.
*
* The listing is NOT site-admin-gated (issue #304): every signed-in user picks
* fonts in their pond's Appearance settings, reads the licence page, and needs
* the `@font-face` rules injected the admin list at `/admin/fonts` carries
* the same data, so gating this one would only force a second, admin-only UI.
*
* The file route is unauthenticated on purpose: a font is referenced from CSS,
* and the login screen carries the pond-independent chrome an authenticated
* font URL would simply not load. The bytes are branding, not content.
*/
@Controller('fonts/custom')
export class CustomFontsFileController {
constructor(
private readonly storage: CustomFontStorageService,
private readonly fonts: CustomFontsService,
) {}
// Explicit access declaration, as every route needs (issue #52's fence
// `route-permissions.e2e.db.test.ts`): a session, no further permission —
// the list says which families exist, which is what the pickers offer.
@AuthenticatedOnly()
@Get()
list(): Promise<CustomFontView[]> {
return this.fonts.list();
}
@Public()
@Get(':slug/:file')
async serve(
@Param('slug') slug: string,
@Param('file') file: string,
@Res() res: Response,
): Promise<void> {
const match = /^([a-z0-9-]+)-(\d{3})\.(woff2|woff)$/.exec(file);
// The slug must match the file's own prefix, so the path cannot be used
// to reach a different family's directory.
if (!match || match[1] !== slug) throw new BadRequestException({ code: 'not_found' });
const known = (await this.fonts.list()).find((entry) => entry.slug === slug);
if (!known) throw new BadRequestException({ code: 'not_found' });
const format = match[3] as 'woff2' | 'woff';
const bytes = await this.storage
.read(slug, Number(match[2]), format)
.catch(() => Promise.reject(new BadRequestException({ code: 'not_found' })));
res.setHeader('Content-Type', format === 'woff2' ? 'font/woff2' : 'font/woff');
// Slug + weight + format identify the bytes; a changed family is a new
// upload under a new id, so a long lifetime is safe.
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
res.send(bytes);
}
}

Some files were not shown because too many files have changed in this diff Show More