Compare commits

..

No commits in common. "main" and "issue-228-security-documentation" have entirely different histories.

291 changed files with 915 additions and 17624 deletions

View File

@ -107,24 +107,6 @@ jobs:
exit 1 exit 1
fi 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
@ -210,16 +192,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 +318,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 +338,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%';" | \
@ -660,37 +611,6 @@ jobs:
E2E_BASE_URL=http://localhost:5173 \ E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/a11y.spec.ts 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). # Hell/Dunkel/System-Umschalter (issue #180).
- name: Run theme pack - name: Run theme pack
run: | run: |

View File

@ -27,3 +27,13 @@ AA) — nicht nachträglich. Kurzfassung; Details und Begründung in
machen — betroffene Specs mit anpassen (scopen), nicht das Label opfern. machen — betroffene Specs mit anpassen (scopen), nicht das Label opfern.
Verstöße gelten in Review und Abnahme als Funktionsfehler. Verstöße gelten in Review und Abnahme als Funktionsfehler.
## graphify
This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.
Rules:
- For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output.
- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing.
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context.
- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost).

View File

@ -29,19 +29,18 @@ 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 \

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 683 B

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,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")
@ -67,35 +62,10 @@ model User {
watches Watch[] watches Watch[]
notifications Notification[] notifications Notification[]
favorites PageFavorite[] 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 +91,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 +239,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")
@ -910,8 +830,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 +856,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

@ -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,7 +2,6 @@ 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 { SearchModule } from '../search/search.module';
@ -20,15 +19,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, SearchModule],
QuotasModule,
UsersModule,
AuthModule,
SchedulerModule,
BackupModule,
SearchModule,
PondsModule,
],
controllers: [ controllers: [
AdminSettingsController, AdminSettingsController,
BackupAdminController, BackupAdminController,

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

@ -6,7 +6,6 @@ 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 { maskTokenParam } from './common/mask-token-param';
import { SecurityHeadersMiddleware } from './common/security-headers.middleware'; import { SecurityHeadersMiddleware } from './common/security-headers.middleware';
@ -18,7 +17,6 @@ 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 { 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 +32,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';
@ -50,7 +47,6 @@ import { VersionsModule } from './versions/versions.module';
ConfigModule, ConfigModule,
PrismaModule, PrismaModule,
AuditModule, AuditModule,
ReadTrailModule,
RateLimitModule, RateLimitModule,
MailModule, MailModule,
SettingsModule, SettingsModule,
@ -83,8 +79,6 @@ import { VersionsModule } from './versions/versions.module';
PublicModule, PublicModule,
PublicApiModule, PublicApiModule,
McpModule, McpModule,
BrandingModule,
FontsModule,
ImportExportModule, ImportExportModule,
PluginsModule, PluginsModule,
AuthModule, AuthModule,

View File

@ -17,11 +17,9 @@ export const AUDIT_EVENTS = {
'api.write': { severity: 'info' }, 'api.write': { severity: 'info' },
'audit.pruned': { severity: 'info' }, 'audit.pruned': { severity: 'info' },
'auth.email_verified': { severity: 'info' }, 'auth.email_verified': { severity: 'info' },
'auth.identity_linked': { severity: 'notice' },
'auth.login_failed': { severity: 'warning' }, 'auth.login_failed': { severity: 'warning' },
'auth.login_succeeded': { severity: 'info' }, 'auth.login_succeeded': { severity: 'info' },
'auth.password_reset': { severity: 'notice' }, 'auth.password_reset': { severity: 'notice' },
'auth.proxy_rejected': { severity: 'warning' },
'auth.signup': { severity: 'info' }, 'auth.signup': { severity: 'info' },
'backup.restore_requested': { severity: 'warning' }, 'backup.restore_requested': { severity: 'warning' },
'backup.run_triggered': { severity: 'info' }, 'backup.run_triggered': { severity: 'info' },
@ -29,9 +27,6 @@ export const AUDIT_EVENTS = {
'file.integrity_failed': { severity: 'critical' }, 'file.integrity_failed': { severity: 'critical' },
'grant.created': { severity: 'notice' }, 'grant.created': { severity: 'notice' },
'grant.deleted': { severity: 'notice' }, 'grant.deleted': { severity: 'notice' },
'invitation.accepted': { severity: 'notice' },
'invitation.created': { severity: 'info' },
'invitation.revoked': { severity: 'info' },
'job.triggered': { severity: 'info' }, 'job.triggered': { severity: 'info' },
'member.added': { severity: 'notice' }, 'member.added': { severity: 'notice' },
'member.removed': { severity: 'notice' }, 'member.removed': { severity: 'notice' },
@ -39,24 +34,17 @@ export const AUDIT_EVENTS = {
'page.classification_lowered': { severity: 'warning' }, 'page.classification_lowered': { severity: 'warning' },
'page.classification_raised': { severity: 'notice' }, 'page.classification_raised': { severity: 'notice' },
'plugin.installed': { severity: 'notice' }, 'plugin.installed': { severity: 'notice' },
'plugin.rejected': { severity: 'warning' },
'plugin.mode_set': { severity: 'notice' }, 'plugin.mode_set': { severity: 'notice' },
'plugin.pond_toggled': { severity: 'info' }, 'plugin.pond_toggled': { severity: 'info' },
'plugin.uninstalled': { severity: 'notice' }, 'plugin.uninstalled': { severity: 'notice' },
'pond.archived': { severity: 'notice' },
'pond.purged': { severity: 'notice' }, 'pond.purged': { severity: 'notice' },
'quota.override_cleared': { severity: 'notice' }, 'quota.override_cleared': { severity: 'notice' },
'quota.override_set': { severity: 'notice' }, 'quota.override_set': { severity: 'notice' },
'read_trail.pruned': { severity: 'info' },
'settings.changed': { severity: 'notice' }, 'settings.changed': { severity: 'notice' },
'branding.changed': { severity: 'notice' },
'font.uploaded': { severity: 'notice' },
'font.deleted': { severity: 'notice' },
'setup.admin_created': { severity: 'notice' }, 'setup.admin_created': { severity: 'notice' },
'setup.completed': { severity: 'info' }, 'setup.completed': { severity: 'info' },
'setup.preseeded': { severity: 'info' }, 'setup.preseeded': { severity: 'info' },
'setup.smtp_stored': { severity: 'info' }, 'setup.smtp_stored': { severity: 'info' },
'user.created_by_admin': { severity: 'notice' },
'user.deleted': { severity: 'notice' }, 'user.deleted': { severity: 'notice' },
'user.disabled_set': { severity: 'notice' }, 'user.disabled_set': { severity: 'notice' },
'user.pseudonymized': { severity: 'notice' }, 'user.pseudonymized': { severity: 'notice' },

View File

@ -18,12 +18,11 @@ import { AUDIT_EVENTS } from './audit-actions';
const doc = readFileSync(join(__dirname, '../../../../docs/architecture/audit-events.md'), 'utf8'); const doc = readFileSync(join(__dirname, '../../../../docs/architecture/audit-events.md'), 'utf8');
/** Event rows are `| \`ns.event\` | trigger | severity | ` the dot in the /** Event rows are `| \`ns.event\` | trigger | severity | ` the dot in the
* id keeps field-set rows (`msg`, `severity`, ) out of the match. The * id keeps field-set rows (`msg`, `severity`, ) out of the match. */
* namespace may carry an underscore since `read_trail.*` (issue #224). */
function documentedEvents(): Map<string, string> { function documentedEvents(): Map<string, string> {
const events = new Map<string, string>(); const events = new Map<string, string>();
for (const line of doc.split('\n')) { for (const line of doc.split('\n')) {
const id = /^\| `([a-z_]+\.[a-z_]+)` +\|/.exec(line)?.[1]; const id = /^\| `([a-z]+\.[a-z_]+)` +\|/.exec(line)?.[1];
if (!id) continue; if (!id) continue;
const cells = line.split('|').map((cell) => cell.trim()); const cells = line.split('|').map((cell) => cell.trim());
// cells[0] is the empty string before the leading pipe. // cells[0] is the empty string before the leading pipe.

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,14 +20,12 @@ 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, sessionAbsoluteMs } 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
@ -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(
@ -141,7 +121,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 +131,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;
@ -98,38 +84,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;

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,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,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

@ -7,7 +7,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { AuthTokensService } from '../auth/auth-tokens.service'; import { AuthTokensService } from '../auth/auth-tokens.service';
import { createTestApp } from '../testing/test-app'; import { createTestApp } 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';
import { FileStorageService } from './file-storage.service'; import { FileStorageService } from './file-storage.service';
@ -74,7 +74,7 @@ describe.skipIf(!hasTestDb)('attachment integrity (e2e, issue #199)', () => {
await prisma.attachment.deleteMany({ where: { pondId } }); await prisma.attachment.deleteMany({ where: { pondId } });
const where = { pond: { owner: { username: { contains: suffix } } } }; const where = { pond: { owner: { username: { contains: suffix } } } };
await prisma.roleGrant.deleteMany({ where }); await prisma.roleGrant.deleteMany({ where });
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.$disconnect(); await prisma.$disconnect();
await app.close(); await app.close();
@ -90,16 +90,14 @@ describe.skipIf(!hasTestDb)('attachment integrity (e2e, issue #199)', () => {
const { id, bytes } = await uploadPng('will-be-tampered'); const { id, bytes } = await uploadPng('will-be-tampered');
// Intact: the download succeeds and streams the exact bytes. // Intact: the download succeeds and streams the exact bytes.
const intact = await files.download(null, id, { actorId: null, sessionKey: 'anon' }); const intact = await files.download(null, id);
const chunks: Buffer[] = []; const chunks: Buffer[] = [];
for await (const chunk of intact.stream) chunks.push(chunk as Buffer); for await (const chunk of intact.stream) chunks.push(chunk as Buffer);
expect(Buffer.concat(chunks).equals(bytes)).toBe(true); expect(Buffer.concat(chunks).equals(bytes)).toBe(true);
// Tampered on disk (row untouched): fail closed with the dedicated code. // Tampered on disk (row untouched): fail closed with the dedicated code.
await storage.save(pondId, id, pngBuffer('evil-replacement')); await storage.save(pondId, id, pngBuffer('evil-replacement'));
const failure = await files const failure = await files.download(null, id).catch((error: unknown) => error);
.download(null, id, { actorId: null, sessionKey: 'anon' })
.catch((error: unknown) => error);
expect(failure).toBeInstanceOf(InternalServerErrorException); expect(failure).toBeInstanceOf(InternalServerErrorException);
expect((failure as InternalServerErrorException).getResponse()).toMatchObject({ expect((failure as InternalServerErrorException).getResponse()).toMatchObject({
code: 'attachment_integrity_failure', code: 'attachment_integrity_failure',
@ -126,10 +124,7 @@ describe.skipIf(!hasTestDb)('attachment integrity (e2e, issue #199)', () => {
await storage.delete(pondId, unreadable.id); await storage.delete(pondId, unreadable.id);
// A null-hash row is served unverified (pre-#199 status quo). // A null-hash row is served unverified (pre-#199 status quo).
const unverified = await files.download(null, readable.id, { const unverified = await files.download(null, readable.id);
actorId: null,
sessionKey: 'anon',
});
expect(unverified.attachment.sha256).toBeNull(); expect(unverified.attachment.sha256).toBeNull();
const first = await files.backfillHashes(); const first = await files.backfillHashes();

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';
@ -94,7 +93,6 @@ export class FilesController {
const { attachment, stream, inline, downloadName } = await this.files.download( const { attachment, stream, inline, downloadName } = await this.files.download(
request.user ?? null, request.user ?? null,
fileId, 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.

View File

@ -26,7 +26,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 { 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';
@ -63,7 +62,6 @@ export class FilesService {
private readonly storage: FileStorageService, private readonly storage: FileStorageService,
private readonly settings: InstanceSettingsService, private readonly settings: InstanceSettingsService,
private readonly audit: AuditService, 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);
@ -232,7 +230,7 @@ export class FilesService {
* the nightly backfill reaches them) are served unverified that is the * the nightly backfill reaches them) are served unverified that is the
* pre-#199 status quo, not a downgrade. * pre-#199 status quo, not a downgrade.
*/ */
async download(_user: User | null, id: string, read: ReadActor): Promise<FileDownload> { async download(_user: User | null, id: string): 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); const buffer = await this.storage.read(attachment.pondId, attachment.id).catch(() => null);
@ -250,19 +248,6 @@ export class FilesService {
} }
} }
const classification = await this.effectiveClassification(attachment); 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: Readable.from(buffer),

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);
}
}

View File

@ -1,244 +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, deletePondsWhere, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service';
/** Smallest bytes that pass the magic check — the api never parses further. */
const woff2 = (): Buffer => Buffer.concat([Buffer.from('wOF2'), Buffer.alloc(64)]);
const woff = (): Buffer => Buffer.concat([Buffer.from('wOFF'), Buffer.alloc(64)]);
describe.skipIf(!hasTestDb)('custom fonts (e2e, issue #303)', () => {
let app: INestApplication;
let prisma: PrismaClient;
let fontsDir: string;
const suffix = uniqueSuffix();
const password = 'schriftverwaltung mit stil 1';
const admin = { username: `fa-${suffix}`, displayName: `Font Admin ${suffix}` };
const plain = { username: `fp-${suffix}`, displayName: `Font Plain ${suffix}` };
let adminCookie: string;
let plainCookie: string;
const api = () => request(app.getHttpServer());
beforeAll(async () => {
prisma = createTestPrisma();
await prisma.rateLimit.deleteMany({});
// A real directory so the storage layer is exercised, not mocked — the
// point of this suite is that bytes actually land somewhere retrievable.
fontsDir = await mkdtemp(join(tmpdir(), 'dorfteich-fonts-'));
process.env.CUSTOM_FONTS_DIR = fontsDir;
app = await createTestApp();
const users = app.get(UsersService);
const adminUser = await users.createUser({
username: admin.username,
email: `${admin.username}@example.org`,
displayName: admin.displayName,
password,
locale: 'en',
});
await users.markEmailVerified(adminUser.id);
await prisma.user.update({ where: { id: adminUser.id }, data: { isSiteAdmin: true } });
// additional_ponds defaults to 0 (ADR 0011) and the instance default is
// never raised — the usage test needs a pond, so grant an override.
await prisma.quotaOverride.create({
data: {
subjectType: 'USER',
subjectId: adminUser.id,
quotaKey: 'additional_ponds',
value: 10,
},
});
const plainUser = await users.createUser({
username: plain.username,
email: `${plain.username}@example.org`,
displayName: plain.displayName,
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.customFont.deleteMany({});
const ids = (
await prisma.user.findMany({
where: { username: { contains: suffix } },
select: { id: true },
})
).map((row) => row.id);
await prisma.quotaOverride.deleteMany({ where: { subjectId: { in: ids } } });
await deletePondsWhere(prisma, { owner: { username: { contains: suffix } } });
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
await prisma.$disconnect();
await app.close();
await rm(fontsDir, { recursive: true, force: true });
delete process.env.CUSTOM_FONTS_DIR;
});
it('uploads a family, writes the bytes, and serves them back', async () => {
const created = await api()
.post('/api/v1/admin/fonts')
.set('Cookie', adminCookie)
.field('family', `Hausschrift ${suffix}`)
.field('category', 'serif')
.field('licence', 'Commercial — Foundry XY')
.attach('woff2-400', woff2(), 'x.woff2')
.attach('woff-400', woff(), 'x.woff')
.expect(201);
expect(created.body.weights).toEqual([400]);
expect(created.body.licence).toBe('Commercial — Foundry XY');
const slug = created.body.slug as string;
// The bytes are really on disk, in the catalog's layout.
const onDisk = await readFile(join(fontsDir, slug, `${slug}-400.woff2`));
expect(onDisk.subarray(0, 4).toString()).toBe('wOF2');
// …and reachable without a session: a font is fetched from CSS.
const served = await api().get(`/api/v1/fonts/custom/${slug}/${slug}-400.woff2`).expect(200);
expect(served.headers['content-type']).toContain('font/woff2');
});
it('rejects a file that is not a font, whatever it is called', async () => {
const res = await api()
.post('/api/v1/admin/fonts')
.set('Cookie', adminCookie)
.field('family', `Fake ${suffix}`)
.field('category', 'sans-serif')
.field('licence', 'X')
.attach('woff2-400', Buffer.from('\x89PNG\r\n\x1a\n and more'), 'evil.woff2')
.expect(400);
expect(res.body.code).toBe('font_file_not_a_font');
});
it('refuses a family name that a catalog font already owns', async () => {
const res = await api()
.post('/api/v1/admin/fonts')
.set('Cookie', adminCookie)
.field('family', 'Roboto')
.field('category', 'sans-serif')
.field('licence', 'X')
.attach('woff2-400', woff2(), 'x.woff2')
.expect(409);
expect(res.body.code).toBe('font_family_reserved');
});
it('refuses a weight whose WOFF2 is missing', async () => {
const res = await api()
.post('/api/v1/admin/fonts')
.set('Cookie', adminCookie)
.field('family', `NurWoff ${suffix}`)
.field('category', 'sans-serif')
.field('licence', 'X')
.attach('woff-400', woff(), 'x.woff')
.expect(400);
expect(res.body.code).toBe('font_woff2_missing');
});
/**
* Issue #304: an ordinary member picks fonts in their pond's Appearance
* settings and reads the licence page, so the family list cannot be
* Site-Admin-only only the management routes are.
*/
it('lets any signed-in user read the family list, but nobody anonymous', async () => {
await api()
.post('/api/v1/admin/fonts')
.set('Cookie', adminCookie)
.field('family', `Leseschrift ${suffix}`)
.field('category', 'monospace')
.field('licence', 'Read me')
.attach('woff2-500', woff2(), 'x.woff2')
.expect(201);
const listed = await api().get('/api/v1/fonts/custom').set('Cookie', plainCookie).expect(200);
const seen = (listed.body as { family: string; weights: number[] }[]).find(
(font) => font.family === `Leseschrift ${suffix}`,
);
expect(seen?.weights).toEqual([500]);
await api().get('/api/v1/fonts/custom').expect(401);
});
it('keeps every management route away from a non-admin', async () => {
await api().get('/api/v1/admin/fonts').set('Cookie', plainCookie).expect(403);
await api()
.post('/api/v1/admin/fonts')
.set('Cookie', plainCookie)
.field('family', `Nope ${suffix}`)
.field('category', 'serif')
.field('licence', 'X')
.attach('woff2-400', woff2(), 'x.woff2')
.expect(403);
});
it('counts the ponds a family is used by, and deletion leaves them working', async () => {
const created = await api()
.post('/api/v1/admin/fonts')
.set('Cookie', adminCookie)
.field('family', `Zählschrift ${suffix}`)
.field('category', 'sans-serif')
.field('licence', 'X')
.attach('woff2-400', woff2(), 'x.woff2')
.expect(201);
const pond = await api()
.post('/api/v1/ponds')
.set('Cookie', adminCookie)
.send({ name: `Schriftteich ${suffix}` })
.expect(201);
await api()
.patch(`/api/v1/ponds/${pond.body.id}`)
.set('Cookie', adminCookie)
.send({ fonts: { body: { family: `Zählschrift ${suffix}`, weight: 400 } } })
.expect(200);
const usage = await api()
.get(`/api/v1/admin/fonts/${created.body.id}/usage`)
.set('Cookie', adminCookie)
.expect(200);
expect(usage.body.pondsAffected).toBe(1);
// Deletion is never blocked by usage.
await api()
.delete(`/api/v1/admin/fonts/${created.body.id}`)
.set('Cookie', adminCookie)
.expect(204);
// The pond still resolves — it keeps the stored family name and falls
// back to the system stack, rather than breaking.
const after = await api()
.get(`/api/v1/ponds/${pond.body.slug}`)
.set('Cookie', adminCookie)
.expect(200);
expect(after.body.settings.fonts.body.family).toBe(`Zählschrift ${suffix}`);
expect(
await api().get('/api/v1/admin/fonts').set('Cookie', adminCookie).expect(200),
).toBeTruthy();
const audit = await prisma.auditEntry.findFirst({
where: { action: 'font.deleted', targetId: created.body.id },
});
expect(audit).not.toBeNull();
expect(audit!.details).toMatchObject({ pondsAffected: 1 });
});
});

View File

@ -1,249 +0,0 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import {
CreateCustomFontInput,
CustomFontView,
FONT_CATALOG,
FontCategory,
FontUploadFormat,
MAX_FONT_FILE_BYTES,
MAX_FONT_WEIGHTS,
fontSlug,
hasFontMagic,
} from '@dorfteich/shared';
import { User } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino';
import { AuditService } from '../audit/audit.service';
import { PrismaService } from '../prisma/prisma.service';
import { CustomFontStorageService } from './custom-font-storage.service';
/** One weight's bytes as they arrive from the controller. */
export interface WeightUpload {
weight: number;
woff2: Buffer;
woff?: Buffer;
}
/**
* Operator-uploaded font families (issue #303, ADR 0016 §#303).
*
* Site-Admin-only, additive to the compile-time catalog, and deliberately
* incurious about the files: the api validates the magic number and the size
* and then stores the bytes. Family, category and licence come from the form.
*/
@Injectable()
export class CustomFontsService {
constructor(
private readonly prisma: PrismaService,
private readonly storage: CustomFontStorageService,
private readonly audit: AuditService,
private readonly logger: PinoLogger,
) {
this.logger.setContext(CustomFontsService.name);
}
/**
* Rejects bytes that are not what they claim to be, before anything is
* written. Deliberately the ONLY inspection: parsing the font would gain
* metadata the form already carries, at the price of a known
* memory-safety surface (ADR 0016 §#303).
*/
private assertUsableFont(bytes: Buffer, format: FontUploadFormat): void {
if (bytes.length === 0) throw new BadRequestException({ code: 'font_file_empty' });
if (bytes.length > MAX_FONT_FILE_BYTES) {
throw new BadRequestException({ code: 'font_file_too_large' });
}
if (!hasFontMagic(bytes, format)) {
throw new BadRequestException({ code: 'font_file_not_a_font' });
}
}
/**
* A custom family must not collide with a catalog one, by name or by slug:
* a pond stores `fonts.<slot>.family` as a plain string, so two families
* answering to the same name would make the PDF path embed whichever file
* it happened to find.
*/
private async assertNameIsFree(family: string, slug: string): Promise<void> {
const catalogHit = FONT_CATALOG.some(
(entry) => entry.family === family || fontSlug(entry.family) === slug,
);
if (catalogHit) throw new ConflictException({ code: 'font_family_reserved' });
const existing = await this.prisma.customFont.findFirst({
where: { OR: [{ family }, { slug }] },
select: { id: true },
});
if (existing) throw new ConflictException({ code: 'font_family_exists' });
}
private viewOf(font: {
id: string;
family: string;
slug: string;
category: string;
licence: string;
licenceUrl: string | null;
createdAt: Date;
weights: { weight: number }[];
}): CustomFontView {
return {
id: font.id,
family: font.family,
slug: font.slug,
category: font.category as FontCategory,
licence: font.licence,
licenceUrl: font.licenceUrl,
weights: font.weights.map((row) => row.weight).sort((a, b) => a - b),
createdAt: font.createdAt.toISOString(),
};
}
async list(): Promise<CustomFontView[]> {
const fonts = await this.prisma.customFont.findMany({
orderBy: { family: 'asc' },
include: { weights: { select: { weight: true } } },
});
return fonts.map((font) => this.viewOf(font));
}
async create(
admin: User,
input: CreateCustomFontInput,
uploads: WeightUpload[],
): Promise<CustomFontView> {
if (uploads.length === 0) throw new BadRequestException({ code: 'font_no_weights' });
if (uploads.length > MAX_FONT_WEIGHTS) {
throw new BadRequestException({ code: 'font_too_many_weights' });
}
for (const upload of uploads) {
this.assertUsableFont(upload.woff2, 'woff2');
if (upload.woff) this.assertUsableFont(upload.woff, 'woff');
}
const slug = fontSlug(input.family);
if (!slug) throw new BadRequestException({ code: 'font_family_unusable' });
await this.assertNameIsFree(input.family, slug);
// Row first, then bytes: a row without files is repairable (re-upload the
// weight), while files without a row would be invisible litter.
const font = await this.prisma.customFont.create({
data: {
family: input.family,
slug,
category: input.category,
licence: input.licence,
licenceUrl: input.licenceUrl,
uploadedBy: admin.id,
weights: {
create: uploads.map((upload) => ({
weight: upload.weight,
hasWoff: Boolean(upload.woff),
byteSize: upload.woff2.length,
})),
},
},
include: { weights: { select: { weight: true } } },
});
for (const upload of uploads) {
await this.storage.save(slug, upload.weight, 'woff2', upload.woff2);
if (upload.woff) await this.storage.save(slug, upload.weight, 'woff', upload.woff);
}
await this.audit.record({
action: 'font.uploaded',
actorId: admin.id,
targetType: 'font',
targetId: font.id,
details: { family: font.family },
});
return this.viewOf(font);
}
async addWeight(admin: User, fontId: string, upload: WeightUpload): Promise<CustomFontView> {
this.assertUsableFont(upload.woff2, 'woff2');
if (upload.woff) this.assertUsableFont(upload.woff, 'woff');
const font = await this.prisma.customFont.findUnique({
where: { id: fontId },
include: { weights: { select: { weight: true } } },
});
if (!font) throw new NotFoundException();
if (font.weights.length >= MAX_FONT_WEIGHTS) {
throw new BadRequestException({ code: 'font_too_many_weights' });
}
if (font.weights.some((row) => row.weight === upload.weight)) {
throw new ConflictException({ code: 'font_weight_exists' });
}
await this.prisma.customFontWeight.create({
data: {
fontId,
weight: upload.weight,
hasWoff: Boolean(upload.woff),
byteSize: upload.woff2.length,
},
});
await this.storage.save(font.slug, upload.weight, 'woff2', upload.woff2);
if (upload.woff) await this.storage.save(font.slug, upload.weight, 'woff', upload.woff);
await this.audit.record({
action: 'font.uploaded',
actorId: admin.id,
targetType: 'font',
targetId: fontId,
details: { family: font.family, weight: upload.weight },
});
const updated = await this.prisma.customFont.findUniqueOrThrow({
where: { id: fontId },
include: { weights: { select: { weight: true } } },
});
return this.viewOf(updated);
}
/**
* How many live ponds still name this family in any of their three font
* slots. Shown before deletion those ponds keep working (an unknown
* family falls back to the system stack) but they visibly change.
*/
async pondsUsing(family: string): Promise<number> {
const rows = await this.prisma.$queryRaw<{ count: bigint }[]>`
SELECT count(*)::bigint AS count
FROM ponds
WHERE deleted_at IS NULL
AND (settings #>> '{fonts,heading,family}' = ${family}
OR settings #>> '{fonts,body,family}' = ${family}
OR settings #>> '{fonts,mono,family}' = ${family})
`;
return Number(rows[0]?.count ?? 0);
}
/**
* Deletion is never blocked by usage. `fontStack` already yields the system
* fallback for an unknown family, so affected ponds degrade rather than
* break, and re-uploading the family restores them but the count travels
* into the audit entry so the change is not silent.
*/
async remove(admin: User, fontId: string): Promise<void> {
const font = await this.prisma.customFont.findUnique({ where: { id: fontId } });
if (!font) throw new NotFoundException();
const pondsAffected = await this.pondsUsing(font.family);
await this.prisma.customFont.delete({ where: { id: fontId } });
await this.storage.deleteFamily(font.slug);
await this.audit.record({
action: 'font.deleted',
actorId: admin.id,
targetType: 'font',
targetId: fontId,
details: { family: font.family, pondsAffected },
});
this.logger.info({ fontId, family: font.family, pondsAffected }, 'custom font deleted');
}
}

View File

@ -1,14 +0,0 @@
import { Module } from '@nestjs/common';
import { CustomFontStorageService } from './custom-font-storage.service';
import { CustomFontsAdminController, CustomFontsFileController } from './custom-fonts.controller';
import { CustomFontsService } from './custom-fonts.service';
/** Operator-uploaded fonts (issue #303, ADR 0016 §#303). Exports the service
* so the PDF exporter can resolve a pond's font to a custom family. */
@Module({
controllers: [CustomFontsAdminController, CustomFontsFileController],
providers: [CustomFontsService, CustomFontStorageService],
exports: [CustomFontsService, CustomFontStorageService],
})
export class FontsModule {}

View File

@ -152,14 +152,7 @@ export class GrantsService {
* pond_admin only at pond scope for a user subject, no extra admins on a * pond_admin only at pond scope for a user subject, no extra admins on a
* personal pond, and scope/subject must exist here. Rejects duplicates. * personal pond, and scope/subject must exist here. Rejects duplicates.
*/ */
async createGrant( async createGrant(user: User, pondId: string, grant: Grant): Promise<GrantView> {
user: User,
pondId: string,
grant: Grant,
// `idp` when the claim mapping writes (issue #217): the row is marked
// as mapping-owned and the audit entry names the origin.
options: { origin?: 'manual' | 'idp' } = {},
): Promise<GrantView> {
const pond = await this.requireLivePond(pondId); const pond = await this.requireLivePond(pondId);
const invalid = grantValidationError(grant, { const invalid = grantValidationError(grant, {
@ -176,7 +169,7 @@ export class GrantsService {
if (existing) throw new ConflictException({ code: 'grant_exists' }); if (existing) throw new ConflictException({ code: 'grant_exists' });
const created = await this.prisma.roleGrant.create({ const created = await this.prisma.roleGrant.create({
data: { pondId, createdBy: user.id, origin: options.origin ?? 'manual', ...columns }, data: { pondId, createdBy: user.id, ...columns },
}); });
await this.accessChanged(pondId); await this.accessChanged(pondId);
await this.audit.record({ await this.audit.record({
@ -192,7 +185,6 @@ export class GrantsService {
scope: grant.scopeType, scope: grant.scopeType,
scopeId: grant.scopeId, scopeId: grant.scopeId,
effect: grant.effect, effect: grant.effect,
...(options.origin === 'idp' ? { origin: 'idp_mapping' } : {}),
}, },
}); });
return GrantsService.viewOf(created); return GrantsService.viewOf(created);
@ -203,12 +195,7 @@ export class GrantsService {
* grant is protected deleting it would leave the pond unmanageable * grant is protected deleting it would leave the pond unmanageable
* (only a Site Admin could recover it). * (only a Site Admin could recover it).
*/ */
async deleteGrant( async deleteGrant(user: User, pondId: string, grantId: string): Promise<void> {
user: User,
pondId: string,
grantId: string,
options: { origin?: 'manual' | 'idp' } = {},
): Promise<void> {
const grant = await this.prisma.roleGrant.findFirst({ where: { id: grantId, pondId } }); const grant = await this.prisma.roleGrant.findFirst({ where: { id: grantId, pondId } });
if (!grant) throw new NotFoundException(); if (!grant) throw new NotFoundException();
@ -226,12 +213,7 @@ export class GrantsService {
actorId: user.id, actorId: user.id,
targetType: 'pond', targetType: 'pond',
targetId: pondId, targetId: pondId,
details: { details: { grantId, subjectId: grant.subjectId, role: grant.role },
grantId,
subjectId: grant.subjectId,
role: grant.role,
...(options.origin === 'idp' ? { origin: 'idp_mapping' } : {}),
},
}); });
} }

View File

@ -1,12 +1,10 @@
import deErrors from '@dorfteich/shared/i18n/de/errors.json'; import deErrors from '@dorfteich/shared/i18n/de/errors.json';
import deLegal from '@dorfteich/shared/i18n/de/legal.json'; import deLegal from '@dorfteich/shared/i18n/de/legal.json';
import deMails from '@dorfteich/shared/i18n/de/mails.json'; import deMails from '@dorfteich/shared/i18n/de/mails.json';
import dePonds from '@dorfteich/shared/i18n/de/ponds.json';
import deTasks from '@dorfteich/shared/i18n/de/tasks.json'; import deTasks from '@dorfteich/shared/i18n/de/tasks.json';
import enErrors from '@dorfteich/shared/i18n/en/errors.json'; import enErrors from '@dorfteich/shared/i18n/en/errors.json';
import enLegal from '@dorfteich/shared/i18n/en/legal.json'; import enLegal from '@dorfteich/shared/i18n/en/legal.json';
import enMails from '@dorfteich/shared/i18n/en/mails.json'; import enMails from '@dorfteich/shared/i18n/en/mails.json';
import enPonds from '@dorfteich/shared/i18n/en/ponds.json';
import enTasks from '@dorfteich/shared/i18n/en/tasks.json'; import enTasks from '@dorfteich/shared/i18n/en/tasks.json';
import { createInstance, type i18n as I18n } from 'i18next'; import { createInstance, type i18n as I18n } from 'i18next';
@ -19,8 +17,8 @@ export const apiI18n: I18n = createInstance();
void apiI18n.init({ void apiI18n.init({
resources: { resources: {
en: { errors: enErrors, mails: enMails, legal: enLegal, tasks: enTasks, ponds: enPonds }, en: { errors: enErrors, mails: enMails, legal: enLegal, tasks: enTasks },
de: { errors: deErrors, mails: deMails, legal: deLegal, tasks: deTasks, ponds: dePonds }, de: { errors: deErrors, mails: deMails, legal: deLegal, tasks: deTasks },
}, },
fallbackLng: 'en', fallbackLng: 'en',
supportedLngs: ['de', 'en'], supportedLngs: ['de', 'en'],

View File

@ -5,7 +5,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { AuthTokensService } from '../auth/auth-tokens.service'; import { AuthTokensService } from '../auth/auth-tokens.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';
import { ConversionJobService } from './conversion-job.service'; import { ConversionJobService } from './conversion-job.service';
@ -108,7 +108,7 @@ describe.skipIf(!hasTestDb)('conversion job queue (e2e, issue #62)', () => {
// grant); clear those before the users they reference. // grant); clear those before the users they reference.
const where = { pond: { owner: { username: { contains: suffix } } } }; const where = { pond: { owner: { username: { contains: suffix } } } };
await prisma.roleGrant.deleteMany({ where }); await prisma.roleGrant.deleteMany({ where });
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.$disconnect(); await prisma.$disconnect();
await app.close(); await app.close();

View File

@ -95,14 +95,7 @@ export class DataExportService implements DataExportProcessor {
orderBy: { slug: 'asc' }, orderBy: { slug: 'asc' },
}); });
for (const pond of ponds) { for (const pond of ponds) {
// The build runs in the conversion worker, outside any request — the await this.exports.appendPondMarkdown(archive, user, pond, `ponds/${pond.slug}/`);
// read-trail session key (#222) is the job itself: `job:<id>` names the
// one download this build feeds, so the dedup window (#223) has a
// stable, honest key.
await this.exports.appendPondMarkdown(archive, user, pond, `ponds/${pond.slug}/`, {
actorId: user.id,
sessionKey: `job:${job.id}`,
});
} }
await archive.finalize(); await archive.finalize();

View File

@ -1,21 +1,12 @@
import { Body, Controller, Get, Param, Post, Req, Res, UseGuards } from '@nestjs/common'; import { Body, Controller, Get, Param, Post, Req, Res } from '@nestjs/common';
import { import { ConversionJobView, PageExportInput, pageExportInputSchema } from '@dorfteich/shared';
ConversionJobView,
PageExportInput,
PondArchivePreview,
pageExportInputSchema,
} from '@dorfteich/shared';
import type { Response } from 'express'; import type { Response } from 'express';
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 { RequiresPagePermission, RequiresPondRole } from '../permissions/permission.decorators'; import { RequiresPagePermission, RequiresPondRole } from '../permissions/permission.decorators';
import { readActorOf } from '../read-trail/read-actor';
import { SiteAdminGuard } from '../admin/site-admin.guard';
import { ExportService } from './export.service'; import { ExportService } from './export.service';
import { PondArchiveService } from './pond-archive.service';
/** /**
* Export endpoints (ADR 0009, issue #65): a whole pond as a ZIP of Markdown and * Export endpoints (ADR 0009, issue #65): a whole pond as a ZIP of Markdown and
@ -24,41 +15,7 @@ import { PondArchiveService } from './pond-archive.service';
*/ */
@Controller() @Controller()
export class ExportController { export class ExportController {
constructor( constructor(private readonly exports: ExportService) {}
private readonly exports: ExportService,
private readonly archives: PondArchiveService,
) {}
/**
* How much of the pond this requester's archive would contain (issue #305).
* Asked before the download so the UI can name the number of omitted pages:
* an archive silently missing content is worse than no archive, because it
* ends the search.
*/
@Get('ponds/:pondId/archive/preview')
@RequiresPondRole('pond_admin', { idParam: 'pondId' })
archivePreview(
@Param('pondId') pondId: string,
@Req() request: AuthedRequest,
): Promise<PondArchivePreview> {
return this.archives.preview(request.user!, pondId, false);
}
/**
* The full archive: every readable page, EVERY attachment, and a versioned
* manifest with settings, labels, comments and the hierarchy (issue #305).
* Pond-Admin, because it is the deletion flow's last resort a reader who
* wants their own copy has the Markdown export.
*/
@Get('ponds/:pondId/archive')
@RequiresPondRole('pond_admin', { idParam: 'pondId' })
async pondArchive(
@Param('pondId') pondId: string,
@Req() request: AuthedRequest,
@Res() response: Response,
): Promise<void> {
await this.archives.stream(request.user!, pondId, response, readActorOf(request), false);
}
/** Streamed ZIP of the pond's readable pages as Markdown (+ `media/`). The /** Streamed ZIP of the pond's readable pages as Markdown (+ `media/`). The
* `reader` role is "may see the pond"; the service filters to readable pages, * `reader` role is "may see the pond"; the service filters to readable pages,
@ -70,7 +27,7 @@ export class ExportController {
@Req() request: AuthedRequest, @Req() request: AuthedRequest,
@Res() response: Response, @Res() response: Response,
): Promise<void> { ): Promise<void> {
await this.exports.streamPondMarkdownZip(request.user!, pondId, response, readActorOf(request)); await this.exports.streamPondMarkdownZip(request.user!, pondId, response);
} }
/** Enqueue a `.docx`/`.odt` export of one page; poll `GET /jobs/:id` and /** Enqueue a `.docx`/`.odt` export of one page; poll `GET /jobs/:id` and
@ -82,42 +39,6 @@ export class ExportController {
@Body(new ZodValidationPipe(pageExportInputSchema)) input: PageExportInput, @Body(new ZodValidationPipe(pageExportInputSchema)) input: PageExportInput,
@Req() request: AuthedRequest, @Req() request: AuthedRequest,
): Promise<ConversionJobView> { ): Promise<ConversionJobView> {
return this.exports.enqueuePageExport( return this.exports.enqueuePageExport(request.user!, pageId, input.format);
request.user!,
pageId,
input.format,
readActorOf(request),
);
}
}
/**
* The Site Admin's archive from the purge dialog (issue #305, #193).
*
* Separate controller because it must NOT carry `@RequiresPondRole`: a Site
* Admin purging a trashed pond is usually not a member of it, and the last
* archive before an irreversible purge must not depend on that. It is
* therefore complete by construction the read filter is skipped.
*/
@Controller('admin/trash')
@UseGuards(SiteAdminGuard)
export class PondArchiveAdminController {
constructor(private readonly archives: PondArchiveService) {}
@Get('ponds/:pondId/archive/preview')
archivePreview(
@Param('pondId') pondId: string,
@Req() request: AuthedRequest,
): Promise<PondArchivePreview> {
return this.archives.preview(request.user!, pondId, true);
}
@Get('ponds/:pondId/archive')
async archive(
@Param('pondId') pondId: string,
@Req() request: AuthedRequest,
@Res() response: Response,
): Promise<void> {
await this.archives.stream(request.user!, pondId, response, readActorOf(request), true);
} }
} }

View File

@ -6,7 +6,6 @@ import {
ConversionJobView, ConversionJobView,
ExportFormat, ExportFormat,
PondFonts, PondFonts,
customFontEntries,
fontSlug, fontSlug,
PageClassification, PageClassification,
classificationMarking, classificationMarking,
@ -21,12 +20,10 @@ import { PinoLogger } from 'nestjs-pino';
import { AppConfig } from '../config/app-config.service'; import { AppConfig } from '../config/app-config.service';
import { FileStorageService } from '../files/file-storage.service'; import { FileStorageService } from '../files/file-storage.service';
import { CustomFontsService } from '../fonts/custom-fonts.service';
import { PermissionService } from '../permissions/permission.service'; import { PermissionService } from '../permissions/permission.service';
import { PluginFallbackRenderer } from '../plugins/plugin-fallback-renderer'; import { PluginFallbackRenderer } from '../plugins/plugin-fallback-renderer';
import { PluginsService } from '../plugins/plugins.service'; import { PluginsService } from '../plugins/plugins.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { ReadTrailService, type ReadActor } from '../read-trail/read-trail.service';
import { markClassifiedMarkdown } from './classified-markdown'; import { markClassifiedMarkdown } from './classified-markdown';
import { ConversionJobService } from './conversion-job.service'; import { ConversionJobService } from './conversion-job.service';
@ -55,8 +52,6 @@ export class ExportService {
private readonly plugins: PluginsService, private readonly plugins: PluginsService,
private readonly fallbacks: PluginFallbackRenderer, private readonly fallbacks: PluginFallbackRenderer,
private readonly config: AppConfig, private readonly config: AppConfig,
private readonly customFonts: CustomFontsService,
private readonly readTrail: ReadTrailService,
private readonly logger: PinoLogger, private readonly logger: PinoLogger,
) { ) {
this.logger.setContext(ExportService.name); this.logger.setContext(ExportService.name);
@ -69,12 +64,7 @@ export class ExportService {
* already checked the requester may see the pond; here we filter to the pages * already checked the requester may see the pond; here we filter to the pages
* they may actually read. * they may actually read.
*/ */
async streamPondMarkdownZip( async streamPondMarkdownZip(user: User, pondId: string, res: Response): Promise<void> {
user: User,
pondId: string,
res: Response,
read: ReadActor,
): Promise<void> {
const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } }); const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } });
if (!pond) throw new NotFoundException(); if (!pond) throw new NotFoundException();
@ -87,7 +77,7 @@ export class ExportService {
res.destroy(error); res.destroy(error);
}); });
archive.pipe(res); archive.pipe(res);
await this.appendPondMarkdown(archive, user, pond, '', read); await this.appendPondMarkdown(archive, user, pond);
await archive.finalize(); await archive.finalize();
} }
@ -103,8 +93,7 @@ export class ExportService {
archive: archiver.Archiver, archive: archiver.Archiver,
user: User, user: User,
pond: { id: string; slug: string }, pond: { id: string; slug: string },
prefix: string, prefix = '',
read: ReadActor,
): Promise<void> { ): Promise<void> {
const pondId = pond.id; const pondId = pond.id;
const pages = await this.prisma.page.findMany({ const pages = await this.prisma.page.findMany({
@ -124,21 +113,6 @@ export class ExportService {
const readablePages = pages.filter((page) => readableIds.has(page.id)); const readablePages = pages.filter((page) => readableIds.has(page.id));
const readableSlugs = new Set(readablePages.map((page) => page.slug)); const readableSlugs = new Set(readablePages.map((page) => page.slug));
// Read trail (issue #222): the ZIP is a bulk-egress channel — one event
// per classified page it will contain, recorded BEFORE any classified
// bytes enter the stream, so a failed write aborts the download while the
// evidence is still complete (ADR 0023).
for (const page of readablePages) {
if (page.classification !== 'VS_NFD') continue;
await this.readTrail.record({
...read,
pageId: page.id,
pondId,
channel: 'export',
details: { format: 'markdown_zip' },
});
}
// Every image referenced by a readable page — resolved to attachments that // Every image referenced by a readable page — resolved to attachments that
// still exist in this pond, so the media directory matches the rewrites. // still exist in this pond, so the media directory matches the rewrites.
const referenced = new Set<string>(); const referenced = new Set<string>();
@ -248,18 +222,14 @@ export class ExportService {
user: User, user: User,
pageId: string, pageId: string,
format: ExportFormat, format: ExportFormat,
read: ReadActor,
): Promise<ConversionJobView> { ): Promise<ConversionJobView> {
if (format === 'pdf') return this.enqueuePdfExport(user, pageId, read); if (format === 'pdf') return this.enqueuePdfExport(user, pageId);
const page = await this.prisma.page.findFirst({ const page = await this.prisma.page.findFirst({
where: { id: pageId, deletedAt: null }, where: { id: pageId, deletedAt: null },
include: { contentCache: { select: { markdown: true } } }, include: { contentCache: { select: { markdown: true } } },
}); });
if (!page) throw new NotFoundException(); if (!page) throw new NotFoundException();
// Read trail (issue #222): recorded at enqueue — the user's action; the
// worker's later conversion is machinery, not a second read.
await this.recordClassifiedExport(page, read, format);
// Plugin blocks degrade to their fallback text and sections to quoted // Plugin blocks degrade to their fallback text and sections to quoted
// blocks first (#79) — GFM knows neither construct, and pandoc would // blocks first (#79) — GFM knows neither construct, and pandoc would
@ -295,27 +265,7 @@ export class ExportService {
* the job input; the worker sends it to Gotenberg (`html → pdf`). Building it * the job input; the worker sends it to Gotenberg (`html → pdf`). Building it
* up front keeps the job a plain bytebyte render the worker can retry. * up front keeps the job a plain bytebyte render the worker can retry.
*/ */
/** One `export` event for a classified page leaving as a document (#222). */ private async enqueuePdfExport(user: User, pageId: string): Promise<ConversionJobView> {
private async recordClassifiedExport(
page: { id: string; pondId: string; classification: string },
read: ReadActor,
format: ExportFormat,
): Promise<void> {
if (page.classification !== 'VS_NFD') return;
await this.readTrail.record({
...read,
pageId: page.id,
pondId: page.pondId,
channel: 'export',
details: { format },
});
}
private async enqueuePdfExport(
user: User,
pageId: string,
read: ReadActor,
): Promise<ConversionJobView> {
const page = await this.prisma.page.findFirst({ const page = await this.prisma.page.findFirst({
where: { id: pageId, deletedAt: null }, where: { id: pageId, deletedAt: null },
include: { include: {
@ -324,7 +274,6 @@ export class ExportService {
}, },
}); });
if (!page) throw new NotFoundException(); if (!page) throw new NotFoundException();
await this.recordClassifiedExport(page, read, 'pdf');
const fonts = pondSettingsSchema.parse(page.pond.settings ?? {}).fonts; const fonts = pondSettingsSchema.parse(page.pond.settings ?? {}).fonts;
// Plugin blocks first become their best static form (#79: stored SVG // Plugin blocks first become their best static form (#79: stored SVG
@ -336,9 +285,6 @@ export class ExportService {
pondName: page.pond.name, pondName: page.pond.name,
bodyHtml, bodyHtml,
fonts, fonts,
// Both the rules and the stack need the uploaded families: embedding a
// face the stack never names would render the system font (issue #304).
customFonts: customFontEntries(await this.customFonts.list()),
fontFaceCss: await this.fontFaceCss(fonts), fontFaceCss: await this.fontFaceCss(fonts),
// Styled sections keep their look in the PDF (#75); a pond without // Styled sections keep their look in the PDF (#75); a pond without
// active style plugins contributes an empty string. // active style plugins contributes an empty string.
@ -377,18 +323,12 @@ export class ExportService {
}); });
} }
/** Base64 `@font-face` rules for the pond's three fonts. Catalog families /** Base64 `@font-face` rules for the pond's three fonts, read from the
* come from the directory baked into the image (ADR 0016); operator-uploaded * catalog baked into the image (ADR 0016). A font file that is absent (a
* ones from `CUSTOM_FONTS_DIR` (issue #303) same on-disk layout, so only * native dev run without `FONTS_DIR` populated) is skipped the render falls
* the base directory differs. A font file that is absent (a native dev run * back to the system stack rather than failing. */
* without `FONTS_DIR` populated, or a family deleted between the settings
* write and the export) is skipped: the render falls back to the system
* stack rather than failing. */
private async fontFaceCss(fonts: PondFonts): Promise<string> { private async fontFaceCss(fonts: PondFonts): Promise<string> {
const slots = [fonts.heading, fonts.body, fonts.mono]; const slots = [fonts.heading, fonts.body, fonts.mono];
const customSlugs = new Map(
(await this.customFonts.list()).map((font) => [font.family, font.slug]),
);
// Dedup identical family+weight so a doc that repeats a font embeds it once. // Dedup identical family+weight so a doc that repeats a font embeds it once.
const seen = new Set<string>(); const seen = new Set<string>();
const faces: string[] = []; const faces: string[] = [];
@ -396,10 +336,8 @@ export class ExportService {
const key = `${slot.family}:${slot.weight}`; const key = `${slot.family}:${slot.weight}`;
if (seen.has(key)) continue; if (seen.has(key)) continue;
seen.add(key); seen.add(key);
const customSlug = customSlugs.get(slot.family); const slug = fontSlug(slot.family);
const slug = customSlug ?? fontSlug(slot.family); const file = join(this.config.env.FONTS_DIR, slug, `${slug}-${slot.weight}.woff2`);
const baseDir = customSlug ? this.config.env.CUSTOM_FONTS_DIR : this.config.env.FONTS_DIR;
const file = join(baseDir, slug, `${slug}-${slot.weight}.woff2`);
try { try {
const bytes = await readFile(file); const bytes = await readFile(file);
faces.push( faces.push(
@ -407,7 +345,7 @@ export class ExportService {
` src: url('data:font/woff2;base64,${bytes.toString('base64')}') format('woff2'); }`, ` src: url('data:font/woff2;base64,${bytes.toString('base64')}') format('woff2'); }`,
); );
} catch { } catch {
this.logger.warn({ font: key }, 'pdf export: font file missing, using fallback'); this.logger.warn({ font: key }, 'pdf export: catalog font file missing, using fallback');
} }
} }
return faces.join('\n'); return faces.join('\n');

View File

@ -2,7 +2,6 @@ import { Module, OnModuleInit } from '@nestjs/common';
import { CommonModule } from '../common/common.module'; import { CommonModule } from '../common/common.module';
import { FilesModule } from '../files/files.module'; import { FilesModule } from '../files/files.module';
import { FontsModule } from '../fonts/fonts.module';
import { LabelsModule } from '../labels/labels.module'; import { LabelsModule } from '../labels/labels.module';
import { PagesModule } from '../pages/pages.module'; import { PagesModule } from '../pages/pages.module';
import { PluginsModule } from '../plugins/plugins.module'; import { PluginsModule } from '../plugins/plugins.module';
@ -15,7 +14,7 @@ import { ConversionWorker } from './conversion-worker.service';
import { DATA_EXPORT_PROCESSOR } from './data-export.constants'; import { DATA_EXPORT_PROCESSOR } from './data-export.constants';
import { DataExportController } from './data-export.controller'; import { DataExportController } from './data-export.controller';
import { DataExportService } from './data-export.service'; import { DataExportService } from './data-export.service';
import { ExportController, PondArchiveAdminController } from './export.controller'; import { ExportController } from './export.controller';
import { ExportService } from './export.service'; import { ExportService } from './export.service';
import { GotenbergHttpRenderer, GotenbergRenderer } from './gotenberg.renderer'; import { GotenbergHttpRenderer, GotenbergRenderer } from './gotenberg.renderer';
import { IMPORT_PROCESSOR } from './import.constants'; import { IMPORT_PROCESSOR } from './import.constants';
@ -23,7 +22,6 @@ import { ImportController } from './import.controller';
import { ImportService } from './import.service'; import { ImportService } from './import.service';
import { JobsController } from './jobs.controller'; import { JobsController } from './jobs.controller';
import { PandocConverter, PandocServerConverter } from './pandoc.converter'; import { PandocConverter, PandocServerConverter } from './pandoc.converter';
import { PondArchiveService } from './pond-archive.service';
/** How often expired data-export payloads are purged (#68). Hourly is ample: /** How often expired data-export payloads are purged (#68). Hourly is ample:
* the link's own expiry check already stops downloads the moment it lapses. */ * the link's own expiry check already stops downloads the moment it lapses. */
@ -42,26 +40,18 @@ const PAYLOAD_PRUNE_CADENCE_SECONDS = 24 * 60 * 60;
imports: [ imports: [
CommonModule, CommonModule,
FilesModule, FilesModule,
FontsModule,
LabelsModule, LabelsModule,
PagesModule, PagesModule,
PluginsModule, PluginsModule,
SchedulerModule, SchedulerModule,
SettingsModule, SettingsModule,
], ],
controllers: [ controllers: [JobsController, ImportController, ExportController, DataExportController],
JobsController,
ImportController,
ExportController,
PondArchiveAdminController,
DataExportController,
],
providers: [ providers: [
ConversionJobService, ConversionJobService,
ConversionWorker, ConversionWorker,
ImportService, ImportService,
ExportService, ExportService,
PondArchiveService,
DataExportService, DataExportService,
// The worker resolves the import pipeline through this token (never the // The worker resolves the import pipeline through this token (never the
// class), so its file does not import the import service's (avoids a cycle). // class), so its file does not import the import service's (avoids a cycle).

View File

@ -1,51 +0,0 @@
import { DEFAULT_FONTS, customFontEntries } from '@dorfteich/shared';
import { describe, expect, it } from 'vitest';
import { buildPdfHtml } from './pdf-html';
const CUSTOM = customFontEntries([
{
id: 'f1',
family: 'Corporate Grotesk',
slug: 'corporate-grotesk',
category: 'sans-serif',
licence: 'Bought from Foundry X',
licenceUrl: null,
weights: [400, 700],
createdAt: '2026-08-01T00:00:00.000Z',
},
]);
function base(family: string): Parameters<typeof buildPdfHtml>[0] {
return {
title: 'T',
pondName: 'P',
bodyHtml: '<p>x</p>',
fonts: { ...DEFAULT_FONTS, body: { family, weight: 400 } },
fontFaceCss: `@font-face { font-family: '${family}'; src: url('data:font/woff2;base64,AA'); }`,
};
}
describe('buildPdfHtml font stacks (issues #303/#304)', () => {
it('names an operator-uploaded family in the CSS stack when it is known', () => {
const html = buildPdfHtml({ ...base('Corporate Grotesk'), customFonts: CUSTOM });
expect(html).toContain("--font-body: 'Corporate Grotesk',");
});
/**
* The regression this pins: the `@font-face` rule for a custom family was
* embedded, but `fontStack` not knowing the family produced the bare
* system fallback, so the rule was never referenced and the PDF rendered in
* the system font while everything reported success.
*/
it('would fall back to the system stack without the uploaded families', () => {
const html = buildPdfHtml(base('Corporate Grotesk'));
expect(html).not.toContain("'Corporate Grotesk',");
expect(html).toContain('--font-body: system-ui');
});
it('leaves catalog families working without any uploaded ones', () => {
const html = buildPdfHtml(base('Lora'));
expect(html).toContain("--font-body: 'Lora', Georgia");
});
});

View File

@ -1,4 +1,4 @@
import { FontCatalogEntry, PondFonts, fontStack } from '@dorfteich/shared'; import { PondFonts, fontStack } from '@dorfteich/shared';
export interface PdfHtmlParams { export interface PdfHtmlParams {
title: string; title: string;
@ -8,12 +8,6 @@ export interface PdfHtmlParams {
fonts: PondFonts; fonts: PondFonts;
/** Pre-built `@font-face` rules (base64 WOFF2) for the pond's fonts. */ /** Pre-built `@font-face` rules (base64 WOFF2) for the pond's fonts. */
fontFaceCss: string; fontFaceCss: string;
/** The instance's operator-uploaded families (issue #303), so a pond set to
* one gets it NAMED in the `font-family` stack. Without them `fontStack`
* cannot tell a custom family from a typo and yields the bare system
* fallback the `@font-face` rule would then be embedded but never
* referenced, and the PDF would silently render in the system font. */
customFonts?: readonly FontCatalogEntry[];
/** The pond's active section-style plugin CSS (issue #75), already validated /** The pond's active section-style plugin CSS (issue #75), already validated
* at install time (scoped selectors, no external fetches, no `</style>`). * at install time (scoped selectors, no external fetches, no `</style>`).
* Sections of a disabled plugin render neutrally their class matches * Sections of a disabled plugin render neutrally their class matches
@ -41,7 +35,6 @@ function escapeHtml(value: string): string {
*/ */
export function buildPdfHtml(params: PdfHtmlParams): string { export function buildPdfHtml(params: PdfHtmlParams): string {
const { fonts } = params; const { fonts } = params;
const extra = params.customFonts ?? [];
return `<!doctype html> return `<!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
@ -51,9 +44,9 @@ export function buildPdfHtml(params: PdfHtmlParams): string {
${params.fontFaceCss} ${params.fontFaceCss}
@page { size: A4; } @page { size: A4; }
:root { :root {
--font-heading: ${fontStack(fonts.heading.family, extra)}; --font-heading: ${fontStack(fonts.heading.family)};
--font-body: ${fontStack(fonts.body.family, extra)}; --font-body: ${fontStack(fonts.body.family)};
--font-mono: ${fontStack(fonts.mono.family, extra)}; --font-mono: ${fontStack(fonts.mono.family)};
} }
html { font-size: 11pt; } html { font-size: 11pt; }
body { body {

View File

@ -1,250 +0,0 @@
import { INestApplication } from '@nestjs/common';
import { PondArchiveManifest } from '@dorfteich/shared';
import { PrismaClient } from '@prisma/client';
import { unzipSync } from 'fflate';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { AuthTokensService } from '../auth/auth-tokens.service';
import { FilesService } from '../files/files.service';
import { createTestApp, sessionCookieOf } from '../testing/test-app';
import { createTestPrisma, deletePondsWhere, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service';
const PNG_BASE64 =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';
function entries(buffer: Buffer): Record<string, Uint8Array> {
return unzipSync(new Uint8Array(buffer));
}
/** supertest parses text by default — a ZIP has to be collected as bytes. */
function asBinary(req: request.Test): request.Test {
return req.parse((res, cb) => {
const chunks: Buffer[] = [];
res.on('data', (chunk: Buffer) => chunks.push(chunk));
res.on('end', () => cb(null, Buffer.concat(chunks)));
});
}
function manifestOf(buffer: Buffer): PondArchiveManifest {
const raw = entries(buffer)['manifest.json'];
return JSON.parse(Buffer.from(raw!).toString('utf8')) as PondArchiveManifest;
}
/**
* The full pond archive (issue #305). What separates it from the Markdown
* export is exactly what is asserted here: EVERY attachment travels, not only
* the embedded ones, and the manifest carries what Markdown cannot settings,
* labels, comments and the hierarchy.
*/
describe.skipIf(!hasTestDb)('pond archive (e2e, issue #305)', () => {
let app: INestApplication;
let prisma: PrismaClient;
let files: FilesService;
const suffix = uniqueSuffix();
const password = 'archiviere den ganzen teich 1';
const owner = { username: `arch-${suffix}` };
const admin = { username: `archadm-${suffix}` };
let ownerId: string;
let ownerCookie: string;
let adminCookie: string;
let pondId: string;
let parentPageId: string;
const api = () => request(app.getHttpServer());
beforeAll(async () => {
prisma = createTestPrisma();
await prisma.rateLimit.deleteMany({});
app = await createTestApp();
files = app.get(FilesService);
const users = app.get(UsersService);
const tokens = app.get(AuthTokensService);
const ownerUser = await users.createUser({
username: owner.username,
email: `${owner.username}@example.org`,
displayName: `Archive Owner ${suffix}`,
password,
locale: 'en',
});
ownerId = ownerUser.id;
await api()
.post('/api/v1/auth/verify-email')
.send({ token: await tokens.issue(ownerUser.id, 'EMAIL_VERIFICATION', 600) })
.expect(204);
ownerCookie = sessionCookieOf(
await api()
.post('/api/v1/auth/login')
.send({ usernameOrEmail: owner.username, password })
.expect(200),
);
const adminUser = await users.createUser({
username: admin.username,
email: `${admin.username}@example.org`,
displayName: `Archive Admin ${suffix}`,
password,
locale: 'en',
});
await users.markEmailVerified(adminUser.id);
await prisma.user.update({ where: { id: adminUser.id }, data: { isSiteAdmin: true } });
adminCookie = sessionCookieOf(
await api()
.post('/api/v1/auth/login')
.send({ usernameOrEmail: admin.username, password })
.expect(200),
);
pondId = (await prisma.pond.findFirstOrThrow({ where: { ownerId, type: 'PERSONAL' } })).id;
// A parent and a child page, so the hierarchy has something to state.
const parent = await prisma.page.create({
data: {
pondId,
title: 'Archive Parent',
slug: 'archive-parent',
ydocState: new Uint8Array(),
sortKey: 'a',
createdBy: ownerId,
contentCache: {
create: { plainText: 'Parent body', markdown: 'Parent body', html: '', outline: [] },
},
},
});
parentPageId = parent.id;
await prisma.page.create({
data: {
pondId,
parentId: parent.id,
title: 'Archive Child',
slug: 'archive-child',
ydocState: new Uint8Array(),
sortKey: 'b',
createdBy: ownerId,
contentCache: {
create: { plainText: 'Child body', markdown: 'Child body', html: '', outline: [] },
},
},
});
const label = await prisma.label.create({
data: { pondId, name: `Archive Label ${suffix}`, color: '#2f6f4f' },
});
await prisma.pageLabel.create({ data: { pageId: parent.id, labelId: label.id } });
await prisma.comment.create({
data: { pageId: parent.id, authorId: ownerId, body: 'A remark worth keeping.' },
});
});
afterAll(async () => {
const where = { pond: { owner: { username: { contains: suffix } } } };
await prisma.comment.deleteMany({ where: { page: where } });
await prisma.attachment.deleteMany({ where });
await prisma.pageLabel.deleteMany({ where: { page: where } });
await prisma.label.deleteMany({ where });
await prisma.page.deleteMany({ where });
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('contains EVERY attachment, not only the embedded ones', async () => {
// The gap this whole issue exists for: an attachment nobody embedded
// would vanish unnoticed with the Markdown export.
const orphan = await files.upload({ id: ownerId } as never, pondId, {
buffer: Buffer.from(PNG_BASE64, 'base64'),
size: 70,
originalname: 'never-embedded.png',
} as never);
const res = await asBinary(api().get(`/api/v1/ponds/${pondId}/archive`))
.set('Cookie', ownerCookie)
.expect(200);
expect(res.headers['content-type']).toContain('application/zip');
const names = Object.keys(entries(res.body as Buffer));
expect(names).toContain('manifest.json');
expect(names).toContain('README.txt');
expect(names).toContain('pages/archive-parent.md');
expect(names).toContain('pages/archive-child.md');
expect(names.some((name) => name.startsWith(`media/${orphan.id}.`))).toBe(true);
const manifest = manifestOf(res.body as Buffer);
expect(manifest.attachments.map((a) => a.id)).toContain(orphan.id);
// The Markdown export would have shipped no media at all here.
expect(manifest.attachments.length).toBeGreaterThan(0);
});
it('states the hierarchy, labels, comments and settings in the manifest', async () => {
const res = await asBinary(api().get(`/api/v1/ponds/${pondId}/archive`))
.set('Cookie', ownerCookie)
.expect(200);
const manifest = manifestOf(res.body as Buffer);
expect(manifest.kind).toBe('dorfteich-pond-archive');
expect(manifest.formatVersion).toBe(1);
expect(manifest.complete).toBe(true);
expect(manifest.omittedPages).toBe(0);
const child = manifest.pages.find((page) => page.slug === 'archive-child');
// The hierarchy is exactly what a folder of Markdown cannot express.
expect(child?.parentId).toBe(parentPageId);
expect(manifest.labels.some((label) => label.name.includes(suffix))).toBe(true);
expect(manifest.comments.map((comment) => comment.body)).toContain('A remark worth keeping.');
// A display name, not an account id — the archive outlives the account.
expect(manifest.comments[0]?.author).toContain('Archive Owner');
// Pond settings ride along; fonts are always present through the schema
// defaults, so their presence proves the settings object is real.
expect(manifest.pond.settings).toHaveProperty('fonts');
});
it('tells a requester before the download how much they would get', async () => {
const preview = await api()
.get(`/api/v1/ponds/${pondId}/archive/preview`)
.set('Cookie', ownerCookie)
.expect(200);
expect(preview.body.omittedPages).toBe(0);
expect(preview.body.includedPages).toBe(preview.body.totalPages);
expect(preview.body.complete).toBe(true);
});
it('audits the download with counts and completeness', async () => {
await asBinary(api().get(`/api/v1/ponds/${pondId}/archive`))
.set('Cookie', ownerCookie)
.expect(200);
const entry = await prisma.auditEntry.findFirst({
where: { action: 'pond.archived', targetId: pondId },
orderBy: { at: 'desc' },
});
expect(entry).not.toBeNull();
expect(entry!.details).toMatchObject({ complete: true, omittedPages: 0 });
});
it('gives the Site Admin a complete archive without pond membership', async () => {
// The purge dialog's archive must not depend on which ponds the operator
// happens to be a member of — this admin is a member of none.
const preview = await api()
.get(`/api/v1/admin/trash/ponds/${pondId}/archive/preview`)
.set('Cookie', adminCookie)
.expect(200);
expect(preview.body.complete).toBe(true);
expect(preview.body.omittedPages).toBe(0);
const res = await asBinary(api().get(`/api/v1/admin/trash/ponds/${pondId}/archive`))
.set('Cookie', adminCookie)
.expect(200);
expect(manifestOf(res.body as Buffer).pages.length).toBe(preview.body.totalPages);
});
it('keeps the admin archive away from an ordinary pond admin', async () => {
await api()
.get(`/api/v1/admin/trash/ponds/${pondId}/archive`)
.set('Cookie', ownerCookie)
.expect(403);
});
});

View File

@ -1,395 +0,0 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import {
PageClassification,
PondArchiveManifest,
PondArchivePreview,
POND_ARCHIVE_FORMAT_VERSION,
classificationMarking,
highestClassification,
pondSettingsSchema,
} from '@dorfteich/shared';
import { User } from '@prisma/client';
import archiver from 'archiver';
import type { Response } from 'express';
import { PinoLogger } from 'nestjs-pino';
import { AuditService } from '../audit/audit.service';
import { FileStorageService } from '../files/file-storage.service';
import { PermissionService } from '../permissions/permission.service';
import { PrismaService } from '../prisma/prisma.service';
import { ReadTrailService, type ReadActor } from '../read-trail/read-trail.service';
import { markClassifiedMarkdown } from './classified-markdown';
import { imageExtension, markdownForZip } from './export-markdown';
/** The plain-text note that travels inside the ZIP. The manifest says the
* same thing machine-readably, but a person unpacking a folder of Markdown
* a year from now reads the file lying next to it and must not believe
* they are holding a one-click restore. */
const README = `Dorfteich pond archive (format version ${POND_ARCHIVE_FORMAT_VERSION})
This is a PRESERVATION archive, not a backup you can re-import: Dorfteich has
no importer for it yet. Everything needed to write one later is here and
documented see manifest.json and docs/architecture/pond-archive-format.md in
the Dorfteich repository.
manifest.json pond settings, labels, page hierarchy, comments, attachment
metadata, and the classification of every file
pages/ one Markdown file per page
media/ EVERY attachment of the pond, not only the embedded ones
If manifest.json states "complete": false, the archive was produced by someone
who could not read every page of the pond; "omittedPages" says how many are
missing.
`;
/**
* The full pond archive offered before a pond is deleted (issue #305).
*
* Distinct from the Markdown export (`exportPond`, #65) on purpose: that one
* ships the pages plus the images they embed, which as a LAST resort is not
* enough an attachment nobody embedded would vanish unnoticed. This one adds
* every attachment and a machine-readable sidecar of the things Markdown
* cannot carry: settings, labels, comments and the page hierarchy.
*
* Re-import is deliberately out of scope. The archive is a preservation
* format: complete, versioned and documented, so an importer can be written
* later without guesswork.
*/
@Injectable()
export class PondArchiveService {
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PermissionService,
private readonly storage: FileStorageService,
private readonly readTrail: ReadTrailService,
private readonly audit: AuditService,
private readonly logger: PinoLogger,
) {
this.logger.setContext(PondArchiveService.name);
}
/**
* Pages in the pond and how many of them this requester may read.
*
* The UI states the difference BEFORE the download: an archive silently
* missing content is worse than no archive, because it ends the search.
* A site admin archiving from the purge dialog reads everything, so their
* preview says nothing is omitted.
*/
async preview(user: User, pondId: string, unfiltered: boolean): Promise<PondArchivePreview> {
const pond = await this.loadPond(pondId);
const pages = await this.readablePages(user, pond.id, unfiltered);
const total = await this.prisma.page.count({ where: { pondId: pond.id, deletedAt: null } });
return {
totalPages: total,
includedPages: pages.length,
omittedPages: total - pages.length,
// "Complete" is a statement about the RESULT, not about the route: a
// pond admin who may read every page gets a complete archive too. Only
// an archive that actually leaves pages out is incomplete.
complete: total === pages.length,
};
}
/** The pond, or 404 — the caller's permission is checked by the route. */
private async loadPond(pondId: string): Promise<{ id: string; slug: string; name: string }> {
// Deliberately including trashed ponds: the purge dialog archives a pond
// that is already in the trash, which is the last moment it exists.
const pond = await this.prisma.pond.findUnique({
where: { id: pondId },
select: { id: true, slug: true, name: true },
});
if (!pond) throw new NotFoundException();
return pond;
}
private async readablePages(
user: User,
pondId: string,
unfiltered: boolean,
): Promise<
{
id: string;
slug: string;
title: string;
parentId: string | null;
sortKey: string;
classification: string;
createdAt: Date;
updatedAt: Date;
labels: { labelId: string }[];
contentCache: { markdown: string } | null;
}[]
> {
const pages = await this.prisma.page.findMany({
where: { pondId, deletedAt: null },
orderBy: { title: 'asc' },
select: {
id: true,
slug: true,
title: true,
parentId: true,
sortKey: true,
classification: true,
createdAt: true,
updatedAt: true,
labels: { select: { labelId: true } },
contentCache: { select: { markdown: true } },
},
});
if (unfiltered) return pages;
const readable = await this.permissions.filterPages(
user,
pondId,
pages.map((page) => ({ id: page.id, labelIds: page.labels.map((l) => l.labelId) })),
'read',
);
return pages.filter((page) => readable.has(page.id));
}
/**
* Stream the archive.
*
* `unfiltered` is the Site-Admin path from the purge dialog: it skips the
* read filter, because the last archive before an irreversible purge must
* not depend on which pages the operator happens to be a member of.
* Whether the RESULT is complete is a separate question, answered by
* comparing what went in with what exists.
*/
async stream(
user: User,
pondId: string,
res: Response,
read: ReadActor,
unfiltered: boolean,
): Promise<void> {
const pond = await this.loadPond(pondId);
const pages = await this.readablePages(user, pond.id, unfiltered);
const totalPages = await this.prisma.page.count({
where: { pondId: pond.id, deletedAt: null },
});
const complete = totalPages === pages.length;
// Read trail (ADR 0023, issue #222's property): one `export` event per
// classified page BEFORE any classified byte enters the stream, so a
// failed write aborts the download with the evidence intact. The added
// attachments carry their page's classification and are covered by the
// same events — they never travel without their page.
for (const page of pages) {
if (page.classification !== 'VS_NFD') continue;
await this.readTrail.record({
...read,
pageId: page.id,
pondId: pond.id,
channel: 'export',
details: { format: 'pond_archive' },
});
}
const [settingsRow, labels, comments, attachmentRows] = await Promise.all([
this.prisma.pond.findUniqueOrThrow({
where: { id: pond.id },
select: { name: true, slug: true, type: true, settings: true, createdAt: true },
}),
this.prisma.label.findMany({
where: { pondId: pond.id },
select: { id: true, name: true, color: true, parentId: true },
orderBy: { name: 'asc' },
}),
this.prisma.comment.findMany({
where: { page: { pondId: pond.id, deletedAt: null } },
orderBy: { createdAt: 'asc' },
select: {
id: true,
pageId: true,
parentId: true,
body: true,
createdAt: true,
editedAt: true,
resolvedAt: true,
author: { select: { displayName: true } },
},
}),
// EVERY attachment of the pond (issue #305) — not only the embedded
// ones the Markdown export ships.
this.prisma.attachment.findMany({
where: { pondId: pond.id },
select: {
id: true,
pageId: true,
fileName: true,
mimeType: true,
sizeBytes: true,
sha256: true,
createdAt: true,
},
orderBy: { createdAt: 'asc' },
}),
]);
const includedPageIds = new Set(pages.map((page) => page.id));
// An attachment of a page the requester cannot read stays out — the same
// rule the pages follow. Pond-level attachments (no page) are included:
// nothing narrower than the pond governs them.
const visibleAttachments = attachmentRows.filter(
(row) => !row.pageId || includedPageIds.has(row.pageId),
);
const onDisk = await Promise.all(
visibleAttachments.map((row) => this.storage.exists(pond.id, row.id)),
);
const attachments = visibleAttachments.filter((_, index) => onDisk[index]);
const classificationByPage = new Map(
pages.map((page) => [page.id, page.classification.toLowerCase() as PageClassification]),
);
const mediaName = new Map(
attachments.map((row) => [row.id, `${row.id}.${imageExtension(row.mimeType)}`]),
);
const readableSlugs = new Set(pages.map((page) => page.slug));
const archive = archiver('zip', { zlib: { level: 9 } });
res.set('Content-Type', 'application/zip');
res.set('Content-Disposition', `attachment; filename="${pond.slug}-archive.zip"`);
res.set('X-Content-Type-Options', 'nosniff');
archive.on('error', (error) => {
this.logger.error({ pondId: pond.id, err: error.message }, 'pond archive failed');
res.destroy(error);
});
archive.pipe(res);
const files: { path: string; classification: PageClassification }[] = [];
for (const page of pages) {
const level = classificationByPage.get(page.id) ?? 'unclassified';
const markdown = markClassifiedMarkdown(
markdownForZip(page.contentCache?.markdown ?? '', readableSlugs, mediaName),
level,
);
const path = `pages/${page.slug}.md`;
archive.append(markdown, { name: path });
files.push({ path, classification: level });
}
for (const row of attachments) {
// An attachment inherits its page's level (fail-closed, ADR 0022); one
// that belongs to no page inherits the pond's highest, because nothing
// narrower governs it.
const level = row.pageId
? (classificationByPage.get(row.pageId) ?? 'unclassified')
: highestClassification([...classificationByPage.values()]);
const path = `media/${mediaName.get(row.id)!}`;
files.push({ path, classification: level });
// Companion marking (issue #212): binaries cannot carry it themselves,
// and the sibling file survives unpacking where a manifest may not.
const marking = classificationMarking(level);
if (marking) {
archive.append(`${marking}\n`, { name: `${path}.classification.txt` });
files.push({ path: `${path}.classification.txt`, classification: level });
}
}
const manifest: PondArchiveManifest = {
kind: 'dorfteich-pond-archive',
formatVersion: POND_ARCHIVE_FORMAT_VERSION,
exportedAt: new Date().toISOString(),
complete,
omittedPages: totalPages - pages.length,
classification: highestClassification(files.map((file) => file.classification)),
pond: {
name: settingsRow.name,
slug: settingsRow.slug,
type: settingsRow.type,
createdAt: settingsRow.createdAt.toISOString(),
// The EFFECTIVE settings, defaults filled in — a preservation format
// must not require its reader to know Dorfteich's defaults, and the
// stored row only holds what was explicitly set.
settings: pondSettingsSchema.parse(settingsRow.settings ?? {}) as unknown as Record<
string,
unknown
>,
},
labels: labels.map((label) => ({
id: label.id,
name: label.name,
color: label.color,
parentId: label.parentId,
})),
pages: pages.map((page) => ({
id: page.id,
slug: page.slug,
title: page.title,
parentId: page.parentId,
sortKey: page.sortKey,
classification: page.classification.toLowerCase() as PageClassification,
labelIds: page.labels.map((label) => label.labelId),
createdAt: page.createdAt.toISOString(),
updatedAt: page.updatedAt.toISOString(),
file: `pages/${page.slug}.md`,
})),
// Comments of included pages only — a comment is content of its page.
comments: comments
.filter((comment) => includedPageIds.has(comment.pageId))
.map((comment) => ({
id: comment.id,
pageId: comment.pageId,
parentId: comment.parentId,
body: comment.body,
// The display name, not the account: the archive is a document, and
// it should stay readable after the account is gone.
author: comment.author?.displayName ?? null,
createdAt: comment.createdAt.toISOString(),
editedAt: comment.editedAt?.toISOString() ?? null,
resolvedAt: comment.resolvedAt?.toISOString() ?? null,
})),
attachments: attachments.map((row) => ({
id: row.id,
pageId: row.pageId,
fileName: row.fileName,
mimeType: row.mimeType,
sizeBytes: row.sizeBytes,
sha256: row.sha256,
createdAt: row.createdAt.toISOString(),
file: `media/${mediaName.get(row.id)!}`,
})),
files,
};
archive.append(README, { name: 'README.txt' });
archive.append(JSON.stringify(manifest, null, 2), { name: 'manifest.json' });
for (const row of attachments) {
const stream = this.storage.createReadStream(pond.id, row.id);
stream.on('error', (error) =>
this.logger.warn(
{ pondId: pond.id, fileId: row.id, err: error.message },
'pond archive: media read failed',
),
);
archive.append(stream, { name: `media/${mediaName.get(row.id)!}` });
}
// Audited: a whole pond leaving the instance in one file, usually right
// before it is deleted, is exactly the event an operator wants to find
// later. Recorded before finalize so the trail exists even if the
// download is aborted mid-stream.
await this.audit.record({
action: 'pond.archived',
actorId: user.id,
targetType: 'pond',
targetId: pond.id,
details: {
pages: pages.length,
attachments: attachments.length,
omittedPages: totalPages - pages.length,
complete,
},
});
await archive.finalize();
this.logger.info(
{ pondId: pond.id, pages: pages.length, attachments: attachments.length, complete },
'pond archive streamed',
);
}
}

View File

@ -1,50 +0,0 @@
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Req } from '@nestjs/common';
import {
CreateInvitationInput,
InvitationListView,
InvitationPreview,
InvitationView,
createInvitationSchema,
invitationPreviewSchema,
} from '@dorfteich/shared';
import { AuthedRequest, Public } from '../auth/auth.guard';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { AuthenticatedOnly } from '../permissions/permission.decorators';
import { InvitationsService } from './invitations.service';
/** Peer invitations (issue #332). */
@AuthenticatedOnly()
@Controller('invitations')
export class InvitationsController {
constructor(private readonly invitations: InvitationsService) {}
@Post()
async create(
@Body(new ZodValidationPipe(createInvitationSchema)) input: CreateInvitationInput,
@Req() request: AuthedRequest,
): Promise<InvitationView> {
return this.invitations.create(request.user!, input.email);
}
@Get()
async list(@Req() request: AuthedRequest): Promise<InvitationListView> {
return this.invitations.list(request.user!);
}
@Delete(':id')
@HttpCode(204)
async revoke(@Param('id') id: string, @Req() request: AuthedRequest): Promise<void> {
await this.invitations.revoke(request.user!, id);
}
/** The signup screen's link check — POST keeps the token out of logs. */
@Public()
@Post('preview')
@HttpCode(200)
async preview(
@Body(new ZodValidationPipe(invitationPreviewSchema)) input: { token: string },
): Promise<InvitationPreview> {
return this.invitations.preview(input.token);
}
}

View File

@ -1,246 +0,0 @@
import { INestApplication } from '@nestjs/common';
import { InvitationListView, InvitationPreview, InvitationView } from '@dorfteich/shared';
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 { createTestApp, sessionCookieOf } from '../testing/test-app';
import { createTestPrisma, deletePondsWhere, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service';
/**
* Peer invitations end to end (issue #332): inviting mails a single-use
* link, open invitations are quota-bound per user, and a valid token lets
* exactly one signup through a closed registration. Settings written here
* are restored inside each test and the keys are deleted in afterAll
* (shared-DB rule).
*/
describe.skipIf(!hasTestDb)('invitations (e2e, issue #332)', () => {
let app: INestApplication;
let prisma: PrismaClient;
const suffix = uniqueSuffix();
const password = 'einladungen sind praktisch 1';
const ids: Record<string, string> = {};
const cookies: Record<string, string> = {};
const api = () => request(app.getHttpServer());
const settings = () => app.get(InstanceSettingsService);
async function makeUser(handle: string): Promise<void> {
const users = app.get(UsersService);
const username = `inv-${handle}-${suffix}`;
const user = await users.createUser({
username,
email: `${username}@example.org`,
displayName: `Inv ${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),
);
}
/** The raw token only travels in the mail — fish it out of the outbox. */
async function mailedTokenFor(email: string): Promise<string> {
const mail = await prisma.mailOutbox.findFirstOrThrow({
where: { toAddress: email },
orderBy: { createdAt: 'desc' },
});
const match = /invitation=([A-Za-z0-9_-]+)/.exec(mail.textBody);
expect(match).not.toBeNull();
return match![1]!;
}
beforeAll(async () => {
prisma = createTestPrisma();
await prisma.rateLimit.deleteMany({});
app = await createTestApp();
await makeUser('alice');
await makeUser('quota');
});
afterAll(async () => {
await prisma.instanceSetting.deleteMany({
where: { key: { in: ['auth.registrationMode', 'invitations.maxOpenPerUser'] } },
});
const all = Object.values(ids);
await prisma.invitation.deleteMany({ where: { inviterId: { in: all } } });
await prisma.mailOutbox.deleteMany({ where: { toAddress: { contains: suffix } } });
await prisma.session.deleteMany({ where: { userId: { in: all } } });
await deletePondsWhere(prisma, { ownerId: { 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('invites, lists, and mails a single-use signup link', async () => {
const invitee = `guest-${suffix}@example.org`;
const res = await api()
.post('/api/v1/invitations')
.set('Cookie', cookies.alice!)
.send({ email: invitee })
.expect(201);
const view = res.body as InvitationView;
expect(view.status).toBe('pending');
const list = (await api().get('/api/v1/invitations').set('Cookie', cookies.alice!).expect(200))
.body as InvitationListView;
expect(list.open).toBe(1);
expect(list.maxOpen).toBe(5);
expect(list.invitations.map((i) => i.id)).toContain(view.id);
// The mail exists and the public preview identifies the inviter.
const token = await mailedTokenFor(invitee);
const preview = (await api().post('/api/v1/invitations/preview').send({ token }).expect(200))
.body as InvitationPreview;
expect(preview.email).toBe(invitee);
expect(preview.inviterName).toBe('Inv alice');
});
it('a valid token passes a closed registration exactly once; a burned signup attempt does not consume it', async () => {
const invitee = `joiner-${suffix}@example.org`;
await api()
.post('/api/v1/invitations')
.set('Cookie', cookies.alice!)
.send({ email: invitee })
.expect(201);
const token = await mailedTokenFor(invitee);
await settings().set('auth.registrationMode', 'closed', ids.alice!);
try {
// Closed without a token: refused.
await api()
.post('/api/v1/auth/signup')
.send({
username: `inv-blocked-${suffix}`,
email: `inv-blocked-${suffix}@example.org`,
displayName: 'Blocked',
password,
locale: 'en',
})
.expect(403);
// A failing signup (taken username) must NOT burn the token.
await api()
.post('/api/v1/auth/signup')
.send({
username: `inv-alice-${suffix}`, // taken
email: invitee,
displayName: 'Joiner',
password,
locale: 'en',
invitationToken: token,
})
.expect(409);
// Same link, fresh username: through, despite closed mode.
const username = `inv-joiner-${suffix}`;
await api()
.post('/api/v1/auth/signup')
.send({
username,
email: invitee,
displayName: 'Joiner',
password,
locale: 'en',
invitationToken: token,
})
.expect(201);
const joiner = await prisma.user.findUniqueOrThrow({ where: { username } });
ids.joiner = joiner.id;
// The invitation is tied to the new account…
const accepted = await prisma.invitation.findFirstOrThrow({
where: { acceptedUserId: joiner.id },
});
expect(accepted.acceptedAt).not.toBeNull();
// …and the token is single-use.
await api()
.post('/api/v1/auth/signup')
.send({
username: `inv-replay-${suffix}`,
email: `inv-replay-${suffix}@example.org`,
displayName: 'Replay',
password,
locale: 'en',
invitationToken: token,
})
.expect(400);
} finally {
await settings().set('auth.registrationMode', 'open', ids.alice!);
}
});
it('enforces the open-invitations quota and frees it on revoke', async () => {
await settings().set('invitations.maxOpenPerUser', 2, ids.alice!);
try {
const first = (
await api()
.post('/api/v1/invitations')
.set('Cookie', cookies.quota!)
.send({ email: `q1-${suffix}@example.org` })
.expect(201)
).body as InvitationView;
await api()
.post('/api/v1/invitations')
.set('Cookie', cookies.quota!)
.send({ email: `q2-${suffix}@example.org` })
.expect(201);
await api()
.post('/api/v1/invitations')
.set('Cookie', cookies.quota!)
.send({ email: `q3-${suffix}@example.org` })
.expect(400)
.expect((r) => expect((r.body as { code: string }).code).toBe('invitation_quota_reached'));
// Revoking an open invitation frees the slot…
await api()
.delete(`/api/v1/invitations/${first.id}`)
.set('Cookie', cookies.quota!)
.expect(204);
await api()
.post('/api/v1/invitations')
.set('Cookie', cookies.quota!)
.send({ email: `q3-${suffix}@example.org` })
.expect(201);
// …and the revoked token is dead.
const revokedToken = await mailedTokenFor(`q1-${suffix}@example.org`);
await api().post('/api/v1/invitations/preview').send({ token: revokedToken }).expect(400);
} finally {
await settings().set('invitations.maxOpenPerUser', 5, ids.alice!);
}
});
it('quota 0 disables inviting entirely', async () => {
await settings().set('invitations.maxOpenPerUser', 0, ids.alice!);
try {
await api()
.post('/api/v1/invitations')
.set('Cookie', cookies.alice!)
.send({ email: `off-${suffix}@example.org` })
.expect(403)
.expect((r) => expect((r.body as { code: string }).code).toBe('invitations_disabled'));
} finally {
await settings().set('invitations.maxOpenPerUser', 5, ids.alice!);
}
});
it('requires a session for create/list/revoke but not for preview', async () => {
await api().post('/api/v1/invitations').send({ email: 'nope@example.org' }).expect(401);
await api().get('/api/v1/invitations').expect(401);
await api()
.post('/api/v1/invitations/preview')
.send({ token: 'x'.repeat(32) })
.expect(400);
});
});

View File

@ -1,13 +0,0 @@
import { Module } from '@nestjs/common';
import { MailModule } from '../mail/mail.module';
import { InvitationsController } from './invitations.controller';
import { InvitationsService } from './invitations.service';
@Module({
imports: [MailModule],
controllers: [InvitationsController],
providers: [InvitationsService],
exports: [InvitationsService],
})
export class InvitationsModule {}

View File

@ -1,199 +0,0 @@
import { createHash, randomBytes } from 'node:crypto';
import {
BadRequestException,
ForbiddenException,
HttpException,
HttpStatus,
Injectable,
NotFoundException,
} from '@nestjs/common';
import {
InvitationListView,
InvitationPreview,
InvitationStatus,
InvitationView,
} from '@dorfteich/shared';
import { Invitation, User } from '@prisma/client';
import { AuditService } from '../audit/audit.service';
import { AppConfig } from '../config/app-config.service';
import { MailService } from '../mail/mail.service';
import { PrismaService } from '../prisma/prisma.service';
import { RateLimitService } from '../rate-limit/rate-limit.service';
import { InstanceSettingsService } from '../settings/instance-settings.service';
export const INVITATION_TTL_SECONDS = 14 * 24 * 60 * 60;
// Anti-spam backstop besides the open-invitations quota: without it a
// revoke-and-recreate loop would allow unlimited mail volume while never
// exceeding the quota.
const CREATE_LIMIT = { limit: 20, windowSeconds: 24 * 60 * 60 };
/**
* Peer invitations (issue #332). A user invites an e-mail address; the
* mailed single-use token lets exactly one signup through even while
* registration is closed (auth.service). Open (pending, unexpired)
* invitations count against the per-user quota
* `invitations.maxOpenPerUser` 0 turns the feature off. Only the
* SHA-256 hash of the token is stored (auth-tokens pattern); revoked and
* accepted rows are kept so the settings UI can show history.
*/
@Injectable()
export class InvitationsService {
constructor(
private readonly prisma: PrismaService,
private readonly mail: MailService,
private readonly rateLimits: RateLimitService,
private readonly settings: InstanceSettingsService,
private readonly audit: AuditService,
private readonly config: AppConfig,
) {}
async create(user: User, email: string): Promise<InvitationView> {
const maxOpen = (await this.settings.get('invitations.maxOpenPerUser')) as number;
if (maxOpen === 0) throw new ForbiddenException({ code: 'invitations_disabled' });
if ((await this.openCount(user.id)) >= maxOpen) {
throw new BadRequestException({ code: 'invitation_quota_reached' });
}
const limit = await this.rateLimits.hit(
'invitation-create',
user.id,
CREATE_LIMIT.limit,
CREATE_LIMIT.windowSeconds,
);
if (!limit.allowed) {
throw new HttpException({ code: 'rate_limited' }, HttpStatus.TOO_MANY_REQUESTS);
}
const raw = randomBytes(32).toString('base64url');
const row = await this.prisma.invitation.create({
data: {
inviterId: user.id,
email: email.toLowerCase(),
tokenHash: hashToken(raw),
expiresAt: new Date(Date.now() + INVITATION_TTL_SECONDS * 1000),
},
});
// The invitee has no account and no locale yet — the instance default
// decides the mail language. The greeting falls back to the address.
await this.mail.enqueue(
row.email,
'invitation',
{
displayName: row.email,
inviterName: user.displayName,
link: `${this.config.env.APP_BASE_URL}/signup?invitation=${raw}`,
},
(await this.settings.get('instance.defaultLocale')) as 'de' | 'en',
);
await this.audit.record({
action: 'invitation.created',
actorId: user.id,
targetType: 'invitation',
targetId: row.id,
});
return this.viewOf(row);
}
async list(user: User): Promise<InvitationListView> {
const rows = await this.prisma.invitation.findMany({
where: { inviterId: user.id },
orderBy: { createdAt: 'desc' },
take: 100,
});
return {
invitations: rows.map((row) => this.viewOf(row)),
open: await this.openCount(user.id),
maxOpen: (await this.settings.get('invitations.maxOpenPerUser')) as number,
};
}
async revoke(user: User, id: string): Promise<void> {
const row = await this.prisma.invitation.findFirst({
where: { id, inviterId: user.id },
});
if (!row) throw new NotFoundException();
if (row.acceptedAt) throw new BadRequestException({ code: 'invitation_already_accepted' });
if (row.revokedAt) return; // idempotent
await this.prisma.invitation.update({ where: { id }, data: { revokedAt: new Date() } });
await this.audit.record({
action: 'invitation.revoked',
actorId: user.id,
targetType: 'invitation',
targetId: id,
});
}
/** What the signup screen may show for a link before it is used. */
async preview(raw: string): Promise<InvitationPreview> {
const row = await this.prisma.invitation.findUnique({
where: { tokenHash: hashToken(raw) },
include: { inviter: true },
});
if (!row || row.revokedAt || row.acceptedAt || row.expiresAt <= new Date()) {
throw new BadRequestException({ code: 'token_invalid' });
}
return { email: row.email, inviterName: row.inviter.displayName };
}
/**
* Atomically claims the token (only one signup can flip acceptedAt from
* null). Returns the row, or null for unknown/revoked/expired/used
* tokens. The caller un-redeems if the signup fails afterwards.
*/
async redeem(raw: string): Promise<Invitation | null> {
const result = await this.prisma.invitation.updateMany({
where: {
tokenHash: hashToken(raw),
revokedAt: null,
acceptedAt: null,
expiresAt: { gt: new Date() },
},
data: { acceptedAt: new Date() },
});
if (result.count === 0) return null;
return this.prisma.invitation.findUnique({ where: { tokenHash: hashToken(raw) } });
}
/** Ties the redeemed invitation to the account it created. */
async markAccepted(id: string, userId: string): Promise<void> {
await this.prisma.invitation.update({ where: { id }, data: { acceptedUserId: userId } });
}
/** Rolls a redeem back when the signup it gated failed (e.g. duplicate
* username) the invitee must be able to try again with the same link. */
async unredeem(id: string): Promise<void> {
await this.prisma.invitation.updateMany({
where: { id, acceptedUserId: null },
data: { acceptedAt: null },
});
}
private openCount(inviterId: string): Promise<number> {
return this.prisma.invitation.count({
where: { inviterId, revokedAt: null, acceptedAt: null, expiresAt: { gt: new Date() } },
});
}
private viewOf(row: Invitation): InvitationView {
return {
id: row.id,
email: row.email,
status: statusOf(row),
createdAt: row.createdAt.toISOString(),
expiresAt: row.expiresAt.toISOString(),
};
}
}
function statusOf(row: Invitation): InvitationStatus {
if (row.revokedAt) return 'revoked';
if (row.acceptedAt) return 'accepted';
if (row.expiresAt <= new Date()) return 'expired';
return 'pending';
}
function hashToken(raw: string): string {
return createHash('sha256').update(raw).digest('hex');
}

View File

@ -1,6 +1,6 @@
import { apiI18n } from '../i18n/api-i18n'; import { apiI18n } from '../i18n/api-i18n';
export type MailTemplate = 'verifyEmail' | 'resetPassword' | 'smtpTest' | 'invitation'; export type MailTemplate = 'verifyEmail' | 'resetPassword' | 'smtpTest';
export interface RenderedMail { export interface RenderedMail {
subject: string; subject: string;
@ -15,15 +15,14 @@ export interface RenderedMail {
*/ */
export function renderMail( export function renderMail(
template: MailTemplate, template: MailTemplate,
// Extra keys (e.g. inviterName, #332) interpolate into the body text. params: { displayName: string; link: string },
params: { displayName: string; link: string } & Record<string, string>,
locale: 'de' | 'en', locale: 'de' | 'en',
): RenderedMail { ): RenderedMail {
const t = (key: string, options: Record<string, string> = {}): string => const t = (key: string, options: Record<string, string> = {}): string =>
apiI18n.t(`mails:${key}`, { lng: locale, ...options }); apiI18n.t(`mails:${key}`, { lng: locale, ...options });
const greeting = t('common.greeting', { displayName: params.displayName }); const greeting = t('common.greeting', { displayName: params.displayName });
const body = t(`${template}.body`, params); const body = t(`${template}.body`);
const action = t(`${template}.action`); const action = t(`${template}.action`);
const expiry = t(`${template}.expiry`); const expiry = t(`${template}.expiry`);
const ignore = t('common.ignoreHint'); const ignore = t('common.ignoreHint');

View File

@ -14,7 +14,7 @@ export class MailService {
async enqueue( async enqueue(
to: string, to: string,
template: MailTemplate, template: MailTemplate,
params: { displayName: string; link: string } & Record<string, string>, params: { displayName: string; link: string },
locale: 'de' | 'en', locale: 'de' | 'en',
): Promise<void> { ): Promise<void> {
const rendered = renderMail(template, params, locale); const rendered = renderMail(template, params, locale);

View File

@ -112,9 +112,7 @@ export class McpService {
case 'read_page': case 'read_page':
await this.assertPondExposed(input.pond!, token); await this.assertPondExposed(input.pond!, token);
await this.requirePage(user, input.pond!, input.page!, 'read'); await this.requirePage(user, input.pond!, input.page!, 'read');
// The shared getPage also records the read-trail event (#222) — return asJson(await this.publicApi.getPage(user, input.pond!, input.page!));
// MCP reads of classified pages are covered by the same emission.
return asJson(await this.publicApi.getPage(user, token, input.pond!, input.page!));
case 'search': { case 'search': {
if (input.pond) await this.assertPondExposed(input.pond, token); if (input.pond) await this.assertPondExposed(input.pond, token);

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';
/** /**
@ -97,7 +97,7 @@ describe.skipIf(!hasTestDb)('pond members (e2e, issue #54)', () => {
const ids = Object.values(userIds); const ids = Object.values(userIds);
await prisma.roleGrant.deleteMany({ where: { pond: { ownerId: { in: ids } } } }); await prisma.roleGrant.deleteMany({ where: { pond: { ownerId: { in: ids } } } });
await prisma.quotaOverride.deleteMany({ where: { subjectId: { in: [...ids, pondId] } } }); await prisma.quotaOverride.deleteMany({ where: { subjectId: { in: [...ids, pondId] } } });
await deletePondsWhere(prisma, { ownerId: { in: ids } }); await prisma.pond.deleteMany({ where: { ownerId: { in: ids } } });
await prisma.user.deleteMany({ where: { id: { in: ids } } }); await prisma.user.deleteMany({ where: { id: { in: ids } } });
await prisma.$disconnect(); await prisma.$disconnect();
await app.close(); await app.close();

View File

@ -3,7 +3,11 @@ import { createHmac } from 'node:crypto';
import { deriveTokenKey } from '@dorfteich/shared/token-crypto'; import { deriveTokenKey } from '@dorfteich/shared/token-crypto';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { signUnsubscribeToken, verifyUnsubscribeToken } from './unsubscribe-token'; import {
LEGACY_VERIFY_UNTIL,
signUnsubscribeToken,
verifyUnsubscribeToken,
} from './unsubscribe-token';
const secret = 'test-secret-at-least-16-chars-long'; const secret = 'test-secret-at-least-16-chars-long';
const TTL_MS = 90 * 24 * 60 * 60 * 1000; const TTL_MS = 90 * 24 * 60 * 60 * 1000;
@ -46,15 +50,23 @@ describe('unsubscribe token', () => {
expect(verifyUnsubscribeToken(`${body}.${sig}`, secret)).toBeNull(); expect(verifyUnsubscribeToken(`${body}.${sig}`, secret)).toBeNull();
}); });
it('rejects pre-separation legacy tokens — the dual-verify window is gone (#296)', () => { it('accepts a pre-separation legacy token inside the dual-verify window', () => {
const now = Date.now(); const inWindow = LEGACY_VERIFY_UNTIL - 24 * 60 * 60 * 1000;
// Unexpired on its own terms — rejected because only the subkey verifies. expect(verifyUnsubscribeToken(legacyToken('u1', inWindow - TTL_MS / 2), secret, inWindow)).toBe(
expect(verifyUnsubscribeToken(legacyToken('u1', now - 1000), secret, now)).toBeNull(); 'u1',
);
}); });
it('verifies freshly minted tokens via the subkey', () => { it('rejects a legacy token once the dual-verify window has closed', () => {
const now = Date.now(); const afterWindow = LEGACY_VERIFY_UNTIL + 1000;
const token = signUnsubscribeToken('u1', secret, now); // Unexpired on its own terms — rejected purely because the window closed.
expect(verifyUnsubscribeToken(token, secret, now + 1000)).toBe('u1'); const token = legacyToken('u1', afterWindow - 1000);
expect(verifyUnsubscribeToken(token, secret, afterWindow)).toBeNull();
});
it('verifies freshly minted tokens via the subkey, independent of the window', () => {
const afterWindow = LEGACY_VERIFY_UNTIL + 24 * 60 * 60 * 1000;
const token = signUnsubscribeToken('u1', secret, afterWindow);
expect(verifyUnsubscribeToken(token, secret, afterWindow + 1000)).toBe('u1');
}); });
}); });

View File

@ -13,17 +13,26 @@ import { deriveTokenKey } from '@dorfteich/shared/token-crypto';
const TTL_SECONDS = 90 * 24 * 60 * 60; const TTL_SECONDS = 90 * 24 * 60 * 60;
// The pre-#188 dual-verify window (root secret + `digest-unsubscribe.` /**
// prefix) was removed EARLY by operator decision at the ADR 0020 * Dual-verify window (#188, ADR 0020): before the key separation, tokens
// acceptance (issue #296): links in mails sent before the key separation * were HMACed with the root secret over a `digest-unsubscribe.` prefix.
// no longer work — recipients use the in-app notification settings. * Those links live in digest mails that are already sent and stay valid
// Verification is subkey-only; the regression test pins that the legacy * for their full 90-day TTL, so verification accepts the legacy derivation
// derivation can never verify again. * until every pre-separation token has expired. Tokens are only ever
* SIGNED with the new subkey; the legacy path is verify-only and goes dead
* automatically on the date below (last possible legacy expiry, rounded up).
*/
export const LEGACY_VERIFY_UNTIL = Date.parse('2026-11-01T00:00:00Z');
const LEGACY_PURPOSE = 'digest-unsubscribe';
function signature(body: string, rootSecret: string): Buffer { function signature(body: string, rootSecret: string): Buffer {
return createHmac('sha256', deriveTokenKey(rootSecret, 'unsubscribe')).update(body).digest(); return createHmac('sha256', deriveTokenKey(rootSecret, 'unsubscribe')).update(body).digest();
} }
function legacySignature(body: string, rootSecret: string): Buffer {
return createHmac('sha256', rootSecret).update(`${LEGACY_PURPOSE}.${body}`).digest();
}
export function signUnsubscribeToken(userId: string, rootSecret: string, now = Date.now()): string { export function signUnsubscribeToken(userId: string, rootSecret: string, now = Date.now()): string {
const body = Buffer.from( const body = Buffer.from(
JSON.stringify({ userId, exp: Math.floor(now / 1000) + TTL_SECONDS }), JSON.stringify({ userId, exp: Math.floor(now / 1000) + TTL_SECONDS }),
@ -50,7 +59,10 @@ export function verifyUnsubscribeToken(
} catch { } catch {
return null; return null;
} }
if (!matches(provided, signature(body, rootSecret))) return null; const current = matches(provided, signature(body, rootSecret));
const legacy =
!current && now < LEGACY_VERIFY_UNTIL && matches(provided, legacySignature(body, rootSecret));
if (!current && !legacy) return null;
try { try {
const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) as { const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) as {
userId?: string; userId?: string;

View File

@ -39,7 +39,6 @@ import {
RequiresPagePermission, RequiresPagePermission,
RequiresPondRole, RequiresPondRole,
} from '../permissions/permission.decorators'; } from '../permissions/permission.decorators';
import { readActorOf } from '../read-trail/read-actor';
import { PagesService } from './pages.service'; import { PagesService } from './pages.service';
/** Page CRUD and Yjs state persistence (issue #23; permission guard since #52). */ /** Page CRUD and Yjs state persistence (issue #23; permission guard since #52). */
@ -76,7 +75,7 @@ export class PagesController {
@Get('pages/:id') @Get('pages/:id')
@RequiresPagePermission('read', { idParam: 'id' }) @RequiresPagePermission('read', { idParam: 'id' })
async getState(@Param('id') id: string, @Req() request: AuthedRequest): Promise<PageStateView> { async getState(@Param('id') id: string, @Req() request: AuthedRequest): Promise<PageStateView> {
return this.pages.getState(request.user!, id, readActorOf(request)); return this.pages.getState(request.user!, id);
} }
/** Toggles one task-list checkbox (issue #153). Applied asynchronously /** Toggles one task-list checkbox (issue #153). Applied asynchronously
@ -107,7 +106,7 @@ export class PagesController {
@Param('id') id: string, @Param('id') id: string,
@Req() request: AuthedRequest, @Req() request: AuthedRequest,
): Promise<CollabTokenResponse> { ): Promise<CollabTokenResponse> {
return this.pages.issueCollabToken(request.user ?? null, id, readActorOf(request)); return this.pages.issueCollabToken(request.user ?? null, id);
} }
/** Markdown export (issue #30) — downloads `<slug>.md`. */ /** Markdown export (issue #30) — downloads `<slug>.md`. */
@ -118,11 +117,7 @@ export class PagesController {
@Req() request: AuthedRequest, @Req() request: AuthedRequest,
@Res({ passthrough: true }) response: Response, @Res({ passthrough: true }) response: Response,
): Promise<string> { ): Promise<string> {
const { slug, markdown } = await this.pages.exportMarkdown( const { slug, markdown } = await this.pages.exportMarkdown(request.user!, id);
request.user!,
id,
readActorOf(request),
);
response.set('Content-Type', 'text/markdown; charset=utf-8'); response.set('Content-Type', 'text/markdown; charset=utf-8');
response.set('Content-Disposition', `attachment; filename="${slug}.md"`); response.set('Content-Disposition', `attachment; filename="${slug}.md"`);
return markdown; return markdown;
@ -138,7 +133,7 @@ export class PagesController {
@Param('slug') slug: string, @Param('slug') slug: string,
@Req() request: AuthedRequest, @Req() request: AuthedRequest,
): Promise<PageStateView> { ): Promise<PageStateView> {
return this.pages.getStateBySlug(request.user!, pondId, slug, readActorOf(request)); return this.pages.getStateBySlug(request.user!, pondId, slug);
} }
/** /**

View File

@ -1,5 +1,6 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { PondsModule } from '../ponds/ponds.module';
import { WatchesModule } from '../watches/watches.module'; import { WatchesModule } from '../watches/watches.module';
import { SearchModule } from '../search/search.module'; import { SearchModule } from '../search/search.module';
@ -9,7 +10,7 @@ import { PluginApiController } from './plugin-api.controller';
import { TasksService } from './tasks.service'; import { TasksService } from './tasks.service';
@Module({ @Module({
imports: [SearchModule, WatchesModule], imports: [PondsModule, SearchModule, WatchesModule],
controllers: [PagesController, PluginApiController], controllers: [PagesController, PluginApiController],
providers: [PagesService, TasksService], providers: [PagesService, TasksService],
exports: [PagesService, TasksService], exports: [PagesService, TasksService],

View File

@ -38,11 +38,6 @@ import { AuditService } from '../audit/audit.service';
import { AppConfig } from '../config/app-config.service'; import { AppConfig } from '../config/app-config.service';
import { PermissionService } from '../permissions/permission.service'; import { PermissionService } from '../permissions/permission.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import {
ReadTrailService,
type ReadActor,
type ReadChannel,
} from '../read-trail/read-trail.service';
import { InstanceSettingsService } from '../settings/instance-settings.service'; import { InstanceSettingsService } from '../settings/instance-settings.service';
import { WatchesService } from '../watches/watches.service'; import { WatchesService } from '../watches/watches.service';
import { SearchProvider } from '../search/search.provider'; import { SearchProvider } from '../search/search.provider';
@ -85,29 +80,10 @@ export class PagesService {
private readonly watches: WatchesService, private readonly watches: WatchesService,
private readonly settings: InstanceSettingsService, private readonly settings: InstanceSettingsService,
private readonly audit: AuditService, private readonly audit: AuditService,
private readonly readTrail: ReadTrailService,
) { ) {
this.logger.setContext(PagesService.name); this.logger.setContext(PagesService.name);
} }
/** Records the read-trail event for a classified page (issue #222) a
* no-op for unclassified pages, a hard failure when recording fails. */
private async recordRead(
page: Pick<Page, 'id' | 'pondId' | 'classification'>,
read: ReadActor,
channel: ReadChannel,
details?: Record<string, unknown>,
): Promise<void> {
if (page.classification !== 'VS_NFD') return;
await this.readTrail.record({
...read,
pageId: page.id,
pondId: page.pondId,
channel,
details,
});
}
viewOf(page: Page): PageView { viewOf(page: Page): PageView {
return { return {
id: page.id, id: page.id,
@ -424,9 +400,8 @@ export class PagesService {
AND from_page_id IN (SELECT id FROM pages WHERE pond_id = ${pondId})`; AND from_page_id IN (SELECT id FROM pages WHERE pond_id = ${pondId})`;
} }
async getState(_user: User, id: string, read: ReadActor): Promise<PageStateView> { async getState(_user: User, id: string): Promise<PageStateView> {
const page = await this.findLivePage(id); const page = await this.findLivePage(id);
await this.recordRead(page, read, 'page_view');
return this.stateViewOf(page); return this.stateViewOf(page);
} }
@ -440,21 +415,12 @@ export class PagesService {
* guard has already granted read access via a `public` grant, so they receive * guard has already granted read access via a `public` grant, so they receive
* an `ro` token with a `null` subject. * an `ro` token with a `null` subject.
*/ */
async issueCollabToken( async issueCollabToken(user: User | null, id: string): Promise<CollabTokenResponse> {
user: User | null,
id: string,
read: ReadActor,
): Promise<CollabTokenResponse> {
const page = await this.findLivePage(id); const page = await this.findLivePage(id);
const canWrite = await this.permissions.canAccessPage(user, page, 'write'); const canWrite = await this.permissions.canAccessPage(user, page, 'write');
const mode = canWrite ? 'rw' : 'ro'; const mode = canWrite ? 'rw' : 'ro';
const userId = user?.id ?? null; const userId = user?.id ?? null;
// Token issuance is the api-side proxy for the collab WS join (ADR 0023):
// the collab server has no permission context, and the 60 s token TTL
// makes a live session re-request one per minute — per-minute granularity
// the dedup window (#223) then collapses.
await this.recordRead(page, read, 'collab_join', { mode });
const token = await signCollabToken( const token = await signCollabToken(
{ userId, pageId: page.id, mode }, { userId, pageId: page.id, mode },
this.config.env.COLLAB_TOKEN_SECRET, this.config.env.COLLAB_TOKEN_SECRET,
@ -466,15 +432,9 @@ export class PagesService {
} }
/** The trashed-page hint for editors (issue #31) moved into the guard. */ /** The trashed-page hint for editors (issue #31) moved into the guard. */
async getStateBySlug( async getStateBySlug(_user: User, pondId: string, slug: string): Promise<PageStateView> {
_user: User,
pondId: string,
slug: string,
read: ReadActor,
): Promise<PageStateView> {
const page = await this.prisma.page.findFirst({ where: { pondId, slug, deletedAt: null } }); const page = await this.prisma.page.findFirst({ where: { pondId, slug, deletedAt: null } });
if (!page) throw new NotFoundException(); if (!page) throw new NotFoundException();
await this.recordRead(page, read, 'page_view');
return this.stateViewOf(page); return this.stateViewOf(page);
} }
@ -688,16 +648,8 @@ export class PagesService {
* `page_content_cache.markdown` (refreshed on every state save, #23) * `page_content_cache.markdown` (refreshed on every state save, #23)
* rather than re-decoding the Yjs state, so export always matches what * rather than re-decoding the Yjs state, so export always matches what
* the app itself considers the page's current Markdown representation. */ * the app itself considers the page's current Markdown representation. */
async exportMarkdown( async exportMarkdown(_user: User, id: string): Promise<{ slug: string; markdown: string }> {
_user: User,
id: string,
read: ReadActor,
// The plugin content route reuses this markdown path but is a page view
// in the trail's terms (issue #222), not a download.
channel: ReadChannel = 'export',
): Promise<{ slug: string; markdown: string }> {
const page = await this.findLivePage(id); const page = await this.findLivePage(id);
await this.recordRead(page, read, channel);
const cache = await this.prisma.pageContentCache.findUnique({ where: { pageId: page.id } }); const cache = await this.prisma.pageContentCache.findUnique({ where: { pageId: page.id } });
return { slug: page.slug, markdown: cache?.markdown ?? '' }; return { slug: page.slug, markdown: cache?.markdown ?? '' };
} }

View File

@ -4,7 +4,6 @@ import type { PluginPageContent, PluginPageMeta, PluginPageSummary } from '@dorf
import { AuthedRequest } from '../auth/auth.guard'; import { AuthedRequest } from '../auth/auth.guard';
import { RequiresPagePermission, RequiresPondRole } from '../permissions/permission.decorators'; import { RequiresPagePermission, RequiresPondRole } from '../permissions/permission.decorators';
import { readActorOf } from '../read-trail/read-actor';
import { PagesService } from './pages.service'; import { PagesService } from './pages.service';
@ -46,12 +45,7 @@ export class PluginApiController {
@Param('pageId') pageId: string, @Param('pageId') pageId: string,
@Req() request: AuthedRequest, @Req() request: AuthedRequest,
): Promise<PluginPageContent> { ): Promise<PluginPageContent> {
const { markdown } = await this.pages.exportMarkdown( const { markdown } = await this.pages.exportMarkdown(request.user!, pageId);
request.user!,
pageId,
readActorOf(request),
'page_view',
);
return { markdown }; return { markdown };
} }

View File

@ -5,7 +5,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';
/** /**
@ -23,7 +23,6 @@ describe.skipIf(!hasTestDb)('plugin API (e2e, issue #74)', () => {
const userIds: Record<string, string> = {}; const userIds: Record<string, string> = {};
const cookies: Record<string, string> = {}; const cookies: Record<string, string> = {};
let pondId: string; let pondId: string;
let startPageId: string;
let openPageId: string; let openPageId: string;
let secretPageId: string; let secretPageId: string;
@ -72,10 +71,6 @@ describe.skipIf(!hasTestDb)('plugin API (e2e, issue #74)', () => {
.send({ name: `PlugApi Pond ${suffix}` }) .send({ name: `PlugApi Pond ${suffix}` })
.expect(201); .expect(201);
pondId = pond.body.id; pondId = pond.body.id;
// Every pond created through the api starts with a page (issue #302);
// a "full reader sees everything" assertion has to include it rather
// than pretend the pond began empty.
startPageId = pond.body.settings.startPageId as string;
const open = await api() const open = await api()
.post(`/api/v1/ponds/${pondId}/pages`) .post(`/api/v1/ponds/${pondId}/pages`)
@ -133,10 +128,10 @@ describe.skipIf(!hasTestDb)('plugin API (e2e, issue #74)', () => {
await prisma.pageContentCache.deleteMany({ where: { page: { pondId } } }); await prisma.pageContentCache.deleteMany({ where: { page: { pondId } } });
await prisma.pageUpdate.deleteMany({ where: { page: { pondId } } }); await prisma.pageUpdate.deleteMany({ where: { page: { pondId } } });
await prisma.page.deleteMany({ where: { pondId } }); await prisma.page.deleteMany({ where: { pondId } });
await deletePondsWhere(prisma, { id: pondId }); await prisma.pond.deleteMany({ where: { id: pondId } });
const ids = Object.values(userIds); const ids = Object.values(userIds);
await prisma.roleGrant.deleteMany({ where: { pond: { ownerId: { in: ids } } } }); await prisma.roleGrant.deleteMany({ where: { pond: { ownerId: { in: ids } } } });
await deletePondsWhere(prisma, { ownerId: { in: ids } }); await prisma.pond.deleteMany({ where: { ownerId: { in: ids } } });
await prisma.quotaOverride.deleteMany({ where: { subjectId: { in: ids } } }); await prisma.quotaOverride.deleteMany({ where: { subjectId: { in: ids } } });
await prisma.user.deleteMany({ where: { id: { in: ids } } }); await prisma.user.deleteMany({ where: { id: { in: ids } } });
await prisma.$disconnect(); await prisma.$disconnect();
@ -149,7 +144,7 @@ describe.skipIf(!hasTestDb)('plugin API (e2e, issue #74)', () => {
.set('Cookie', cookies.owner!) .set('Cookie', cookies.owner!)
.expect(200); .expect(200);
expect(res.body.map((p: { id: string }) => p.id).sort()).toEqual( expect(res.body.map((p: { id: string }) => p.id).sort()).toEqual(
[startPageId, openPageId, secretPageId].sort(), [openPageId, secretPageId].sort(),
); );
expect(res.body[0]).toMatchObject({ title: expect.any(String), slug: expect.any(String) }); expect(res.body[0]).toMatchObject({ title: expect.any(String), slug: expect.any(String) });
// Label *names* travel with each summary (issue #77, page-index filter). // Label *names* travel with each summary (issue #77, page-index filter).

View File

@ -26,15 +26,8 @@ describe('sort-key helpers (issue #45)', () => {
* pattern repeatedly drop the last page between the first two must never * pattern repeatedly drop the last page between the first two must never
* collide and never overflow the key length, because the caller rebalances * collide and never overflow the key length, because the caller rebalances
* when {@link nextKeyOrRebalance} returns null. * when {@link nextKeyOrRebalance} returns null.
*
* Under parallel CI load the 10.000 iterations have repeatedly exceeded the
* default 5 s per-test timeout (runs 685, 699 same code passed on rerun),
* so this test carries its own budget.
*/ */
it( it('10.000 adversarial reorders never collide or overflow (rebalance verified)', () => {
'10.000 adversarial reorders never collide or overflow (rebalance verified)',
{ timeout: 30_000 },
() => {
// Start with five pages in a fixed order. // Start with five pages in a fixed order.
let order = evenlySpacedKeys(5).map((key, i) => ({ id: `p${i}`, key })); let order = evenlySpacedKeys(5).map((key, i) => ({ id: `p${i}`, key }));
let rebalances = 0; let rebalances = 0;
@ -76,6 +69,5 @@ describe('sort-key helpers (issue #45)', () => {
// The adversarial pattern must have forced at least one rebalance. // The adversarial pattern must have forced at least one rebalance.
expect(rebalances).toBeGreaterThan(0); expect(rebalances).toBeGreaterThan(0);
}, });
);
}); });

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';
/** /**
@ -120,11 +120,11 @@ describe.skipIf(!hasTestDb)('permission enforcement (e2e, issue #52)', () => {
await prisma.pageContentCache.deleteMany({ where: { page: { pondId } } }); await prisma.pageContentCache.deleteMany({ where: { page: { pondId } } });
await prisma.pageUpdate.deleteMany({ where: { page: { pondId } } }); await prisma.pageUpdate.deleteMany({ where: { page: { pondId } } });
await prisma.page.deleteMany({ where: { pondId } }); await prisma.page.deleteMany({ where: { pondId } });
await deletePondsWhere(prisma, { id: pondId }); await prisma.pond.deleteMany({ where: { id: pondId } });
// Personal ponds (and their grants) before their users. // Personal ponds (and their grants) before their users.
const ids = Object.values(userIds); const ids = Object.values(userIds);
await prisma.roleGrant.deleteMany({ where: { pond: { ownerId: { in: ids } } } }); await prisma.roleGrant.deleteMany({ where: { pond: { ownerId: { in: ids } } } });
await deletePondsWhere(prisma, { ownerId: { in: ids } }); await prisma.pond.deleteMany({ where: { ownerId: { in: ids } } });
await prisma.quotaOverride.deleteMany({ where: { subjectId: { in: ids } } }); await prisma.quotaOverride.deleteMany({ where: { subjectId: { in: ids } } });
await prisma.user.deleteMany({ where: { id: { in: ids } } }); await prisma.user.deleteMany({ where: { id: { in: ids } } });
await prisma.$disconnect(); await prisma.$disconnect();

View File

@ -2,7 +2,6 @@ import {
BadRequestException, BadRequestException,
Body, Body,
ConflictException, ConflictException,
ForbiddenException,
Controller, Controller,
Delete, Delete,
Get, Get,
@ -41,10 +40,6 @@ function toHttpException(error: PluginPackageError): HttpException {
case 'plugin_version_not_higher': case 'plugin_version_not_higher':
case 'plugin_not_optional': case 'plugin_not_optional':
return new ConflictException(body); return new ConflictException(body);
// Hash pinning (#232): the upload is well-formed, the policy says no.
case 'plugin_not_pinned':
case 'plugin_hash_mismatch':
return new ForbiddenException(body);
case 'plugin_too_large': case 'plugin_too_large':
return new PayloadTooLargeException(body); return new PayloadTooLargeException(body);
default: default:

View File

@ -96,9 +96,7 @@ export class PluginAssetsController {
@Param('version') version: string, @Param('version') version: string,
@Res({ passthrough: true }) response: Response, @Res({ passthrough: true }) response: Response,
): Promise<string> { ): Promise<string> {
// getServable enforces the hash-pinning allowlist (#232): a blocked const plugin = await this.plugins.get(id);
// plugin 404s here exactly like a missing one, and is audited.
const plugin = await this.plugins.getServable(id);
if (!plugin || plugin.version !== version) throw new NotFoundException(); if (!plugin || plugin.version !== version) throw new NotFoundException();
// Asset base is built from the configured public origin, not the request // Asset base is built from the configured public origin, not the request
@ -124,9 +122,8 @@ export class PluginAssetsController {
@Res({ passthrough: true }) response: Response, @Res({ passthrough: true }) response: Response,
): Promise<StreamableFile> { ): Promise<StreamableFile> {
// Only serve assets for an installed, current version — a removed plugin or // Only serve assets for an installed, current version — a removed plugin or
// a stale version pointer must not leak files. getServable additionally // a stale version pointer must not leak files.
// enforces the hash-pinning allowlist (#232). const plugin = await this.plugins.get(id);
const plugin = await this.plugins.getServable(id);
if (!plugin || plugin.version !== version) throw new NotFoundException(); if (!plugin || plugin.version !== version) throw new NotFoundException();
const rest = (request.params as Record<string, unknown>).rest; const rest = (request.params as Record<string, unknown>).rest;

View File

@ -1,172 +0,0 @@
import { createHash } from 'node:crypto';
import { INestApplication } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { zipSync } from 'fflate';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
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';
const enc = (text: string) => new TextEncoder().encode(text);
function manifest(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
id: 'pin-me',
name: 'Pin Me',
version: '1.0.0',
apiVersion: '1',
kind: 'code',
extensionPoints: [{ type: 'pageTool', id: 'pin', title: { de: 'Pin', en: 'Pin' } }],
permissions: [],
license: 'MIT',
...overrides,
};
}
function pluginZip(m: Record<string, unknown>, bundle = 'export default {}'): Buffer {
return Buffer.from(
zipSync({ 'manifest.json': enc(JSON.stringify(m)), 'plugin.js': enc(bundle) }),
);
}
const sha256 = (buffer: Buffer) => createHash('sha256').update(buffer).digest('hex');
/**
* Hash-pinning allowlist (issue #232, ADR 0025): with a non-empty
* `plugins.allowlist`, only pinned ids with the exact bundle hash install
* and load; tampered or unpinned bundles fail closed with a stable code,
* every rejection is audited, and a version bump requires an explicit
* re-pin. Empty allowlist = unchanged behaviour (backward compatible).
*/
describe.skipIf(!hasTestDb)('plugin hash pinning (e2e, issue #232)', () => {
let app: INestApplication;
let prisma: PrismaClient;
let settings: InstanceSettingsService;
const suffix = uniqueSuffix();
const password = 'gepinnt ist gepinnt 1';
let adminCookie: string;
let adminId: string;
const api = () => request(app.getHttpServer());
const zipV1 = pluginZip(manifest());
const zipV1Tampered = pluginZip(manifest(), 'export default { evil: true }');
const zipV2 = pluginZip(manifest({ version: '1.1.0' }));
async function setAllowlist(entries: { id: string; sha256: string }[]): Promise<void> {
await settings.set('plugins.allowlist', entries, adminId);
}
beforeAll(async () => {
prisma = createTestPrisma();
await prisma.pondPlugin.deleteMany({});
await prisma.plugin.deleteMany({});
await prisma.instanceSetting.deleteMany({ where: { key: 'plugins.allowlist' } });
app = await createTestApp();
settings = app.get(InstanceSettingsService);
const users = app.get(UsersService);
const adminUser = await users.createUser({
username: `pin-admin-${suffix}`,
email: `pin-admin-${suffix}@example.org`,
displayName: 'Pin Admin',
password,
locale: 'en',
});
await users.markEmailVerified(adminUser.id);
await prisma.user.update({ where: { id: adminUser.id }, data: { isSiteAdmin: true } });
adminId = adminUser.id;
adminCookie = sessionCookieOf(
await api()
.post('/api/v1/auth/login')
.send({ usernameOrEmail: `pin-admin-${suffix}`, password })
.expect(200),
);
});
afterAll(async () => {
await prisma.instanceSetting.deleteMany({ where: { key: 'plugins.allowlist' } });
await prisma.pondPlugin.deleteMany({});
await prisma.plugin.deleteMany({});
await prisma.auditEntry.deleteMany({ where: { targetId: { in: ['pin-me', 'stranger'] } } });
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
await prisma.$disconnect();
await app.close();
});
it('empty allowlist: installs record the hash, nothing is enforced', async () => {
const res = await api()
.post('/api/v1/admin/plugins')
.set('Cookie', adminCookie)
.attach('file', zipV1, 'pin-me.zip')
.expect(201);
expect(res.body.pinning).toBe('not_enforced');
expect(res.body.bundleSha256).toBe(sha256(zipV1));
await api().get(`/api/v1/plugins/pin-me/1.0.0/frame`).expect(200);
});
it('pinned hash: plugin stays loadable and reports pinned', async () => {
await setAllowlist([{ id: 'pin-me', sha256: sha256(zipV1) }]);
const list = await api().get('/api/v1/admin/plugins').set('Cookie', adminCookie).expect(200);
expect(list.body[0].pinning).toBe('pinned');
await api().get(`/api/v1/plugins/pin-me/1.0.0/frame`).expect(200);
});
it('unpinned plugin: install rejected with the stable code and audited', async () => {
const res = await api()
.post('/api/v1/admin/plugins')
.set('Cookie', adminCookie)
.attach('file', pluginZip(manifest({ id: 'stranger', name: 'Stranger' })), 'stranger.zip')
.expect(403);
expect(res.body.code).toBe('plugin_not_pinned');
const audit = await prisma.auditEntry.findFirst({
where: { action: 'plugin.rejected', targetId: 'stranger' },
});
expect(audit).not.toBeNull();
});
it('tampered bundle: same id + pin, different bytes — install rejected', async () => {
// A tampered re-delivery of the pinned version arrives as an update
// attempt; the hash gate must fire before any version comparison.
const res = await api()
.post('/api/v1/admin/plugins')
.set('Cookie', adminCookie)
.attach('file', zipV1Tampered, 'pin-me.zip')
.expect(403);
expect(res.body.code).toBe('plugin_hash_mismatch');
});
it('pin changed away from the installed hash: plugin no longer loads, audited', async () => {
await setAllowlist([{ id: 'pin-me', sha256: sha256(zipV1Tampered) }]);
const list = await api().get('/api/v1/admin/plugins').set('Cookie', adminCookie).expect(200);
// The admin still SEES the plugin, with the deviation named…
expect(list.body[0].pinning).toBe('mismatch');
// …but nothing serves it: frame 404s and the rejection is audited.
await api().get(`/api/v1/plugins/pin-me/1.0.0/frame`).expect(404);
const audit = await prisma.auditEntry.findFirst({
where: { action: 'plugin.rejected', targetId: 'pin-me' },
});
expect(audit).not.toBeNull();
});
it('version bump requires an explicit re-pin', async () => {
await setAllowlist([{ id: 'pin-me', sha256: sha256(zipV1) }]);
const rejected = await api()
.post('/api/v1/admin/plugins')
.set('Cookie', adminCookie)
.attach('file', zipV2, 'pin-me-1.1.zip')
.expect(403);
expect(rejected.body.code).toBe('plugin_hash_mismatch');
await setAllowlist([{ id: 'pin-me', sha256: sha256(zipV2) }]);
const res = await api()
.post('/api/v1/admin/plugins')
.set('Cookie', adminCookie)
.attach('file', zipV2, 'pin-me-1.1.zip')
.expect(201);
expect(res.body.pinning).toBe('pinned');
await api().get(`/api/v1/plugins/pin-me/1.1.0/frame`).expect(200);
});
});

View File

@ -9,7 +9,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { AuthTokensService } from '../auth/auth-tokens.service'; import { AuthTokensService } from '../auth/auth-tokens.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';
import { PluginStorageService } from './plugin-storage.service'; import { PluginStorageService } from './plugin-storage.service';
@ -456,7 +456,7 @@ describe.skipIf(!hasTestDb)('plugins kill switch (e2e, issue #200)', () => {
await prisma.plugin.deleteMany({ where: { id: pluginId } }); await prisma.plugin.deleteMany({ where: { id: pluginId } });
const where = { pond: { owner: { username: { contains: suffix } } } }; const where = { pond: { owner: { username: { contains: suffix } } } };
await prisma.roleGrant.deleteMany({ where }); await prisma.roleGrant.deleteMany({ where });
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 app.close(); await app.close();
}); });

View File

@ -1,5 +1,3 @@
import { createHash } from 'node:crypto';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Plugin, PluginInstanceMode as DbPluginMode, Prisma, User } from '@prisma/client'; import { Plugin, PluginInstanceMode as DbPluginMode, Prisma, User } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino'; import { PinoLogger } from 'nestjs-pino';
@ -14,7 +12,6 @@ import type {
import { ClockService } from '../common/clock.service'; import { ClockService } from '../common/clock.service';
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 { InstanceSettingsService } from '../settings/instance-settings.service';
import { PluginPackageService } from './plugin-package.service'; import { PluginPackageService } from './plugin-package.service';
import { PluginStorageService } from './plugin-storage.service'; import { PluginStorageService } from './plugin-storage.service';
@ -47,7 +44,6 @@ export class PluginsService {
private readonly storage: PluginStorageService, private readonly storage: PluginStorageService,
private readonly clock: ClockService, private readonly clock: ClockService,
private readonly audit: AuditService, private readonly audit: AuditService,
private readonly settings: InstanceSettingsService,
private readonly logger: PinoLogger, private readonly logger: PinoLogger,
) { ) {
this.logger.setContext(PluginsService.name); this.logger.setContext(PluginsService.name);
@ -62,32 +58,6 @@ export class PluginsService {
/** `actor` is absent for dropzone installs (watcher, no session). */ /** `actor` is absent for dropzone installs (watcher, no session). */
async install(zip: Buffer, actor?: User): Promise<PluginView> { async install(zip: Buffer, actor?: User): Promise<PluginView> {
const { manifest, files } = this.packages.parse(zip); const { manifest, files } = this.packages.parse(zip);
const bundleHash = createHash('sha256').update(zip).digest('hex');
// Hash pinning (#232, ADR 0025): while the allowlist is non-empty,
// only listed ids with the exact pinned bundle hash may install.
// Fail closed with a distinct code per cause; every rejection is
// audited so a tampering attempt leaves a trace.
const allowlist = await this.settings.get('plugins.allowlist');
if (allowlist.length > 0) {
const pin = allowlist.find((entry) => entry.id === manifest.id);
if (!pin || pin.sha256 !== bundleHash) {
const reason = pin ? 'hash_mismatch' : 'not_pinned';
await this.audit.record({
action: 'plugin.rejected',
actorId: actor?.id,
targetType: 'plugin',
targetId: manifest.id,
details: { surface: 'install', reason, version: manifest.version, bundleHash },
});
throw new PluginPackageError(
pin ? 'plugin_hash_mismatch' : 'plugin_not_pinned',
pin
? `Bundle hash ${bundleHash} does not match the pinned hash for ${manifest.id}`
: `Plugin ${manifest.id} is not on the allowlist`,
);
}
}
const existing = await this.prisma.plugin.findUnique({ where: { id: manifest.id } }); const existing = await this.prisma.plugin.findUnique({ where: { id: manifest.id } });
const isActiveUpdate = existing !== null && existing.removedAt === null; const isActiveUpdate = existing !== null && existing.removedAt === null;
@ -111,7 +81,6 @@ export class PluginsService {
apiVersion: manifest.apiVersion, apiVersion: manifest.apiVersion,
kind: manifest.kind, kind: manifest.kind,
manifest: manifest as unknown as Prisma.InputJsonValue, manifest: manifest as unknown as Prisma.InputJsonValue,
bundleHash,
}, },
update: { update: {
name: manifest.name, name: manifest.name,
@ -119,7 +88,6 @@ export class PluginsService {
apiVersion: manifest.apiVersion, apiVersion: manifest.apiVersion,
kind: manifest.kind, kind: manifest.kind,
manifest: manifest as unknown as Prisma.InputJsonValue, manifest: manifest as unknown as Prisma.InputJsonValue,
bundleHash,
// Reinstalling a previously removed plugin clears the tombstone. // Reinstalling a previously removed plugin clears the tombstone.
removedAt: null, removedAt: null,
}, },
@ -137,7 +105,7 @@ export class PluginsService {
targetId: manifest.id, targetId: manifest.id,
details: { version: manifest.version, update: isActiveUpdate }, details: { version: manifest.version, update: isActiveUpdate },
}); });
return this.toView(record, await this.settings.get('plugins.allowlist')); return this.toView(record);
} }
/** /**
@ -163,7 +131,7 @@ export class PluginsService {
targetId: id, targetId: id,
details: { mode }, details: { mode },
}); });
return this.toView(updated, await this.settings.get('plugins.allowlist')); return this.toView(updated);
} }
/** /**
@ -214,18 +182,9 @@ export class PluginsService {
this.prisma.pondPlugin.findMany({ where: { pondId } }), this.prisma.pondPlugin.findMany({ where: { pondId } }),
]); ]);
const enabled = new Map(activations.map((a) => [a.pluginId, a.enabled])); const enabled = new Map(activations.map((a) => [a.pluginId, a.enabled]));
const allowlist = await this.settings.get('plugins.allowlist'); return plugins
return ( .filter((p) => p.mode === 'REQUIRED' || (p.mode === 'OPTIONAL' && enabled.get(p.id) === true))
plugins .map((p) => this.toView(p));
.filter(
(p) => p.mode === 'REQUIRED' || (p.mode === 'OPTIONAL' && enabled.get(p.id) === true),
)
// Hash pinning (#232): a plugin outside the allowlist, or with a
// deviating bundle hash, does not load — it simply never appears in
// the pond's mount list. Existing blocks render their fallback.
.filter((p) => this.loadable(this.verdict(p, allowlist)))
.map((p) => this.toView(p, allowlist))
);
} }
/** /**
@ -260,11 +219,7 @@ export class PluginsService {
this.prisma.pondPlugin.findMany({ where: { pondId } }), this.prisma.pondPlugin.findMany({ where: { pondId } }),
]); ]);
const enabled = new Map(activations.map((a) => [a.pluginId, a.enabled])); const enabled = new Map(activations.map((a) => [a.pluginId, a.enabled]));
const allowlist = await this.settings.get('plugins.allowlist'); return plugins.map((p) => ({ plugin: this.toView(p), enabled: enabled.get(p.id) === true }));
return plugins.map((p) => ({
plugin: this.toView(p, allowlist),
enabled: enabled.get(p.id) === true,
}));
} }
/** /**
@ -332,66 +287,19 @@ export class PluginsService {
where: { removedAt: null }, where: { removedAt: null },
orderBy: { name: 'asc' }, orderBy: { name: 'asc' },
}); });
const allowlist = await this.settings.get('plugins.allowlist'); return plugins.map((plugin) => this.toView(plugin));
return plugins.map((plugin) => this.toView(plugin, allowlist));
} }
/** One installed plugin, or `null` if absent/removed. */ /** One installed plugin, or `null` if absent/removed. */
async get(id: string): Promise<PluginView | null> { async get(id: string): Promise<PluginView | null> {
const plugin = await this.prisma.plugin.findUnique({ where: { id } }); const plugin = await this.prisma.plugin.findUnique({ where: { id } });
if (!plugin || plugin.removedAt !== null) return null; if (!plugin || plugin.removedAt !== null) return null;
return this.toView(plugin, await this.settings.get('plugins.allowlist')); return this.toView(plugin);
} }
/** private toView(plugin: Plugin): PluginView {
* Pinning verdict against `plugins.allowlist` (#232). The observed hash
* is the one recorded at install: post-install tampering with unpacked
* files on disk is platform integrity (ADR 0019), not this check's
* scope the pin answers "is this the reviewed bundle".
*/
private verdict(
plugin: Plugin,
allowlist: { id: string; sha256: string }[],
): PluginView['pinning'] {
if (allowlist.length === 0) return 'not_enforced';
const pin = allowlist.find((entry) => entry.id === plugin.id);
if (!pin) return 'unpinned';
return plugin.bundleHash === pin.sha256 ? 'pinned' : 'mismatch';
}
private loadable(verdict: PluginView['pinning']): boolean {
return verdict === 'not_enforced' || verdict === 'pinned';
}
/**
* The load gate for code-serving surfaces (frame + assets, #232): an
* installed plugin outside the allowlist, or with a deviating bundle
* hash, does not load 404 like a missing plugin, and the rejection is
* audited (unlike the silent list filtering, an asset request proves
* something actively referenced the blocked plugin).
*/
async getServable(id: string): Promise<PluginView | null> {
const plugin = await this.prisma.plugin.findUnique({ where: { id } });
if (!plugin || plugin.removedAt !== null) return null;
const allowlist = await this.settings.get('plugins.allowlist');
const verdict = this.verdict(plugin, allowlist);
if (!this.loadable(verdict)) {
await this.audit.record({
action: 'plugin.rejected',
targetType: 'plugin',
targetId: id,
details: { surface: 'load', reason: verdict, version: plugin.version },
});
return null;
}
return this.toView(plugin, allowlist);
}
private toView(plugin: Plugin, allowlist: { id: string; sha256: string }[]): PluginView {
const manifest = plugin.manifest as unknown as PluginManifest; const manifest = plugin.manifest as unknown as PluginManifest;
return { return {
bundleSha256: plugin.bundleHash,
pinning: this.verdict(plugin, allowlist),
id: plugin.id, id: plugin.id,
name: plugin.name, name: plugin.name,
version: plugin.version, version: plugin.version,

View File

@ -5,7 +5,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { AuthTokensService } from '../auth/auth-tokens.service'; import { AuthTokensService } from '../auth/auth-tokens.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';
import { PondAccessNotifier } from './pond-access-notifier.service'; import { PondAccessNotifier } from './pond-access-notifier.service';
@ -81,7 +81,7 @@ describe.skipIf(!hasTestDb)('ponds (e2e, issue #21)', () => {
await prisma.quotaOverride.deleteMany({ await prisma.quotaOverride.deleteMany({
where: { subjectId: { in: users.map((u) => u.id) } }, where: { subjectId: { in: users.map((u) => u.id) } },
}); });
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.$disconnect(); await prisma.$disconnect();
await app.close(); await app.close();
@ -106,107 +106,6 @@ describe.skipIf(!hasTestDb)('ponds (e2e, issue #21)', () => {
expect(res.body.filter((p: { type: string }) => p.type === 'personal')).toHaveLength(1); expect(res.body.filter((p: { type: string }) => p.type === 'personal')).toHaveLength(1);
}); });
it('gives a new shared pond a start page and points settings at it (issue #302)', async () => {
const created = await api()
.post('/api/v1/ponds')
.set('Cookie', ownerCookie)
.send({ name: `Startseitenteich ${suffix}` })
.expect(201);
const pages = await api()
.get(`/api/v1/ponds/${created.body.id}/pages`)
.set('Cookie', ownerCookie)
.expect(200);
expect(pages.body).toHaveLength(1);
// The title follows the creator's stored locale — this owner is 'de'.
expect(pages.body[0].title).toBe('Startseite');
const pond = await api()
.get(`/api/v1/ponds/${created.body.slug}`)
.set('Cookie', ownerCookie)
.expect(200);
expect(pond.body.settings.startPageId).toBe(pages.body[0].id);
});
it("titles the start page in the creator's locale (issue #302)", async () => {
// The owner is 'de' and got "Startseite" above; an 'en' account must get
// the English title. Without both halves the test would pass on a
// hardcoded string just as happily.
const users = app.get(UsersService);
const tokens = app.get(AuthTokensService);
const username = `ellie-${suffix}`;
const user = await users.createUser({
username,
email: `${username}@example.org`,
displayName: `Ellie English ${suffix}`,
password,
locale: 'en',
});
const token = await tokens.issue(user.id, 'EMAIL_VERIFICATION', 600);
await api().post('/api/v1/auth/verify-email').send({ token }).expect(204);
const pond = await prisma.pond.findFirstOrThrow({
where: { ownerId: user.id, type: 'PERSONAL' },
});
const pages = await prisma.page.findMany({ where: { pondId: pond.id } });
expect(pages.map((page) => page.title)).toEqual(['Home']);
});
it('gives the personal pond a start page too (issue #302)', async () => {
const res = await api().get('/api/v1/ponds').set('Cookie', ownerCookie).expect(200);
const personal = res.body.find((p: { type: string }) => p.type === 'personal');
const pages = await api()
.get(`/api/v1/ponds/${personal.id}/pages`)
.set('Cookie', ownerCookie)
.expect(200);
expect(pages.body).toHaveLength(1);
expect(personal.settings.startPageId).toBe(pages.body[0].id);
});
it('changes the start page without losing other settings (issue #302)', async () => {
const created = await api()
.post('/api/v1/ponds')
.set('Cookie', ownerCookie)
.send({ name: `Wechselteich ${suffix}` })
.expect(201);
await api()
.patch(`/api/v1/ponds/${created.body.id}`)
.set('Cookie', ownerCookie)
.send({ commentPolicy: 'editors' })
.expect(200);
const second = await api()
.post(`/api/v1/ponds/${created.body.id}/pages`)
.set('Cookie', ownerCookie)
.send({ title: `Zweite ${suffix}` })
.expect(201);
const updated = await api()
.patch(`/api/v1/ponds/${created.body.id}`)
.set('Cookie', ownerCookie)
.send({ startPageId: second.body.id })
.expect(200);
expect(updated.body.settings.startPageId).toBe(second.body.id);
// The neighbouring key must survive the merge — settings hold only
// deviations, so an assigning write would silently reset it.
expect(updated.body.settings.commentPolicy).toBe('editors');
});
it('clears the start page back to the sort-order default (issue #302)', async () => {
const created = await api()
.post('/api/v1/ponds')
.set('Cookie', ownerCookie)
.send({ name: `Leerteich ${suffix}` })
.expect(201);
const cleared = await api()
.patch(`/api/v1/ponds/${created.body.id}`)
.set('Cookie', ownerCookie)
.send({ startPageId: null })
.expect(200);
expect(cleared.body.settings.startPageId).toBeNull();
});
it('creates shared ponds with deterministic slug suffixes', async () => { it('creates shared ponds with deterministic slug suffixes', async () => {
const name = `Gartenteich ${suffix}`; const name = `Gartenteich ${suffix}`;
const first = await api() const first = await api()

View File

@ -1,6 +1,5 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { PagesModule } from '../pages/pages.module';
import { QuotasModule } from '../quotas/quotas.module'; import { QuotasModule } from '../quotas/quotas.module';
import { SearchModule } from '../search/search.module'; import { SearchModule } from '../search/search.module';
@ -9,7 +8,7 @@ import { PondsController } from './ponds.controller';
import { PondsService } from './ponds.service'; import { PondsService } from './ponds.service';
@Module({ @Module({
imports: [PagesModule, QuotasModule, SearchModule], imports: [QuotasModule, SearchModule],
controllers: [PondsController], controllers: [PondsController],
providers: [PondsService, PondAccessNotifier], providers: [PondsService, PondAccessNotifier],
exports: [PondsService, PondAccessNotifier], exports: [PondsService, PondAccessNotifier],

View File

@ -9,8 +9,6 @@ import {
import { Pond, Prisma, User } from '@prisma/client'; import { Pond, Prisma, User } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino'; import { PinoLogger } from 'nestjs-pino';
import { apiI18n } from '../i18n/api-i18n';
import { PagesService } from '../pages/pages.service';
import { PermissionService } from '../permissions/permission.service'; import { PermissionService } from '../permissions/permission.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';
@ -25,52 +23,11 @@ export class PondsService {
private readonly quotas: QuotaService, private readonly quotas: QuotaService,
private readonly accessNotifier: PondAccessNotifier, private readonly accessNotifier: PondAccessNotifier,
private readonly search: SearchProvider, private readonly search: SearchProvider,
private readonly pages: PagesService,
private readonly logger: PinoLogger, private readonly logger: PinoLogger,
) { ) {
this.logger.setContext(PondsService.name); this.logger.setContext(PondsService.name);
} }
/**
* Every new pond opens on a start page instead of the empty-pond hint
* (issue #302). Deliberately AFTER the creating transaction commits: the
* owner's POND_ADMIN grant is written inside it and the permission layer
* caches per pond, so creating the page in the same transaction would ask
* about rights the grant has not yet published.
*
* Goes through PagesService so the page carries every invariant a page
* needs unique slug, appended sort key, derived content cache, search
* indexing, and an `emptyPageState()` the collab server can bind to. A
* hand-rolled insert here would produce a page the editor cannot open.
*
* Failure is logged, not fatal: a pond without a start page simply falls
* back to the historical behaviour, which is a working state. Losing the
* whole pond over its first page would not be.
*/
private async createStartPage(owner: User, pondId: string): Promise<void> {
try {
const title = apiI18n.t('ponds:startPage.title', {
lng: owner.locale === 'de' ? 'de' : 'en',
});
const page = await this.pages.create(owner, pondId, { title });
const pond = await this.prisma.pond.findUniqueOrThrow({
where: { id: pondId },
select: { settings: true },
});
// Merge rather than assign: stored settings hold only deviations from
// the defaults, and overwriting the object would drop them.
await this.prisma.pond.update({
where: { id: pondId },
data: { settings: { ...(pond.settings as object), startPageId: page.id } },
});
} catch (error) {
this.logger.error(
{ pondId, err: error instanceof Error ? error.message : String(error) },
'start page for new pond could not be created',
);
}
}
viewOf(pond: Pond): PondView { viewOf(pond: Pond): PondView {
return { return {
id: pond.id, id: pond.id,
@ -148,12 +105,7 @@ export class PondsService {
return created; return created;
}); });
this.logger.info({ pondId: pond.id, ownerId: owner.id }, 'audit: pond created'); this.logger.info({ pondId: pond.id, ownerId: owner.id }, 'audit: pond created');
await this.createStartPage(owner, pond.id); return this.viewOf(pond);
// Re-read: the row captured in the transaction predates the start page,
// so returning it would hand the caller `startPageId: null` for a pond
// that has one.
const withStartPage = await this.prisma.pond.findUniqueOrThrow({ where: { id: pond.id } });
return this.viewOf(withStartPage);
} }
/** /**
@ -176,7 +128,6 @@ export class PondsService {
return created; return created;
}); });
this.logger.info({ pondId: pond.id, ownerId: user.id }, 'audit: personal pond created'); this.logger.info({ pondId: pond.id, ownerId: user.id }, 'audit: personal pond created');
await this.createStartPage(user, pond.id);
} }
async listVisible(user: User): Promise<PondView[]> { async listVisible(user: User): Promise<PondView[]> {
@ -206,8 +157,7 @@ export class PondsService {
input.commentPolicy !== undefined || input.commentPolicy !== undefined ||
input.apiEnabled !== undefined || input.apiEnabled !== undefined ||
input.mcpEnabled !== undefined || input.mcpEnabled !== undefined ||
input.theme !== undefined || input.theme !== undefined;
input.startPageId !== undefined;
const settings = !settingsChanged const settings = !settingsChanged
? undefined ? undefined
: { : {
@ -219,7 +169,6 @@ export class PondsService {
...(input.apiEnabled !== undefined ? { apiEnabled: input.apiEnabled } : {}), ...(input.apiEnabled !== undefined ? { apiEnabled: input.apiEnabled } : {}),
...(input.mcpEnabled !== undefined ? { mcpEnabled: input.mcpEnabled } : {}), ...(input.mcpEnabled !== undefined ? { mcpEnabled: input.mcpEnabled } : {}),
...(input.theme !== undefined ? { theme: input.theme } : {}), ...(input.theme !== undefined ? { theme: input.theme } : {}),
...(input.startPageId !== undefined ? { startPageId: input.startPageId } : {}),
}; };
const updated = await this.prisma.pond.update({ const updated = await this.prisma.pond.update({
where: { id }, where: { id },

View File

@ -119,7 +119,7 @@ export class PublicApiController {
@Param('pageSlug') pageSlug: string, @Param('pageSlug') pageSlug: string,
@Req() request: PublicApiRequest, @Req() request: PublicApiRequest,
): Promise<PublicPageView> { ): Promise<PublicPageView> {
return this.publicApi.getPage(request.user!, request.apiToken!, pondSlug, pageSlug); return this.publicApi.getPage(request.user!, pondSlug, pageSlug);
} }
@Patch('ponds/:pondSlug/pages/:pageSlug') @Patch('ponds/:pondSlug/pages/:pageSlug')
@ -169,10 +169,7 @@ export class PublicApiController {
@Res() res: Response, @Res() res: Response,
): Promise<void> { ): Promise<void> {
const pond = await this.publicApi.requirePond(pondSlug); const pond = await this.publicApi.requirePond(pondSlug);
await this.exports.streamPondMarkdownZip(request.user!, pond.id, res, { await this.exports.streamPondMarkdownZip(request.user!, pond.id, res);
actorId: request.user!.id,
sessionKey: `token:${request.apiToken!.id}`,
});
} }
@Get('ponds/:pondSlug/labels') @Get('ponds/:pondSlug/labels')

View File

@ -38,7 +38,6 @@ import { PagesService } from '../pages/pages.service';
import { PermissionService } from '../permissions/permission.service'; import { PermissionService } from '../permissions/permission.service';
import { PondsService } from '../ponds/ponds.service'; import { PondsService } from '../ponds/ponds.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { ReadTrailService } from '../read-trail/read-trail.service';
import { SearchProvider } from '../search/search.provider'; import { SearchProvider } from '../search/search.provider';
import { VersionsService } from '../versions/versions.service'; import { VersionsService } from '../versions/versions.service';
import { ApiTokensService } from './api-tokens.service'; import { ApiTokensService } from './api-tokens.service';
@ -64,7 +63,6 @@ export class PublicApiService {
private readonly tokens: ApiTokensService, private readonly tokens: ApiTokensService,
private readonly audit: AuditService, private readonly audit: AuditService,
private readonly permissions: PermissionService, private readonly permissions: PermissionService,
private readonly readTrail: ReadTrailService,
) {} ) {}
async me(user: User, token: ApiToken): Promise<PublicMeView> { async me(user: User, token: ApiToken): Promise<PublicMeView> {
@ -121,24 +119,8 @@ export class PublicApiService {
})); }));
} }
async getPage( async getPage(user: User, pondSlug: string, pageSlug: string): Promise<PublicPageView> {
user: User,
token: ApiToken,
pondSlug: string,
pageSlug: string,
): Promise<PublicPageView> {
const page = await this.requirePage(pondSlug, pageSlug); const page = await this.requirePage(pondSlug, pageSlug);
// Read trail (issue #222): the PAT is the session in this channel's
// terms — `token:<id>` keys the dedup window (#223).
if (page.classification === 'VS_NFD') {
await this.readTrail.record({
actorId: user.id,
sessionKey: `token:${token.id}`,
pageId: page.id,
pondId: page.pondId,
channel: 'public_api',
});
}
const [cache, pageLabels, labelNames, parent] = await Promise.all([ const [cache, pageLabels, labelNames, parent] = await Promise.all([
this.prisma.pageContentCache.findUnique({ where: { pageId: page.id } }), this.prisma.pageContentCache.findUnique({ where: { pageId: page.id } }),
this.prisma.pageLabel.findMany({ where: { pageId: page.id }, select: { labelId: true } }), this.prisma.pageLabel.findMany({ where: { pageId: page.id }, select: { labelId: true } }),
@ -172,8 +154,7 @@ export class PublicApiService {
const state = this.stateFromMarkdown(input.markdown); const state = this.stateFromMarkdown(input.markdown);
const page = await this.pages.createWithState(user, pond.id, input.title, state, parentId); const page = await this.pages.createWithState(user, pond.id, input.title, state, parentId);
await this.auditWrite(user, token, 'page_created', page.id); await this.auditWrite(user, token, 'page_created', page.id);
// The write echo returns the full page — getPage records the read (#222). return this.getPage(user, pondSlug, page.slug);
return this.getPage(user, token, pondSlug, page.slug);
} }
/** /**
@ -205,7 +186,7 @@ export class PublicApiService {
await this.pages.moveToEnd(user, page.id, parentId); await this.pages.moveToEnd(user, page.id, parentId);
} }
await this.auditWrite(user, token, 'page_updated', page.id); await this.auditWrite(user, token, 'page_updated', page.id);
return this.getPage(user, token, pondSlug, page.slug); return this.getPage(user, pondSlug, page.slug);
} }
async deletePage(user: User, token: ApiToken, pondSlug: string, pageSlug: string): Promise<void> { async deletePage(user: User, token: ApiToken, pondSlug: string, pageSlug: string): Promise<void> {

View File

@ -3,7 +3,6 @@ import type { PageCommentsView } from '@dorfteich/shared';
import type { Response } from 'express'; import type { Response } from 'express';
import { AuthedRequest, Public } from '../auth/auth.guard'; import { AuthedRequest, Public } from '../auth/auth.guard';
import { readActorOf } from '../read-trail/read-actor';
import { InstanceSettingsService } from '../settings/instance-settings.service'; import { InstanceSettingsService } from '../settings/instance-settings.service';
import { FeedService } from './feed.service'; import { FeedService } from './feed.service';
import { PublicPageContent, PublicService } from './public.service'; import { PublicPageContent, PublicService } from './public.service';
@ -67,10 +66,7 @@ export class PublicController {
@Param('pageSlug') pageSlug: string, @Param('pageSlug') pageSlug: string,
@Req() request: AuthedRequest, @Req() request: AuthedRequest,
): Promise<PublicPageContent> { ): Promise<PublicPageContent> {
return this.publicPages.content(request.user ?? null, pondSlug, pageSlug, { return this.publicPages.content(request.user ?? null, pondSlug, pageSlug);
actor: readActorOf(request),
channel: 'page_view',
});
} }
@Get(':pondSlug/:pageSlug/comments') @Get(':pondSlug/:pageSlug/comments')
@ -92,13 +88,7 @@ export class PublicController {
@Res({ passthrough: true }) response: Response, @Res({ passthrough: true }) response: Response,
): Promise<string> { ): Promise<string> {
const canonical = `${request.protocol}://${request.get('host') ?? ''}${request.originalUrl}`; const canonical = `${request.protocol}://${request.get('host') ?? ''}${request.originalUrl}`;
const html = await this.publicPages.html( const html = await this.publicPages.html(request.user ?? null, pondSlug, pageSlug, canonical);
request.user ?? null,
pondSlug,
pageSlug,
canonical,
readActorOf(request),
);
response.set('Content-Type', 'text/html; charset=utf-8'); response.set('Content-Type', 'text/html; charset=utf-8');
return html; return html;
} }

View File

@ -8,18 +8,9 @@ import { TasksService } from '../pages/tasks.service';
import { PermissionService } from '../permissions/permission.service'; import { PermissionService } from '../permissions/permission.service';
import { PluginFallbackRenderer } from '../plugins/plugin-fallback-renderer'; import { PluginFallbackRenderer } from '../plugins/plugin-fallback-renderer';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.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 { escapeHtml, htmlDocument } from './html-shell'; import { escapeHtml, htmlDocument } from './html-shell';
/** Who is reading and through which surface (issue #222, ADR 0023) the
* controllers resolve this once; `content` and the embed expansion record
* classified pages under it. */
export interface ReadContext {
actor: ReadActor;
channel: 'page_view' | 'no_js_shell';
}
/** The JSON the SPA renders for an anonymous (or any) reader of a public page. */ /** The JSON the SPA renders for an anonymous (or any) reader of a public page. */
export interface PublicPageContent { export interface PublicPageContent {
pondName: string; pondName: string;
@ -62,7 +53,6 @@ export class PublicService {
private readonly settings: InstanceSettingsService, private readonly settings: InstanceSettingsService,
private readonly commentsService: CommentsService, private readonly commentsService: CommentsService,
private readonly tasks: TasksService, private readonly tasks: TasksService,
private readonly readTrail: ReadTrailService,
) {} ) {}
private async resolve( private async resolve(
@ -84,24 +74,8 @@ export class PublicService {
} }
/** The page content for the read view (public and authenticated, issue #56). */ /** The page content for the read view (public and authenticated, issue #56). */
async content( async content(user: User | null, pondSlug: string, pageSlug: string): Promise<PublicPageContent> {
user: User | null,
pondSlug: string,
pageSlug: string,
read: ReadContext,
): Promise<PublicPageContent> {
const { pond, page } = await this.resolve(user, pondSlug, pageSlug); const { pond, page } = await this.resolve(user, pondSlug, pageSlug);
// Read trail (issue #222): a classified page leaving through this surface
// is recorded before any content is assembled — a failed write aborts
// the read (ADR 0023, deliberate contrast to AuditService).
if (page.classification === 'VS_NFD') {
await this.readTrail.record({
...read.actor,
pageId: page.id,
pondId: pond.id,
channel: read.channel,
});
}
const cache = await this.prisma.pageContentCache.findUnique({ where: { pageId: page.id } }); const cache = await this.prisma.pageContentCache.findUnique({ where: { pageId: page.id } });
// The pond's active section-style CSS travels inline — the read view loads // The pond's active section-style CSS travels inline — the read view loads
// no plugin runtime, and the CSS passed the install gate's scoping rules. // no plugin runtime, and the CSS passed the install gate's scoping rules.
@ -109,7 +83,7 @@ export class PublicService {
// Plugin blocks render their static form (#79) and page embeds expand to the // Plugin blocks render their static form (#79) and page embeds expand to the
// target's rendered HTML (#135), then media is resolved once over the whole // target's rendered HTML (#135), then media is resolved once over the whole
// tree. `visited` seeds with this page so an embed of self is not expanded. // tree. `visited` seeds with this page so an embed of self is not expanded.
const body = await this.renderBody(user, pond.id, page, 0, new Set([page.slug]), read); const body = await this.renderBody(user, pond.id, page, 0, new Set([page.slug]));
return { return {
pondName: pond.name, pondName: pond.name,
pondSlug: pond.slug, pondSlug: pond.slug,
@ -135,12 +109,11 @@ export class PublicService {
page: { id: string }, page: { id: string },
depth: number, depth: number,
visited: Set<string>, visited: Set<string>,
read: ReadContext,
): Promise<string> { ): Promise<string> {
const cache = await this.prisma.pageContentCache.findUnique({ where: { pageId: page.id } }); const cache = await this.prisma.pageContentCache.findUnique({ where: { pageId: page.id } });
const withFallbacks = await this.fallbacks.applyToHtml(cache?.html ?? ''); const withFallbacks = await this.fallbacks.applyToHtml(cache?.html ?? '');
const withTasks = await this.expandTaskOverviews(withFallbacks, user, pondId, page.id); const withTasks = await this.expandTaskOverviews(withFallbacks, user, pondId, page.id);
return this.expandEmbeds(withTasks, user, pondId, depth, visited, read); return this.expandEmbeds(withTasks, user, pondId, depth, visited);
} }
/** Replaces each task-overview placeholder (issue #154) with the static, /** Replaces each task-overview placeholder (issue #154) with the static,
@ -170,7 +143,6 @@ export class PublicService {
pondId: string, pondId: string,
depth: number, depth: number,
visited: Set<string>, visited: Set<string>,
read: ReadContext,
): Promise<string> { ): Promise<string> {
const placeholder = const placeholder =
/<div class="dt-transclusion" data-transclusion="([^"]+)"( data-transclusion-bare="1")?>[^<]*<\/div>/g; /<div class="dt-transclusion" data-transclusion="([^"]+)"( data-transclusion-bare="1")?>[^<]*<\/div>/g;
@ -179,31 +151,18 @@ export class PublicService {
const bare = Boolean(bareAttr); const bare = Boolean(bareAttr);
const target = await this.prisma.page.findFirst({ const target = await this.prisma.page.findFirst({
where: { pondId, slug, deletedAt: null }, where: { pondId, slug, deletedAt: null },
select: { id: true, pondId: true, slug: true, title: true, classification: true }, select: { id: true, pondId: true, slug: true, title: true },
}); });
const readable = target && (await this.permissions.canAccessPage(user, target, 'read')); const readable = target && (await this.permissions.canAccessPage(user, target, 'read'));
if (!target || !readable || depth >= PublicService.MAX_EMBED_DEPTH || visited.has(slug)) { if (!target || !readable || depth >= PublicService.MAX_EMBED_DEPTH || visited.has(slug)) {
return embedLink(slug, target?.title ?? slug); return embedLink(slug, target?.title ?? slug);
} }
// An expanded embed shows the target's FULL content, so a classified
// target is a read of that page too (issue #222) — recorded under the
// host's channel. The degraded link above shows no content: no event.
if (target.classification === 'VS_NFD') {
await this.readTrail.record({
...read.actor,
pageId: target.id,
pondId,
channel: read.channel,
details: { embedded: true },
});
}
const inner = await this.renderBody( const inner = await this.renderBody(
user, user,
pondId, pondId,
target, target,
depth + 1, depth + 1,
new Set(visited).add(slug), new Set(visited).add(slug),
read,
); );
// A bare embed (`$[[…]]`, #146) reads as part of the host page: no // A bare embed (`$[[…]]`, #146) reads as part of the host page: no
// frame, no title — just the expanded content. // frame, no title — just the expanded content.
@ -234,13 +193,8 @@ export class PublicService {
pondSlug: string, pondSlug: string,
pageSlug: string, pageSlug: string,
canonical: string, canonical: string,
actor: ReadActor,
): Promise<string> { ): Promise<string> {
// `content` records the read-trail event (#222) under the shell's channel. const content = await this.content(user, pondSlug, pageSlug);
const content = await this.content(user, pondSlug, pageSlug, {
actor,
channel: 'no_js_shell',
});
// The VS-NfD marking renders in the same places as the SPA — above and // The VS-NfD marking renders in the same places as the SPA — above and
// below the content (issue #211, ADR 0022). The no-JS shell is its own // below the content (issue #211, ADR 0022). The no-JS shell is its own
// render path, so it carries its own banner markup; unclassified pages // render path, so it carries its own banner markup; unclassified pages

View File

@ -4,7 +4,6 @@ import type { TaskOverviewPage } from '@dorfteich/shared';
import { AuthedRequest } from '../auth/auth.guard'; import { AuthedRequest } from '../auth/auth.guard';
import { TasksService } from '../pages/tasks.service'; import { TasksService } from '../pages/tasks.service';
import { AuthenticatedOnly } from '../permissions/permission.decorators'; import { AuthenticatedOnly } from '../permissions/permission.decorators';
import { readActorOf } from '../read-trail/read-actor';
import { PublicPageContent, PublicService } from './public.service'; import { PublicPageContent, PublicService } from './public.service';
/** /**
@ -44,9 +43,6 @@ export class ReadContentController {
@Param('pageSlug') pageSlug: string, @Param('pageSlug') pageSlug: string,
@Req() request: AuthedRequest, @Req() request: AuthedRequest,
): Promise<PublicPageContent> { ): Promise<PublicPageContent> {
return this.publicPages.content(request.user ?? null, pondSlug, pageSlug, { return this.publicPages.content(request.user ?? null, pondSlug, pageSlug);
actor: readActorOf(request),
channel: 'page_view',
});
} }
} }

View File

@ -1,22 +0,0 @@
import type { ReadActor } from './read-trail.service';
/**
* Resolves the {@link ReadActor} of a cookie-session request (issue #222).
* Structural parameter instead of `AuthedRequest` so the read-trail module
* never imports the auth guard. PAT requests build their key directly
* (`token:<id>`, public-api controller).
*/
export function readActorOf(request: {
user?: { id: string } | null;
sessionId?: string;
}): ReadActor {
// Session-less authenticated requests (trusted-proxy identity, #215) key
// per user — the proxy re-authenticates every request, so the user is
// the closest thing to a session the channel has.
const sessionKey = request.sessionId
? `session:${request.sessionId}`
: request.user
? `user:${request.user.id}`
: 'anon';
return { actorId: request.user?.id ?? null, sessionKey };
}

View File

@ -1,181 +0,0 @@
import { INestApplication } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import request from 'supertest';
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { ClockService } from '../common/clock.service';
import { InstanceSettingsService } from '../settings/instance-settings.service';
import { createTestApp, sessionCookieOf } from '../testing/test-app';
import { createTestPrisma, grantOwnerAdmin, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service';
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
/**
* The read-trail dedup window (issue #223, ADR 0023): one event per
* (session, page, channel) within an aligned `readTrail.dedupWindowMinutes`
* window repeats and reconnect-style re-requests collapse, a new session
* or a new window does not, and the recorded row states the window length
* it represents.
*/
describe.skipIf(!hasTestDb)('read-trail dedup window (e2e, issue #223)', () => {
let app: INestApplication;
let prisma: PrismaClient;
const suffix = uniqueSuffix();
const password = 'dedup fenster zeugen 123';
let ownerId: string;
let ownerCookie: string;
let pondId: string;
let classifiedId: string;
const api = () => request(app.getHttpServer());
const login = async () =>
sessionCookieOf(
await api()
.post('/api/v1/auth/login')
.send({ usernameOrEmail: `dedup-owner-${suffix}`, password })
.expect(200),
);
const events = () =>
prisma.readEvent.findMany({ where: { pondId }, orderBy: { occurredAt: 'asc' } });
beforeAll(async () => {
prisma = createTestPrisma();
await prisma.rateLimit.deleteMany({});
app = await createTestApp();
const users = app.get(UsersService);
const owner = await users.createUser({
username: `dedup-owner-${suffix}`,
email: `dedup-owner-${suffix}@example.test`,
displayName: 'Dedup Owner',
password,
locale: 'en',
});
await users.markEmailVerified(owner.id);
ownerId = owner.id;
// The trail ships OFF by default (#225) — this suite needs it on.
await app.get(InstanceSettingsService).set('readTrail.enabled', true, ownerId);
ownerCookie = await login();
const pond = await prisma.pond.create({
data: { slug: `dedup-pond-${suffix}`, name: 'Dedup Pond', type: 'SHARED', ownerId },
});
pondId = pond.id;
await grantOwnerAdmin(prisma, pondId, ownerId);
const page = await prisma.page.create({
data: {
pondId,
slug: `classified-${suffix}`,
title: 'Classified',
classification: 'VS_NFD',
createdBy: ownerId,
sortKey: 'a0',
ydocState: new Uint8Array(),
contentCache: {
create: { plainText: 'x', markdown: 'x', html: '<p>x</p>', outline: [] },
},
},
});
classifiedId = page.id;
});
afterEach(async () => {
await prisma.readEvent.deleteMany({ where: { pondId } });
await prisma.rateLimit.deleteMany({});
});
afterAll(async () => {
await prisma.instanceSetting.deleteMany({ where: { key: 'readTrail.enabled' } });
await prisma.readEvent.deleteMany({ where: { pondId } });
await prisma.attachment.deleteMany({ where: { pondId } });
await prisma.roleGrant.deleteMany({ where: { pondId } });
await prisma.pageContentCache.deleteMany({ where: { page: { pondId } } });
await prisma.page.deleteMany({ where: { pondId } });
await prisma.pond.deleteMany({ where: { id: pondId } });
await prisma.user.deleteMany({ where: { id: ownerId } });
await prisma.$disconnect();
await app.close();
});
it('collapses repeated reads of one page in one session+channel to a single event that names its window', async () => {
await api().get(`/api/v1/pages/${classifiedId}`).set('Cookie', ownerCookie).expect(200);
await api().get(`/api/v1/pages/${classifiedId}`).set('Cookie', ownerCookie).expect(200);
await api().get(`/api/v1/pages/${classifiedId}`).set('Cookie', ownerCookie).expect(200);
const rows = await events();
expect(rows).toHaveLength(1);
// The row itself states that it represents a window, not a request.
expect(rows[0]!.windowSeconds).toBe(5 * 60);
expect(rows[0]!.dedupKey).toBe(`${rows[0]!.sessionKey}:${classifiedId}:page_view`);
});
it('keeps channels apart: the same session reading and joining collab yields one event each', async () => {
await api().get(`/api/v1/pages/${classifiedId}`).set('Cookie', ownerCookie).expect(200);
await api()
.get(`/api/v1/pages/${classifiedId}/collab-token`)
.set('Cookie', ownerCookie)
.expect(200);
const rows = await events();
expect(rows.map((r) => r.channel).sort()).toEqual(['collab_join', 'page_view']);
});
it('a new session records again even for the same user — a reconnect within the session does not', async () => {
await api().get(`/api/v1/pages/${classifiedId}`).set('Cookie', ownerCookie).expect(200);
// Same session, later request ("reconnect"): deduped.
await api().get(`/api/v1/pages/${classifiedId}`).set('Cookie', ownerCookie).expect(200);
expect(await events()).toHaveLength(1);
const secondCookie = await login();
await api().get(`/api/v1/pages/${classifiedId}`).set('Cookie', secondCookie).expect(200);
const rows = await events();
expect(rows).toHaveLength(2);
expect(new Set(rows.map((r) => r.sessionKey)).size).toBe(2);
expect(rows.every((r) => r.actorId === ownerId)).toBe(true);
});
it('bounds a live editing session: 30 collab-token renewals in one window are one event', async () => {
for (let i = 0; i < 30; i += 1) {
await api()
.get(`/api/v1/pages/${classifiedId}/collab-token`)
.set('Cookie', ownerCookie)
.expect(200);
}
const rows = await events();
expect(rows).toHaveLength(1);
expect(rows[0]!.channel).toBe('collab_join');
});
it('opens a new window when the clock moves past the bucket boundary', async () => {
await api().get(`/api/v1/pages/${classifiedId}`).set('Cookie', ownerCookie).expect(200);
const clock = app.get(ClockService);
const later = new Date(Date.now() + 10 * 60 * 1000);
const spy = vi.spyOn(clock, 'now').mockReturnValue(later);
try {
await api().get(`/api/v1/pages/${classifiedId}`).set('Cookie', ownerCookie).expect(200);
} finally {
spy.mockRestore();
}
expect(await events()).toHaveLength(2);
});
it('dedups page-less attachment downloads on the placeholder page key', async () => {
// A pond-level upload has no page; its effective classification falls
// back to the pond maximum (#212) and the dedup key carries `-`.
const uploaded = await api()
.post(`/api/v1/ponds/${pondId}/files`)
.set('Cookie', ownerCookie)
.attach('file', Buffer.concat([PNG_SIGNATURE, Buffer.from('img')]), 'a.png')
.expect(201);
await prisma.readEvent.deleteMany({ where: { pondId } });
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 rows = await events();
expect(rows).toHaveLength(1);
expect(rows[0]!.channel).toBe('attachment');
expect(rows[0]!.pageId).toBeNull();
expect(rows[0]!.dedupKey).toBe(`${rows[0]!.sessionKey}:-:attachment`);
});
});

View File

@ -1,139 +0,0 @@
import { Injectable } from '@nestjs/common';
import { PinoLogger } from 'nestjs-pino';
import { AuditService } from '../audit/audit.service';
import { ClockService } from '../common/clock.service';
import { PrismaService } from '../prisma/prisma.service';
import { InstanceSettingsService } from '../settings/instance-settings.service';
const MS_PER_DAY = 24 * 60 * 60 * 1000;
/** How many months ahead of "now" a partition must exist. Two keeps a
* multi-week job outage from ever reaching an uncovered month (the DEFAULT
* partition would still catch it reads never fail on a missing month). */
const MONTHS_AHEAD = 2;
/**
* Read-trail storage maintenance (issue #224, ADR 0023), one daily job with
* two duties:
*
* 1. **Partition upkeep** `read_events` is RANGE-partitioned by month
* (migration `20260731170000`); this creates the next {@link MONTHS_AHEAD}
* monthly partitions, each with its per-partition dedup unique index
* (#223 the partitioned parent cannot carry it). A `db push` database
* (tests) has a plain table; partition work skips itself there.
* 2. **Retention** events older than `readTrail.retentionDays` are
* removed: whole months by dropping their partition (no scan), the
* remainder (default partition, plain tables) by a ranged delete. The
* deletion is audited (`read_trail.pruned`) so a gap in the evidence is
* always explainable same principle as `audit.pruned` (#196), but a
* deliberately separate period.
*/
@Injectable()
export class ReadTrailMaintenanceService {
constructor(
private readonly prisma: PrismaService,
private readonly settings: InstanceSettingsService,
private readonly audit: AuditService,
private readonly clock: ClockService,
private readonly logger: PinoLogger,
) {
this.logger.setContext(ReadTrailMaintenanceService.name);
}
async run(): Promise<void> {
await this.ensurePartitions();
await this.pruneExpired();
}
/** True when read_events is a partitioned parent (relkind `p`). */
private async isPartitioned(): Promise<boolean> {
const rows = await this.prisma.$queryRaw<{ relkind: string }[]>`
SELECT relkind::text FROM pg_class
WHERE relname = 'read_events' AND relnamespace = 'public'::regnamespace`;
return rows[0]?.relkind === 'p';
}
/** `read_events_y2026m08` for 2026-08. */
private partitionName(month: Date): string {
const y = month.getUTCFullYear();
const m = String(month.getUTCMonth() + 1).padStart(2, '0');
return `read_events_y${y}m${m}`;
}
private monthStart(base: Date, offsetMonths: number): Date {
return new Date(Date.UTC(base.getUTCFullYear(), base.getUTCMonth() + offsetMonths, 1));
}
async ensurePartitions(): Promise<void> {
if (!(await this.isPartitioned())) {
this.logger.debug('read_events is not partitioned here; skipping partition upkeep');
return;
}
const now = this.clock.now();
for (let offset = 0; offset <= MONTHS_AHEAD; offset += 1) {
const from = this.monthStart(now, offset);
const to = this.monthStart(now, offset + 1);
const name = this.partitionName(from);
try {
await this.prisma.$executeRawUnsafe(
`CREATE TABLE IF NOT EXISTS "${name}" PARTITION OF "read_events"
FOR VALUES FROM ('${from.toISOString()}') TO ('${to.toISOString()}')`,
);
await this.prisma.$executeRawUnsafe(
`CREATE UNIQUE INDEX IF NOT EXISTS "${name}_dedup_key"
ON "${name}" ("dedup_key", "window_bucket")`,
);
} catch (error) {
// Most likely: the DEFAULT partition already holds rows of this month
// (the job lagged past a month boundary). Nothing is lost — those
// rows live in the default partition and age out through the ranged
// delete below; the month just cannot get its own partition anymore.
this.logger.warn({ partition: name, err: error }, 'read-trail partition not created');
}
}
}
async pruneExpired(): Promise<number> {
const retentionDays = await this.settings.get('readTrail.retentionDays');
const cutoff = new Date(this.clock.now().getTime() - retentionDays * MS_PER_DAY);
let dropped = 0;
if (await this.isPartitioned()) {
// Whole months strictly before the cutoff month go by DROP — no scan,
// and the dropped range is exact (every row in them is < cutoff).
const partitions = await this.prisma.$queryRaw<{ relname: string }[]>`
SELECT c.relname::text
FROM pg_inherits i
JOIN pg_class c ON c.oid = i.inhrelid
WHERE i.inhparent = 'read_events'::regclass
AND c.relname ~ '^read_events_y[0-9]{4}m[0-9]{2}$'`;
const cutoffMonth = this.monthStart(cutoff, 0);
for (const { relname } of partitions) {
const match = /^read_events_y(\d{4})m(\d{2})$/.exec(relname);
if (!match) continue;
const monthEnd = new Date(Date.UTC(Number(match[1]), Number(match[2]), 1));
if (monthEnd.getTime() > cutoffMonth.getTime()) continue;
const counted = await this.prisma.$queryRawUnsafe<{ count: bigint }[]>(
`SELECT count(*)::bigint AS count FROM "${relname}"`,
);
dropped += Number(counted[0]?.count ?? 0n);
await this.prisma.$executeRawUnsafe(`DROP TABLE "${relname}"`);
}
}
// The remainder: rows before the cutoff inside surviving partitions,
// the default partition, or a plain (test) table.
const deleted = await this.prisma.readEvent.deleteMany({
where: { occurredAt: { lt: cutoff } },
});
const count = dropped + deleted.count;
if (count > 0) {
await this.audit.record({
action: 'read_trail.pruned',
details: { count, cutoff: cutoff.toISOString(), retentionDays },
});
}
return count;
}
}

View File

@ -1,308 +0,0 @@
import { execFileSync } from 'node:child_process';
import { join } from 'node:path';
import { INestApplication } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { ClockService } from '../common/clock.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';
import { ReadTrailMaintenanceService } from './read-trail-maintenance.service';
const API_ROOT = join(__dirname, '..', '..');
const MS_PER_DAY = 24 * 60 * 60 * 1000;
/**
* Read-trail storage (issue #224, ADR 0023): its own retention period with
* an audited deletion, the Site-Admin query path, and against a freshly
* migrated database, where the real DDL applies the monthly RANGE
* partitioning with per-partition dedup indexes and DROP-based pruning.
*/
describe.skipIf(!hasTestDb)('read-trail storage (e2e, issue #224)', () => {
const suffix = uniqueSuffix();
describe('retention and admin query path (shared database)', () => {
let app: INestApplication;
let prisma: PrismaClient;
const password = 'lesetrail speicher 123';
let adminId: string;
let adminCookie: string;
let readerCookie: string;
// Plain text ids — read_events has no FKs by design (#222).
const pondId = `pond-${suffix}`;
const api = () => request(app.getHttpServer());
async function makeUser(handle: string, siteAdmin: boolean) {
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);
if (siteAdmin) {
await prisma.user.update({ where: { id: user.id }, data: { isSiteAdmin: true } });
}
const cookie = sessionCookieOf(
await api()
.post('/api/v1/auth/login')
.send({ usernameOrEmail: `${handle}-${suffix}`, password })
.expect(200),
);
return { id: user.id, cookie };
}
function eventRow(overrides: Record<string, unknown>) {
return {
actorId: null,
sessionKey: 'anon',
pageId: null,
pondId,
channel: 'page_view',
classification: 'vs_nfd',
dedupKey: `k-${suffix}-${Math.random().toString(36).slice(2)}`,
windowBucket: BigInt(Math.floor(Math.random() * 1_000_000_000)),
windowSeconds: 300,
...overrides,
};
}
beforeAll(async () => {
prisma = createTestPrisma();
await prisma.rateLimit.deleteMany({});
app = await createTestApp();
const admin = await makeUser('storage-admin', true);
adminId = admin.id;
adminCookie = admin.cookie;
const reader = await makeUser('storage-reader', false);
readerCookie = reader.cookie;
});
afterAll(async () => {
await prisma.readEvent.deleteMany({ where: { pondId } });
await prisma.auditEntry.deleteMany({ where: { action: 'read_trail.pruned' } });
await prisma.instanceSetting.deleteMany({ where: { key: 'readTrail.retentionDays' } });
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
await prisma.$disconnect();
await app.close();
});
it('prunes only events past its own period and audits the deletion', async () => {
await app.get(InstanceSettingsService).set('readTrail.retentionDays', 30, adminId);
const now = Date.now();
await prisma.readEvent.createMany({
data: [
eventRow({ occurredAt: new Date(now - 40 * MS_PER_DAY) }),
eventRow({ occurredAt: new Date(now - 31 * MS_PER_DAY) }),
eventRow({ occurredAt: new Date(now - 5 * MS_PER_DAY) }),
],
});
const maintenance = app.get(ReadTrailMaintenanceService);
const pruned = await maintenance.pruneExpired();
expect(pruned).toBeGreaterThanOrEqual(2);
const remaining = await prisma.readEvent.findMany({ where: { pondId } });
expect(remaining).toHaveLength(1);
const audit = await prisma.auditEntry.findFirst({
where: { action: 'read_trail.pruned' },
orderBy: { at: 'desc' },
});
expect(audit).not.toBeNull();
expect(audit!.details).toMatchObject({ retentionDays: 30 });
});
it('answers "who read page X" and "what did user Y read" for Site Admins only', async () => {
const pageId = `page-${suffix}`;
await prisma.readEvent.createMany({
data: [
eventRow({ pageId, actorId: adminId, channel: 'export' }),
eventRow({ pageId: `other-${suffix}`, actorId: null }),
],
});
const byPage = await api()
.get(`/api/v1/admin/system/read-events?pageId=${pageId}`)
.set('Cookie', adminCookie)
.expect(200);
expect(byPage.body.total).toBe(1);
expect(byPage.body.entries[0]).toMatchObject({
pageId,
channel: 'export',
windowSeconds: 300,
});
expect(byPage.body.entries[0].actor).toMatchObject({ id: adminId });
const byActor = await api()
.get(`/api/v1/admin/system/read-events?actor=storage-admin-${suffix}`)
.set('Cookie', adminCookie)
.expect(200);
expect(byActor.body.total).toBe(1);
// An unknown username matches nothing rather than everything.
const unknown = await api()
.get(`/api/v1/admin/system/read-events?actor=nobody-${suffix}`)
.set('Cookie', adminCookie)
.expect(200);
expect(unknown.body.total).toBe(0);
await api()
.get(`/api/v1/admin/system/read-events?pageId=${pageId}`)
.set('Cookie', readerCookie)
.expect(403);
});
});
describe('partitioned shape (fresh database, real migrations)', () => {
const baseUrl = process.env.TEST_DATABASE_URL!;
const dbName = `dorfteich_trail_${suffix}`;
let fresh: PrismaClient;
let maintenance: ReadTrailMaintenanceService;
const auditRecord = vi.fn().mockResolvedValue(undefined);
let retentionDays = 365;
function freshUrl(): string {
const url = new URL(baseUrl);
url.pathname = `/${dbName}`;
return url.toString();
}
beforeAll(async () => {
const admin = new PrismaClient({ datasourceUrl: baseUrl });
try {
await admin.$executeRawUnsafe(`CREATE DATABASE "${dbName}"`);
} finally {
await admin.$disconnect();
}
execFileSync(
process.execPath,
[join(API_ROOT, 'node_modules', 'prisma', 'build', 'index.js'), 'migrate', 'deploy'],
{ env: { ...process.env, DATABASE_URL: freshUrl() }, stdio: 'pipe', cwd: API_ROOT },
);
fresh = new PrismaClient({ datasourceUrl: freshUrl() });
const settingsStub = {
get: async () => retentionDays,
} as unknown as InstanceSettingsService;
const auditStub = { record: auditRecord } as never;
const loggerStub = {
setContext: () => undefined,
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
} as unknown as PinoLogger;
maintenance = new ReadTrailMaintenanceService(
fresh as never,
settingsStub,
auditStub,
new ClockService(),
loggerStub,
);
}, 120_000);
afterAll(async () => {
await fresh.$disconnect();
const admin = new PrismaClient({ datasourceUrl: baseUrl });
try {
await admin.$executeRawUnsafe(`DROP DATABASE IF EXISTS "${dbName}" WITH (FORCE)`);
} finally {
await admin.$disconnect();
}
});
it('migrates read_events to a partitioned parent with current-month coverage', async () => {
const kind = await fresh.$queryRaw<{ relkind: string }[]>`
SELECT relkind::text FROM pg_class WHERE relname = 'read_events'`;
expect(kind[0]!.relkind).toBe('p');
const partitions = await fresh.$queryRaw<{ relname: string }[]>`
SELECT c.relname::text FROM pg_inherits i JOIN pg_class c ON c.oid = i.inhrelid
WHERE i.inhparent = 'read_events'::regclass ORDER BY c.relname`;
const names = partitions.map((p) => p.relname);
expect(names).toContain('read_events_default');
expect(names.some((n) => /^read_events_y\d{4}m\d{2}$/.test(n))).toBe(true);
});
it('keeps the dedup unique pair enforced per partition (P2002 on the duplicate)', async () => {
const row = {
actorId: null,
sessionKey: 'anon',
pageId: null,
pondId: 'pond-part',
channel: 'page_view',
classification: 'vs_nfd',
dedupKey: `dup-${suffix}`,
windowBucket: 42n,
windowSeconds: 300,
};
await fresh.readEvent.create({ data: row });
await expect(fresh.readEvent.create({ data: { ...row } })).rejects.toMatchObject({
code: 'P2002',
});
});
it('creates months ahead and prunes whole expired partitions by DROP, audited', async () => {
await maintenance.ensurePartitions();
const next = new Date();
const nextMonth = new Date(Date.UTC(next.getUTCFullYear(), next.getUTCMonth() + 2, 1));
const nextName = `read_events_y${nextMonth.getUTCFullYear()}m${String(
nextMonth.getUTCMonth() + 1,
).padStart(2, '0')}`;
const created = await fresh.$queryRawUnsafe<{ relname: string }[]>(
`SELECT relname::text FROM pg_class WHERE relname = '${nextName}'`,
);
expect(created).toHaveLength(1);
// ...and each new partition carries its own dedup unique index.
const index = await fresh.$queryRawUnsafe<{ indexname: string }[]>(
`SELECT indexname::text FROM pg_indexes WHERE tablename = '${nextName}'
AND indexname = '${nextName}_dedup_key'`,
);
expect(index).toHaveLength(1);
// An old month: partition + one event well past retention.
await fresh.$executeRawUnsafe(
`CREATE TABLE "read_events_y2020m01" PARTITION OF "read_events"
FOR VALUES FROM ('2020-01-01') TO ('2020-02-01')`,
);
await fresh.readEvent.create({
data: {
occurredAt: new Date('2020-01-15T12:00:00Z'),
actorId: null,
sessionKey: 'anon',
pageId: null,
pondId: 'pond-part',
channel: 'export',
classification: 'vs_nfd',
dedupKey: `old-${suffix}`,
windowBucket: 1n,
windowSeconds: 300,
},
});
const pruned = await maintenance.pruneExpired();
expect(pruned).toBeGreaterThanOrEqual(1);
const gone = await fresh.$queryRawUnsafe<{ relname: string }[]>(
`SELECT relname::text FROM pg_class WHERE relname = 'read_events_y2020m01'`,
);
expect(gone).toHaveLength(0);
expect(auditRecord).toHaveBeenCalledWith(
expect.objectContaining({ action: 'read_trail.pruned' }),
);
// The current month's events survive.
const kept = await fresh.readEvent.count({ where: { dedupKey: `dup-${suffix}` } });
expect(kept).toBe(1);
retentionDays = 365; // restore for any later use
});
});
});

View File

@ -1,132 +0,0 @@
import { INestApplication } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { InstanceSettingsService } from '../settings/instance-settings.service';
import { createTestApp, sessionCookieOf } from '../testing/test-app';
import { createTestPrisma, grantOwnerAdmin, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service';
import { ReadTrailService } from './read-trail.service';
/**
* The read-trail master switch (issue #225, ADR 0023): OFF is the default
* and means no event is written ANYWHERE no row, no stdout line; ON
* restores the full #222 semantics. The switch position is announced so a
* silent trail is never ambiguous.
*/
describe.skipIf(!hasTestDb)('read-trail switch (e2e, issue #225)', () => {
let app: INestApplication;
let prisma: PrismaClient;
const suffix = uniqueSuffix();
const password = 'schalter zeugen 123';
let ownerId: string;
let ownerCookie: string;
let pondId: string;
let classifiedId: string;
const api = () => request(app.getHttpServer());
beforeAll(async () => {
prisma = createTestPrisma();
await prisma.rateLimit.deleteMany({});
// No leftover switch row: this suite tests the DEFAULT (off).
await prisma.instanceSetting.deleteMany({ where: { key: 'readTrail.enabled' } });
app = await createTestApp();
const users = app.get(UsersService);
const owner = await users.createUser({
username: `switch-owner-${suffix}`,
email: `switch-owner-${suffix}@example.test`,
displayName: 'Switch Owner',
password,
locale: 'en',
});
await users.markEmailVerified(owner.id);
ownerId = owner.id;
ownerCookie = sessionCookieOf(
await api()
.post('/api/v1/auth/login')
.send({ usernameOrEmail: `switch-owner-${suffix}`, password })
.expect(200),
);
const pond = await prisma.pond.create({
data: { slug: `switch-pond-${suffix}`, name: 'Switch Pond', type: 'SHARED', ownerId },
});
pondId = pond.id;
await grantOwnerAdmin(prisma, pondId, ownerId);
const page = await prisma.page.create({
data: {
pondId,
slug: `classified-${suffix}`,
title: 'Classified',
classification: 'VS_NFD',
createdBy: ownerId,
sortKey: 'a0',
ydocState: new Uint8Array(),
contentCache: {
create: { plainText: 'x', markdown: 'x', html: '<p>x</p>', outline: [] },
},
},
});
classifiedId = page.id;
});
afterAll(async () => {
await prisma.instanceSetting.deleteMany({ where: { key: 'readTrail.enabled' } });
await prisma.readEvent.deleteMany({ where: { pondId } });
await prisma.roleGrant.deleteMany({ where: { pondId } });
await prisma.pageContentCache.deleteMany({ where: { page: { pondId } } });
await prisma.page.deleteMany({ where: { pondId } });
await prisma.pond.deleteMany({ where: { id: pondId } });
await prisma.user.deleteMany({ where: { id: ownerId } });
await prisma.$disconnect();
await app.close();
});
it('is OFF by default: a classified read writes nothing — no row, no stdout line', async () => {
const trail = app.get(ReadTrailService);
const logger = (trail as unknown as { logger: PinoLogger }).logger;
const infoSpy = vi.spyOn(logger, 'info');
try {
await api().get(`/api/v1/pages/${classifiedId}`).set('Cookie', ownerCookie).expect(200);
} finally {
infoSpy.mockRestore();
}
expect(await prisma.readEvent.count({ where: { pondId } })).toBe(0);
expect(infoSpy).not.toHaveBeenCalled();
});
it('records again the moment the switch turns on', async () => {
await app.get(InstanceSettingsService).set('readTrail.enabled', true, ownerId);
try {
await api().get(`/api/v1/pages/${classifiedId}`).set('Cookie', ownerCookie).expect(200);
expect(await prisma.readEvent.count({ where: { pondId } })).toBe(1);
} finally {
await app.get(InstanceSettingsService).set('readTrail.enabled', false, ownerId);
}
});
it('announces the switch position so silence is never ambiguous', async () => {
const trail = app.get(ReadTrailService);
const logger = (trail as unknown as { logger: PinoLogger }).logger;
const warnSpy = vi.spyOn(logger, 'warn');
const infoSpy = vi.spyOn(logger, 'info');
try {
await trail.announceState(); // switch is off after the previous test
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('NOT evidenced'));
await app.get(InstanceSettingsService).set('readTrail.enabled', true, ownerId);
await trail.announceState();
expect(infoSpy).toHaveBeenCalledWith(expect.stringContaining('enabled'));
} finally {
warnSpy.mockRestore();
infoSpy.mockRestore();
await app.get(InstanceSettingsService).set('readTrail.enabled', false, ownerId);
}
});
});

View File

@ -1,335 +0,0 @@
import { INestApplication } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import request from 'supertest';
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { PrismaService } from '../prisma/prisma.service';
import { InstanceSettingsService } from '../settings/instance-settings.service';
import { createTestApp, sessionCookieOf } from '../testing/test-app';
import { createTestPrisma, grantOwnerAdmin, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service';
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
/**
* The read-access trail (issue #222, ADR 0023): every read channel emits one
* `read_events` row for a `VS_NFD` page and none for an unclassified one
* per channel, both directions. Plus the deliberate failure semantics: a
* failed trail write aborts the read (hard failure, the documented contrast
* to AuditService's swallow-and-log).
*/
describe.skipIf(!hasTestDb)('read-access trail (e2e, issue #222)', () => {
let app: INestApplication;
let prisma: PrismaClient;
const suffix = uniqueSuffix();
const password = 'lesetrail zeugen 123';
let ownerId: string;
let ownerCookie: string;
let pondId: string;
let pondSlug: string;
let classifiedId: string;
let classifiedSlug: string;
let openId: string;
let openSlug: string;
let hostSlug: string;
const api = () => request(app.getHttpServer());
const eventsFor = (pageId: string) =>
prisma.readEvent.findMany({ where: { pageId }, orderBy: { occurredAt: 'asc' } });
async function makePage(
slug: string,
title: string,
html: string,
classification: 'UNCLASSIFIED' | 'VS_NFD',
) {
return prisma.page.create({
data: {
pondId,
slug,
title,
classification,
createdBy: ownerId,
sortKey: 'a0',
ydocState: new Uint8Array(),
contentCache: { create: { plainText: title, markdown: `# ${title}`, html, outline: [] } },
},
});
}
beforeAll(async () => {
prisma = createTestPrisma();
await prisma.rateLimit.deleteMany({});
app = await createTestApp();
const users = app.get(UsersService);
const owner = await users.createUser({
username: `trail-owner-${suffix}`,
email: `trail-owner-${suffix}@example.test`,
displayName: 'Trail Owner',
password,
locale: 'en',
});
await users.markEmailVerified(owner.id);
ownerId = owner.id;
// The trail ships OFF by default (#225) — these suites test the
// instrumented channels, so they run with the switch on.
await app.get(InstanceSettingsService).set('readTrail.enabled', true, ownerId);
ownerCookie = sessionCookieOf(
await api()
.post('/api/v1/auth/login')
.send({ usernameOrEmail: `trail-owner-${suffix}`, password })
.expect(200),
);
pondSlug = `trail-pond-${suffix}`;
const pond = await prisma.pond.create({
data: { slug: pondSlug, name: 'Trail Pond', type: 'SHARED', ownerId },
});
pondId = pond.id;
// Grants land as rows BEFORE any permission query touches this pond, so
// the per-pond cache first fills with them present.
await grantOwnerAdmin(prisma, pondId, ownerId);
await prisma.roleGrant.create({
data: {
pondId,
subjectType: 'PUBLIC',
subjectId: null,
role: 'READER',
scopeType: 'POND',
scopeId: null,
effect: 'ALLOW',
createdBy: ownerId,
},
});
classifiedSlug = `classified-${suffix}`;
const classified = await makePage(
classifiedSlug,
'Classified Note',
'<p>Restricted content.</p>',
'VS_NFD',
);
classifiedId = classified.id;
openSlug = `open-${suffix}`;
const open = await makePage(openSlug, 'Open Note', '<p>Open content.</p>', 'UNCLASSIFIED');
openId = open.id;
// An unclassified host page that transcludes the classified page — the
// expanded embed shows the target's full content (#222).
hostSlug = `host-${suffix}`;
await makePage(
hostSlug,
'Host Page',
`<p>Intro.</p><div class="dt-transclusion" data-transclusion="${classifiedSlug}">${classifiedSlug}</div>`,
'UNCLASSIFIED',
);
});
afterEach(async () => {
await prisma.readEvent.deleteMany({ where: { pondId } });
});
afterAll(async () => {
await prisma.instanceSetting.deleteMany({
where: { key: { in: ['api.enabled', 'readTrail.enabled'] } },
});
await prisma.readEvent.deleteMany({ where: { pondId } });
await prisma.conversionJob.deleteMany({ where: { ownerId } });
await prisma.apiToken.deleteMany({ where: { userId: ownerId } });
await prisma.attachment.deleteMany({ where: { pondId } });
await prisma.roleGrant.deleteMany({ where: { pondId } });
await prisma.pageContentCache.deleteMany({ where: { page: { pondId } } });
await prisma.page.deleteMany({ where: { pondId } });
await prisma.pond.deleteMany({ where: { id: pondId } });
await prisma.user.deleteMany({ where: { id: ownerId } });
await prisma.$disconnect();
await app.close();
});
it('records the SPA state fetch (page_view) with actor and session key — and nothing for unclassified', async () => {
await api().get(`/api/v1/pages/${classifiedId}`).set('Cookie', ownerCookie).expect(200);
const events = await eventsFor(classifiedId);
expect(events).toHaveLength(1);
expect(events[0]!).toMatchObject({
channel: 'page_view',
actorId: ownerId,
pondId,
classification: 'vs_nfd',
});
expect(events[0]!.sessionKey).toMatch(/^session:/);
await api().get(`/api/v1/pages/${openId}`).set('Cookie', ownerCookie).expect(200);
expect(await eventsFor(openId)).toHaveLength(0);
});
it('records the authenticated read rendering (/read) as page_view', async () => {
await api()
.get(`/api/v1/read/${pondSlug}/${classifiedSlug}`)
.set('Cookie', ownerCookie)
.expect(200);
const events = await eventsFor(classifiedId);
expect(events).toHaveLength(1);
expect(events[0]!.channel).toBe('page_view');
await api().get(`/api/v1/read/${pondSlug}/${openSlug}`).set('Cookie', ownerCookie).expect(200);
expect(await eventsFor(openId)).toHaveLength(0);
});
it('records the anonymous public JSON read with the documented anon marker', async () => {
await api().get(`/api/v1/public/${pondSlug}/${classifiedSlug}/content`).expect(200);
const events = await eventsFor(classifiedId);
expect(events).toHaveLength(1);
expect(events[0]!).toMatchObject({ channel: 'page_view', actorId: null, sessionKey: 'anon' });
await api().get(`/api/v1/public/${pondSlug}/${openSlug}/content`).expect(200);
expect(await eventsFor(openId)).toHaveLength(0);
});
it('records the no-JS shell under its own channel', async () => {
await api().get(`/api/v1/public/${pondSlug}/${classifiedSlug}`).expect(200);
const events = await eventsFor(classifiedId);
expect(events).toHaveLength(1);
expect(events[0]!.channel).toBe('no_js_shell');
await api().get(`/api/v1/public/${pondSlug}/${openSlug}`).expect(200);
expect(await eventsFor(openId)).toHaveLength(0);
});
it('records an expanded embed of a classified page inside an unclassified host', async () => {
const res = await api()
.get(`/api/v1/read/${pondSlug}/${hostSlug}`)
.set('Cookie', ownerCookie)
.expect(200);
expect(res.body.html).toContain('Restricted content.');
const events = await eventsFor(classifiedId);
expect(events).toHaveLength(1);
expect(events[0]!).toMatchObject({ channel: 'page_view', details: { embedded: true } });
});
it('records collab-token issuance (collab_join) with the granted mode', async () => {
await api()
.get(`/api/v1/pages/${classifiedId}/collab-token`)
.set('Cookie', ownerCookie)
.expect(200);
const events = await eventsFor(classifiedId);
expect(events).toHaveLength(1);
expect(events[0]!).toMatchObject({ channel: 'collab_join', details: { mode: 'rw' } });
await api().get(`/api/v1/pages/${openId}/collab-token`).set('Cookie', ownerCookie).expect(200);
expect(await eventsFor(openId)).toHaveLength(0);
});
it('records the single-page markdown download and the queued document export', async () => {
await api()
.get(`/api/v1/pages/${classifiedId}/export/markdown`)
.set('Cookie', ownerCookie)
.expect(200);
let events = await eventsFor(classifiedId);
expect(events).toHaveLength(1);
expect(events[0]!.channel).toBe('export');
await prisma.readEvent.deleteMany({ where: { pondId } });
await api()
.post(`/api/v1/pages/${classifiedId}/export`)
.set('Cookie', ownerCookie)
.send({ format: 'docx' })
.expect(201);
events = await eventsFor(classifiedId);
expect(events).toHaveLength(1);
expect(events[0]!).toMatchObject({ channel: 'export', details: { format: 'docx' } });
await api()
.get(`/api/v1/pages/${openId}/export/markdown`)
.set('Cookie', ownerCookie)
.expect(200);
await api()
.post(`/api/v1/pages/${openId}/export`)
.set('Cookie', ownerCookie)
.send({ format: 'docx' })
.expect(201);
expect(await eventsFor(openId)).toHaveLength(0);
});
it('records one event per classified page in a pond ZIP export — none for the unclassified ones', async () => {
await api()
.get(`/api/v1/ponds/${pondId}/export/markdown`)
.set('Cookie', ownerCookie)
.expect(200);
const events = await eventsFor(classifiedId);
expect(events).toHaveLength(1);
expect(events[0]!).toMatchObject({ channel: 'export', details: { format: 'markdown_zip' } });
expect(await eventsFor(openId)).toHaveLength(0);
});
it('records a download of an attachment whose effective classification is vs_nfd', async () => {
const classifiedUpload = await api()
.post(`/api/v1/pages/${classifiedId}/files`)
.set('Cookie', ownerCookie)
.attach('file', Buffer.concat([PNG_SIGNATURE, Buffer.from('classified bytes')]), 'c.png')
.expect(201);
await prisma.readEvent.deleteMany({ where: { pondId } });
await api()
.get(`/api/v1/media/${classifiedUpload.body.id}`)
.set('Cookie', ownerCookie)
.expect(200);
const events = await prisma.readEvent.findMany({ where: { pondId, channel: 'attachment' } });
expect(events).toHaveLength(1);
expect(events[0]!).toMatchObject({
pageId: classifiedId,
details: { attachmentId: classifiedUpload.body.id },
});
});
it('records a public-api page read under the token session key', async () => {
await app.get(InstanceSettingsService).set('api.enabled', true, ownerId);
try {
await api()
.patch(`/api/v1/ponds/${pondId}`)
.set('Cookie', ownerCookie)
.send({ apiEnabled: true })
.expect(200);
const minted = await api()
.post('/api/v1/users/me/api-tokens')
.set('Cookie', ownerCookie)
.send({ name: `trail-${suffix}`, scope: 'read' })
.expect(201);
await api()
.get(`/api/public/v1/ponds/${pondSlug}/pages/${classifiedSlug}`)
.set('Authorization', `Bearer ${minted.body.token}`)
.expect(200);
const events = await eventsFor(classifiedId);
expect(events).toHaveLength(1);
expect(events[0]!.channel).toBe('public_api');
expect(events[0]!.sessionKey).toMatch(/^token:/);
await api()
.get(`/api/public/v1/ponds/${pondSlug}/pages/${openSlug}`)
.set('Authorization', `Bearer ${minted.body.token}`)
.expect(200);
expect(await eventsFor(openId)).toHaveLength(0);
} finally {
await prisma.instanceSetting.deleteMany({ where: { key: 'api.enabled' } });
}
});
it('fails the read hard when the trail cannot be written (ADR 0023 — no silent gap)', async () => {
const appPrisma = app.get(PrismaService);
const create = vi
.spyOn(appPrisma.readEvent, 'create')
.mockRejectedValueOnce(new Error('trail unavailable'));
try {
await api().get(`/api/v1/pages/${classifiedId}`).set('Cookie', ownerCookie).expect(500);
// The unclassified read never touches the trail and stays unaffected.
await api().get(`/api/v1/pages/${openId}`).set('Cookie', ownerCookie).expect(200);
} finally {
create.mockRestore();
}
expect(await eventsFor(classifiedId)).toHaveLength(0);
});
});

View File

@ -1,44 +0,0 @@
import { Global, Module, OnModuleInit } 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 { ReadTrailMaintenanceService } from './read-trail-maintenance.service';
import { ReadTrailService } from './read-trail.service';
/** Daily, per operations.md's maintenance-jobs table (issue #224). */
const READ_TRAIL_MAINTENANCE_CADENCE_SECONDS = 24 * 60 * 60;
/**
* Global like AuditModule and for the same reason: the read-access trail
* (issue #222, ADR 0023) cuts across every module that serves page content
* pages, public, public-api, files, import-export.
*/
@Global()
@Module({
imports: [CommonModule, SchedulerModule, SettingsModule],
providers: [ReadTrailService, ReadTrailMaintenanceService],
exports: [ReadTrailService, ReadTrailMaintenanceService],
})
export class ReadTrailModule implements OnModuleInit {
constructor(
private readonly scheduler: SchedulerService,
private readonly maintenance: ReadTrailMaintenanceService,
private readonly trail: ReadTrailService,
) {}
async onModuleInit(): Promise<void> {
this.scheduler.register({
name: 'read-trail-maintenance',
cadenceSeconds: READ_TRAIL_MAINTENANCE_CADENCE_SECONDS,
run: async () => {
await this.maintenance.run();
},
});
// One line per boot stating the switch position (issue #225) — a silent
// trail must never be ambiguous.
await this.trail.announceState();
}
}

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