Compare commits

..

No commits in common. "main" and "issue-231-residual-risks" have entirely different histories.

290 changed files with 978 additions and 17225 deletions

View File

@ -107,24 +107,6 @@ jobs:
exit 1
fi
# A fresh named volume inherits the ownership of the image directory it
# is mounted over. Every /data/… path the api image defaults to must
# therefore be pre-created AND chowned to `node`, or the non-root user
# cannot write to it — found on a real deploy in #303, where the env
# entry was added but the mkdir/chown line was not.
- name: api image pre-creates its data directories node-owned
run: |
set -euo pipefail
dirs=$(grep -oE '[A-Z_]+_DIR=/data/[a-z]+' apps/api/Dockerfile | cut -d= -f2 | sort -u)
bad=0
for d in $dirs; do
grep -q "mkdir -p .*$d" apps/api/Dockerfile || {
echo "$d is not pre-created in apps/api/Dockerfile"; bad=1; }
grep -q "chown -R node:node .*$d" apps/api/Dockerfile || {
echo "$d is not chowned to node in apps/api/Dockerfile"; bad=1; }
done
exit "$bad"
- name: Set up pnpm
uses: pnpm/action-setup@v4
@ -210,16 +192,7 @@ jobs:
- name: Start api, collab, and static web server
run: |
# VS_NFD_MODE=marked: the marking pack and the a11y admin scan
# 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/api && PORT=3001 node dist/main.js > /tmp/api.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 &)
for i in $(seq 1 30); do
@ -345,16 +318,6 @@ jobs:
E2E_BASE_URL=http://localhost:5173 \
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
run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
@ -375,18 +338,6 @@ jobs:
E2E_BASE_URL=http://localhost:5173 \
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
run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
@ -660,37 +611,6 @@ jobs:
E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/a11y.spec.ts
# Das a11y-Pack kostet seit #301 einen Login mehr (der Reflow-Zaun);
# damit reicht das Budget nicht mehr bis in die VS-NfD-Packs → hier
# zusätzlich zurücksetzen (siehe Hinweis oben).
- name: Reset login rate limit before the VS-NfD packs
run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
# VS-NfD-Markierungen im Modus `marked` (issue #244).
- name: Run VS-NfD marking pack
run: |
E2E_BASE_URL=http://localhost:5173 E2E_VS_NFD_MODE=marked \
pnpm --filter @dorfteich/web exec playwright test e2e/vs-nfd-marking.spec.ts
# Ausblendung + Policy-Hinweis im Modus `hidden` (issue #245).
- name: Run VS-NfD hidden pack
run: |
for i in $(seq 1 30); do
curl -sf http://localhost:3006/api/v1/readyz >/dev/null && break
sleep 2
done
E2E_BASE_URL=http://localhost:5176 E2E_VS_NFD_MODE=hidden \
pnpm --filter @dorfteich/web exec playwright test e2e/vs-nfd-marking.spec.ts
# The marking pack's extra login on top of the six a11y logins pushes
# the theme pack over the 10/min login limit — reset again (#244).
- name: Reset login rate limit before theme pack
run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
# Hell/Dunkel/System-Umschalter (issue #180).
- name: Run theme pack
run: |

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.
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
# 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).
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
COPY --from=build --chown=node:node /out /app
# Generate the Prisma client for this image's platform.
RUN node node_modules/prisma/build/index.js generate
# A fresh named volume mounted at /data/uploads, /data/plugins, /data/fonts
# or /data/branding is created
# A fresh named volume mounted at /data/uploads or /data/plugins is created
# 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
# 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
# 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
EXPOSE 3000
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",
"fractional-indexing": "^4.0.0",
"i18next": "^26.3.4",
"jose": "^6.2.4",
"jsdom": "^26.1.0",
"multer": "^2.1.1",
"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

@ -31,71 +31,41 @@ enum UserStatus {
/// Account profile. Login methods live in UserIdentity (OIDC-ready,
/// ADR 0007); Site Admin is a user flag, all other roles are grants.
model User {
id String @id @default(uuid())
username String @unique
email String @unique
displayName String @map("display_name")
locale String @default("en")
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")
id String @id @default(uuid())
username String @unique
email String @unique
displayName String @map("display_name")
locale String @default("en")
isSiteAdmin Boolean @default(false) @map("is_site_admin")
/// Auto-watch preferences (issue #93): watch pages I create / comment on.
autoWatchOwnPages Boolean @default(true) @map("auto_watch_own_pages")
autoWatchOnComment Boolean @default(true) @map("auto_watch_on_comment")
autoWatchOwnPages Boolean @default(true) @map("auto_watch_own_pages")
autoWatchOnComment Boolean @default(true) @map("auto_watch_on_comment")
/// E-mail digest cadence (issue #95): hourly | daily | off.
digestFrequency String @default("hourly") @map("digest_frequency")
status UserStatus @default(PENDING_VERIFICATION)
emailVerifiedAt DateTime? @map("email_verified_at")
createdAt DateTime @default(now()) @map("created_at")
lastLoginAt DateTime? @map("last_login_at")
digestFrequency String @default("hourly") @map("digest_frequency")
status UserStatus @default(PENDING_VERIFICATION)
emailVerifiedAt DateTime? @map("email_verified_at")
createdAt DateTime @default(now()) @map("created_at")
lastLoginAt DateTime? @map("last_login_at")
identities UserIdentity[]
sessions Session[]
authTokens AuthToken[]
apiTokens ApiToken[]
feedTokens FeedToken[]
mentionRows PageMention[]
ponds Pond[]
pages Page[]
attachments Attachment[]
conversionJobs ConversionJob[]
auditEntries AuditEntry[]
comments Comment[]
watches Watch[]
notifications Notification[]
favorites PageFavorite[]
customFonts CustomFont[]
invitations Invitation[] @relation("InvitationsSent")
identities UserIdentity[]
sessions Session[]
authTokens AuthToken[]
apiTokens ApiToken[]
feedTokens FeedToken[]
mentionRows PageMention[]
ponds Pond[]
pages Page[]
attachments Attachment[]
conversionJobs ConversionJob[]
auditEntries AuditEntry[]
comments Comment[]
watches Watch[]
notifications Notification[]
favorites PageFavorite[]
@@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
/// admin actions — grants, member roles, plugin installs, quota and settings
/// changes, setup steps, manual job triggers. Written by AuditService, which
@ -121,52 +91,6 @@ model AuditEntry {
@@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
/// one level deep: roots carry the optional document anchor and the resolve
/// state, replies reference the root via `parentId`. Purging a page cascades
@ -262,14 +186,14 @@ model Pond {
deletedAt DateTime? @map("deleted_at")
deletedBy String? @map("deleted_by")
owner User @relation(fields: [ownerId], references: [id])
usage PondUsage?
pages Page[]
attachments Attachment[]
labels Label[]
grants RoleGrant[]
owner User @relation(fields: [ownerId], references: [id])
usage PondUsage?
pages Page[]
attachments Attachment[]
labels Label[]
grants RoleGrant[]
conversionJobs ConversionJob[]
pondPlugins PondPlugin[]
pondPlugins PondPlugin[]
@@index([ownerId])
@@map("ponds")
@ -315,10 +239,6 @@ model RoleGrant {
scopeType GrantScopeType @map("scope_type")
scopeId String? @map("scope_id")
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")
createdAt DateTime @default(now()) @map("created_at")
@ -485,12 +405,12 @@ model CollabOpenSession {
/// built from the Yjs state via the shared editor schema. `outline` is the
/// heading tree (`OutlineEntry[]` from @dorfteich/shared) as jsonb.
model PageContentCache {
pageId String @id @map("page_id")
plainText String @map("plain_text")
markdown String
html String
outline Json
updatedAt DateTime @updatedAt @map("updated_at")
pageId String @id @map("page_id")
plainText String @map("plain_text")
markdown String
html String
outline Json
updatedAt DateTime @updatedAt @map("updated_at")
/// Weighted full-text search vector (title A, labels B, body C; issue #49,
/// ADR 0010). Maintained by the SearchProvider and the collab persistence
/// hook (both write it with the same weighting). The GIN index is added in
@ -879,9 +799,9 @@ model ConversionJob {
sourceName String? @map("source_name")
resultPageId String? @map("result_page_id")
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
pond Pond? @relation(fields: [pondId], references: [id], onDelete: Cascade)
page Page? @relation(fields: [resultPageId], references: [id], onDelete: SetNull)
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
pond Pond? @relation(fields: [pondId], references: [id], onDelete: Cascade)
page Page? @relation(fields: [resultPageId], references: [id], onDelete: SetNull)
@@index([status, createdAt])
@@map("conversion_jobs")
@ -910,8 +830,6 @@ model Plugin {
mode PluginInstanceMode @default(DISABLED)
/// The full manifest as validated at install time (@dorfteich/plugin-sdk).
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")
updatedAt DateTime @updatedAt @map("updated_at")
/// Set when uninstalled; active queries filter `removedAt: null`.
@ -938,48 +856,3 @@ model PondPlugin {
@@id([pondId, pluginId])
@@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';
import { SiteAdminGuard } from './site-admin.guard';
// Lifecycle markers and file-backed metadata, not configuration: never
// editable through this endpoint. The setup lock must be irreversible
// (issue #80), and the branding entries only describe bytes on disk
// (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',
]);
// Lifecycle markers, not configuration: never editable through this
// endpoint (the setup lock must be irreversible, issue #80).
const INTERNAL_KEYS: ReadonlySet<InstanceSettingKey> = new Set(['setup.completedAt']);
// Partial update: any subset of the known settings, each validated by
// 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 { BackupModule } from '../backup/backup.module';
import { PondsModule } from '../ponds/ponds.module';
import { QuotasModule } from '../quotas/quotas.module';
import { SchedulerModule } from '../scheduler/scheduler.module';
import { SearchModule } from '../search/search.module';
@ -20,15 +19,7 @@ import { UserAdminController } from './user-admin.controller';
import { UserAdminService } from './user-admin.service';
@Module({
imports: [
QuotasModule,
UsersModule,
AuthModule,
SchedulerModule,
BackupModule,
SearchModule,
PondsModule,
],
imports: [QuotasModule, UsersModule, AuthModule, SchedulerModule, BackupModule, SearchModule],
controllers: [
AdminSettingsController,
BackupAdminController,

View File

@ -1,21 +1,16 @@
import { Controller, Get, Param, Post, Query, Req, UseGuards } from '@nestjs/common';
import {
auditListQuerySchema,
readEventListQuerySchema,
type AuditListQuery,
type AuditListView,
type JobTriggerResult,
type ReadEventListQuery,
type ReadEventListView,
type StorageOverviewView,
type SystemBackupView,
type SystemJobView,
type VsNfdProfileView,
} from '@dorfteich/shared';
import { AuthedRequest } from '../auth/auth.guard';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { VsNfdProfileService } from '../settings/vs-nfd-profile.service';
import { SiteAdminGuard } from './site-admin.guard';
import { SystemAdminService } from './system-admin.service';
@ -23,17 +18,7 @@ import { SystemAdminService } from './system-admin.service';
@Controller('admin/system')
@UseGuards(SiteAdminGuard)
export class SystemAdminController {
constructor(
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();
}
constructor(private readonly system: SystemAdminService) {}
@Get('jobs')
async jobs(): Promise<SystemJobView[]> {
@ -60,15 +45,6 @@ export class SystemAdminController {
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')
async storage(): Promise<StorageOverviewView> {
return this.system.storage();

View File

@ -7,13 +7,10 @@ import {
AUDIT_PAGE_SIZE,
BACKUP_FRESH_MAX_AGE_HOURS,
BACKUP_STATUS_FILE,
READ_EVENT_PAGE_SIZE,
type AuditListQuery,
type AuditListView,
type BackupStatus,
type JobTriggerResult,
type ReadEventListQuery,
type ReadEventListView,
type StorageOverviewView,
type SystemBackupView,
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> {
const usages = await this.prisma.pondUsage.findMany({
where: { pond: { deletedAt: null } },

View File

@ -12,11 +12,9 @@ import {
UseGuards,
} from '@nestjs/common';
import {
AdminCreateUserInput,
AdminUserListQuery,
AdminUserListView,
AdminUserView,
adminCreateUserSchema,
adminUserListQuerySchema,
setSiteAdminSchema,
setUserDisabledSchema,
@ -33,14 +31,6 @@ import { UserAdminService } from './user-admin.service';
export class UserAdminController {
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()
async list(
@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 { 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';
/**
@ -62,71 +62,13 @@ describe.skipIf(!hasTestDb)('user admin (e2e, issue #59)', () => {
afterAll(async () => {
const all = Object.values(ids);
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.user.deleteMany({ where: { id: { in: all } } });
await prisma.$disconnect();
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 () => {
const res = await api()
.get(`/api/v1/admin/users?q=ua-bob-${suffix}`)

View File

@ -1,6 +1,5 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import {
AdminCreateUserInput,
AdminUserListQuery,
AdminUserListView,
AdminUserStatus,
@ -11,9 +10,7 @@ import { PinoLogger } from 'nestjs-pino';
import { AuthService } from '../auth/auth.service';
import { AuditService } from '../audit/audit.service';
import { PondsService } from '../ponds/ponds.service';
import { PrismaService } from '../prisma/prisma.service';
import { UsersService } from '../users/users.service';
import { PseudonymizationService } from './pseudonymization.service';
/**
@ -30,33 +27,12 @@ export class UserAdminService {
private readonly prisma: PrismaService,
private readonly pseudonymizer: PseudonymizationService,
private readonly auth: AuthService,
private readonly users: UsersService,
private readonly ponds: PondsService,
private readonly audit: AuditService,
private readonly logger: PinoLogger,
) {
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> {
const q = query.q?.trim();
const where: Prisma.UserWhereInput = q
@ -139,9 +115,7 @@ export class UserAdminService {
if (!value && user.isSiteAdmin) await this.assertNotLastSiteAdmin();
const updated = await this.prisma.user.update({
where: { id },
// A manual toggle takes ownership of the flag: the IdP mapping
// (#217) may only revoke what it itself set.
data: { isSiteAdmin: value, isSiteAdminManaged: false },
data: { isSiteAdmin: value },
});
await this.audit.record({
action: 'user.site_admin_set',

View File

@ -6,7 +6,6 @@ import { AdminModule } from './admin/admin.module';
import { AuditModule } from './audit/audit.module';
import { AuthModule } from './auth/auth.module';
import { BackupModule } from './backup/backup.module';
import { BrandingModule } from './branding/branding.module';
import { ApiExceptionFilter } from './common/api-exception.filter';
import { maskTokenParam } from './common/mask-token-param';
import { SecurityHeadersMiddleware } from './common/security-headers.middleware';
@ -18,7 +17,6 @@ import { FilesModule } from './files/files.module';
import { GrantsModule } from './grants/grants.module';
import { HealthModule } from './health/health.module';
import { HomeModule } from './home/home.module';
import { FontsModule } from './fonts/fonts.module';
import { ImportExportModule } from './import-export/import-export.module';
import { LabelsModule } from './labels/labels.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 { PublicModule } from './public/public.module';
import { RateLimitModule } from './rate-limit/rate-limit.module';
import { ReadTrailModule } from './read-trail/read-trail.module';
import { SearchModule } from './search/search.module';
import { SettingsModule } from './settings/settings.module';
import { SetupModule } from './setup/setup.module';
@ -50,7 +47,6 @@ import { VersionsModule } from './versions/versions.module';
ConfigModule,
PrismaModule,
AuditModule,
ReadTrailModule,
RateLimitModule,
MailModule,
SettingsModule,
@ -83,8 +79,6 @@ import { VersionsModule } from './versions/versions.module';
PublicModule,
PublicApiModule,
McpModule,
BrandingModule,
FontsModule,
ImportExportModule,
PluginsModule,
AuthModule,

View File

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

View File

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

View File

@ -1,6 +1,5 @@
import { Body, Controller, Get, HttpCode, Post, Req, Res } from '@nestjs/common';
import {
AuthMethodsView,
CurrentUser as CurrentUserShape,
LoginInput,
SignupInput,
@ -21,14 +20,12 @@ import { InstanceSettingsService } from '../settings/instance-settings.service';
import { SetupExempt } from '../setup/setup.guard';
import {
AuthedRequest,
LocalCredentialFlow,
Public,
SESSION_COOKIE,
setSessionCookie,
toCurrentUser,
} from './auth.guard';
import { AuthService } from './auth.service';
import { OidcService } from './oidc.service';
import { SessionsService, sessionAbsoluteMs } from './sessions.service';
@AuthenticatedOnly() // routes reachable without a session opt out via @Public
@ -39,7 +36,6 @@ export class AuthController {
private readonly sessions: SessionsService,
private readonly config: AppConfig,
private readonly settings: InstanceSettingsService,
private readonly oidc: OidcService,
) {}
/** 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') };
}
/** 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()
@Post('signup')
@LocalCredentialFlow()
@HttpCode(201)
@RateLimit({ scope: 'signup', limit: 5, windowSeconds: 60 * 60 })
async signup(@Body(new ZodValidationPipe(signupInputSchema)) input: SignupInput): Promise<void> {
@ -72,7 +55,6 @@ export class AuthController {
@Public()
@Post('verify-email')
@LocalCredentialFlow()
@HttpCode(204)
@RateLimit({ scope: 'verify-email', limit: 20, windowSeconds: 60 * 60 })
async verifyEmail(
@ -83,7 +65,6 @@ export class AuthController {
@Public()
@Post('resend-verification')
@LocalCredentialFlow()
@HttpCode(204)
@RateLimit({ scope: 'resend-verification', limit: 5, windowSeconds: 60 * 60 })
async resendVerification(
@ -97,7 +78,6 @@ export class AuthController {
@SetupExempt()
@Public()
@Post('login')
@LocalCredentialFlow()
@HttpCode(200)
@RateLimit({ scope: 'login', limit: 10, windowSeconds: 60 })
async login(
@ -141,7 +121,6 @@ export class AuthController {
@Public()
@Post('forgot-password')
@LocalCredentialFlow()
@HttpCode(204)
@RateLimit({ scope: 'forgot-password', limit: 5, windowSeconds: 60 * 60 })
async forgotPassword(
@ -152,7 +131,6 @@ export class AuthController {
@Public()
@Post('reset-password')
@LocalCredentialFlow()
@HttpCode(204)
@RateLimit({ scope: 'reset-password', limit: 10, windowSeconds: 60 * 60 })
async resetPassword(

View File

@ -4,7 +4,7 @@ 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 { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
describe.skipIf(!hasTestDb)('auth flows (e2e)', () => {
let app: INestApplication;
@ -46,7 +46,7 @@ describe.skipIf(!hasTestDb)('auth flows (e2e)', () => {
afterAll(async () => {
// 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.mailOutbox.deleteMany({ where: { toAddress: { contains: suffix } } });
await prisma.$disconnect();

View File

@ -3,7 +3,6 @@ import {
ExecutionContext,
ForbiddenException,
Injectable,
NotFoundException,
SetMetadata,
UnauthorizedException,
createParamDecorator,
@ -14,7 +13,6 @@ import type { User } from '@prisma/client';
import type { Request, Response } from 'express';
import { AppConfig } from '../config/app-config.service';
import { ProxyIdentityService } from './proxy-identity.service';
import { SessionsService } from './sessions.service';
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…). */
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 {
user?: User;
sessionId?: string;
@ -98,38 +84,19 @@ export class AuthGuard implements CanActivate {
private readonly reflector: Reflector,
private readonly sessions: SessionsService,
private readonly config: AppConfig,
private readonly proxyIdentity: ProxyIdentityService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
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];
if (rawToken && MUTATING_METHODS.has(request.method)) {
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
// still want to know who is asking.
// Attach the user whenever the cookie is valid — public routes may
// still want to know who is asking.
if (rawToken) {
const validated = await this.sessions.validate(rawToken);
if (validated) {
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 { 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 { PondsModule } from '../ponds/ponds.module';
import { UsersModule } from '../users/users.module';
@ -12,42 +8,18 @@ import { AuthController } from './auth.controller';
import { AuthGuard } from './auth.guard';
import { AuthService } from './auth.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';
@Module({
imports: [UsersModule, MailModule, SessionsModule, PondsModule, GrantsModule, InvitationsModule],
controllers: [AuthController, OidcController],
imports: [UsersModule, MailModule, SessionsModule, PondsModule],
controllers: [AuthController],
providers: [
AuthService,
AuthTokensService,
ClaimMappingService,
OidcService,
ProxyIdentityService,
// Global default-protected: every route needs a session unless it
// opts out with @Public().
{ provide: APP_GUARD, useClass: AuthGuard },
],
exports: [AuthTokensService, AuthService, OidcService],
exports: [AuthTokensService, AuthService],
})
export class AuthModule implements OnModuleInit {
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',
);
}
}
}
export class AuthModule {}

View File

@ -9,7 +9,6 @@ import { User } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino';
import { AppConfig } from '../config/app-config.service';
import { InvitationsService } from '../invitations/invitations.service';
import { MailService } from '../mail/mail.service';
import { PondsService } from '../ponds/ponds.service';
import { AuditService } from '../audit/audit.service';
@ -34,7 +33,6 @@ export class AuthService {
private readonly sessions: SessionsService,
private readonly mail: MailService,
private readonly ponds: PondsService,
private readonly invitations: InvitationsService,
private readonly rateLimits: RateLimitService,
private readonly audit: AuditService,
private readonly config: AppConfig,
@ -45,37 +43,10 @@ export class AuthService {
}
async signup(input: SignupInput): Promise<void> {
// An invitation token (issue #332) lets exactly one signup through a
// 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') {
if ((await this.settings.get('auth.registrationMode')) === 'closed') {
throw new ForbiddenException({ code: 'registration_closed' });
}
let user: User;
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.
const user = await this.users.createUser(input);
await this.sendVerificationMail(user);
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 { 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 { FileStorageService } from './file-storage.service';
@ -74,7 +74,7 @@ describe.skipIf(!hasTestDb)('attachment integrity (e2e, issue #199)', () => {
await prisma.attachment.deleteMany({ where: { pondId } });
const where = { pond: { owner: { username: { contains: suffix } } } };
await prisma.roleGrant.deleteMany({ where });
await deletePondsWhere(prisma, { owner: { username: { contains: suffix } } });
await prisma.pond.deleteMany({ where: { owner: { username: { contains: suffix } } } });
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
await prisma.$disconnect();
await app.close();
@ -90,16 +90,14 @@ describe.skipIf(!hasTestDb)('attachment integrity (e2e, issue #199)', () => {
const { id, bytes } = await uploadPng('will-be-tampered');
// Intact: the download succeeds and streams the exact bytes.
const intact = await files.download(null, id, { actorId: null, sessionKey: 'anon' });
const intact = await files.download(null, id);
const chunks: Buffer[] = [];
for await (const chunk of intact.stream) chunks.push(chunk as Buffer);
expect(Buffer.concat(chunks).equals(bytes)).toBe(true);
// Tampered on disk (row untouched): fail closed with the dedicated code.
await storage.save(pondId, id, pngBuffer('evil-replacement'));
const failure = await files
.download(null, id, { actorId: null, sessionKey: 'anon' })
.catch((error: unknown) => error);
const failure = await files.download(null, id).catch((error: unknown) => error);
expect(failure).toBeInstanceOf(InternalServerErrorException);
expect((failure as InternalServerErrorException).getResponse()).toMatchObject({
code: 'attachment_integrity_failure',
@ -126,10 +124,7 @@ describe.skipIf(!hasTestDb)('attachment integrity (e2e, issue #199)', () => {
await storage.delete(pondId, unreadable.id);
// A null-hash row is served unverified (pre-#199 status quo).
const unverified = await files.download(null, readable.id, {
actorId: null,
sessionKey: 'anon',
});
const unverified = await files.download(null, readable.id);
expect(unverified.attachment.sha256).toBeNull();
const first = await files.backfillHashes();

View File

@ -27,7 +27,6 @@ import {
RequiresPagePermission,
RequiresPondRole,
} from '../permissions/permission.decorators';
import { readActorOf } from '../read-trail/read-actor';
import { FilesService } from './files.service';
@ -94,7 +93,6 @@ export class FilesController {
const { attachment, stream, inline, downloadName } = await this.files.download(
request.user ?? null,
fileId,
readActorOf(request),
);
response.set('X-Content-Type-Options', 'nosniff');
// 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 { PrismaService } from '../prisma/prisma.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 { FileStorageService } from './file-storage.service';
@ -63,7 +62,6 @@ export class FilesService {
private readonly storage: FileStorageService,
private readonly settings: InstanceSettingsService,
private readonly audit: AuditService,
private readonly readTrail: ReadTrailService,
private readonly logger: PinoLogger,
) {
this.logger.setContext(FilesService.name);
@ -232,7 +230,7 @@ export class FilesService {
* the nightly backfill reaches them) are served unverified that is the
* pre-#199 status quo, not a downgrade.
*/
async download(_user: User | null, id: string, read: ReadActor): Promise<FileDownload> {
async download(_user: User | null, id: string): Promise<FileDownload> {
const attachment = await this.prisma.attachment.findFirst({ where: { id } });
if (!attachment) throw new NotFoundException();
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);
// 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 {
attachment,
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
* personal pond, and scope/subject must exist here. Rejects duplicates.
*/
async createGrant(
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> {
async createGrant(user: User, pondId: string, grant: Grant): Promise<GrantView> {
const pond = await this.requireLivePond(pondId);
const invalid = grantValidationError(grant, {
@ -176,7 +169,7 @@ export class GrantsService {
if (existing) throw new ConflictException({ code: 'grant_exists' });
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.audit.record({
@ -192,7 +185,6 @@ export class GrantsService {
scope: grant.scopeType,
scopeId: grant.scopeId,
effect: grant.effect,
...(options.origin === 'idp' ? { origin: 'idp_mapping' } : {}),
},
});
return GrantsService.viewOf(created);
@ -203,12 +195,7 @@ export class GrantsService {
* grant is protected deleting it would leave the pond unmanageable
* (only a Site Admin could recover it).
*/
async deleteGrant(
user: User,
pondId: string,
grantId: string,
options: { origin?: 'manual' | 'idp' } = {},
): Promise<void> {
async deleteGrant(user: User, pondId: string, grantId: string): Promise<void> {
const grant = await this.prisma.roleGrant.findFirst({ where: { id: grantId, pondId } });
if (!grant) throw new NotFoundException();
@ -226,12 +213,7 @@ export class GrantsService {
actorId: user.id,
targetType: 'pond',
targetId: pondId,
details: {
grantId,
subjectId: grant.subjectId,
role: grant.role,
...(options.origin === 'idp' ? { origin: 'idp_mapping' } : {}),
},
details: { grantId, subjectId: grant.subjectId, role: grant.role },
});
}

View File

@ -1,12 +1,10 @@
import deErrors from '@dorfteich/shared/i18n/de/errors.json';
import deLegal from '@dorfteich/shared/i18n/de/legal.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 enErrors from '@dorfteich/shared/i18n/en/errors.json';
import enLegal from '@dorfteich/shared/i18n/en/legal.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 { createInstance, type i18n as I18n } from 'i18next';
@ -19,8 +17,8 @@ export const apiI18n: I18n = createInstance();
void apiI18n.init({
resources: {
en: { errors: enErrors, mails: enMails, legal: enLegal, tasks: enTasks, ponds: enPonds },
de: { errors: deErrors, mails: deMails, legal: deLegal, tasks: deTasks, ponds: dePonds },
en: { errors: enErrors, mails: enMails, legal: enLegal, tasks: enTasks },
de: { errors: deErrors, mails: deMails, legal: deLegal, tasks: deTasks },
},
fallbackLng: '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 { 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 { 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.
const where = { pond: { owner: { username: { contains: suffix } } } };
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.$disconnect();
await app.close();

View File

@ -95,14 +95,7 @@ export class DataExportService implements DataExportProcessor {
orderBy: { slug: 'asc' },
});
for (const pond of ponds) {
// The build runs in the conversion worker, outside any request — the
// 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 this.exports.appendPondMarkdown(archive, user, pond, `ponds/${pond.slug}/`);
}
await archive.finalize();

View File

@ -1,21 +1,12 @@
import { Body, Controller, Get, Param, Post, Req, Res, UseGuards } from '@nestjs/common';
import {
ConversionJobView,
PageExportInput,
PondArchivePreview,
pageExportInputSchema,
} from '@dorfteich/shared';
import { Body, Controller, Get, Param, Post, Req, Res } from '@nestjs/common';
import { ConversionJobView, PageExportInput, pageExportInputSchema } from '@dorfteich/shared';
import type { Response } from 'express';
import { AuthedRequest } from '../auth/auth.guard';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
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 { PondArchiveService } from './pond-archive.service';
/**
* 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()
export class ExportController {
constructor(
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);
}
constructor(private readonly exports: ExportService) {}
/** 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,
@ -70,7 +27,7 @@ export class ExportController {
@Req() request: AuthedRequest,
@Res() response: Response,
): 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
@ -82,42 +39,6 @@ export class ExportController {
@Body(new ZodValidationPipe(pageExportInputSchema)) input: PageExportInput,
@Req() request: AuthedRequest,
): Promise<ConversionJobView> {
return this.exports.enqueuePageExport(
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);
return this.exports.enqueuePageExport(request.user!, pageId, input.format);
}
}

View File

@ -6,7 +6,6 @@ import {
ConversionJobView,
ExportFormat,
PondFonts,
customFontEntries,
fontSlug,
PageClassification,
classificationMarking,
@ -21,12 +20,10 @@ import { PinoLogger } from 'nestjs-pino';
import { AppConfig } from '../config/app-config.service';
import { FileStorageService } from '../files/file-storage.service';
import { CustomFontsService } from '../fonts/custom-fonts.service';
import { PermissionService } from '../permissions/permission.service';
import { PluginFallbackRenderer } from '../plugins/plugin-fallback-renderer';
import { PluginsService } from '../plugins/plugins.service';
import { PrismaService } from '../prisma/prisma.service';
import { ReadTrailService, type ReadActor } from '../read-trail/read-trail.service';
import { markClassifiedMarkdown } from './classified-markdown';
import { ConversionJobService } from './conversion-job.service';
@ -55,8 +52,6 @@ export class ExportService {
private readonly plugins: PluginsService,
private readonly fallbacks: PluginFallbackRenderer,
private readonly config: AppConfig,
private readonly customFonts: CustomFontsService,
private readonly readTrail: ReadTrailService,
private readonly logger: PinoLogger,
) {
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
* they may actually read.
*/
async streamPondMarkdownZip(
user: User,
pondId: string,
res: Response,
read: ReadActor,
): Promise<void> {
async streamPondMarkdownZip(user: User, pondId: string, res: Response): Promise<void> {
const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } });
if (!pond) throw new NotFoundException();
@ -87,7 +77,7 @@ export class ExportService {
res.destroy(error);
});
archive.pipe(res);
await this.appendPondMarkdown(archive, user, pond, '', read);
await this.appendPondMarkdown(archive, user, pond);
await archive.finalize();
}
@ -103,8 +93,7 @@ export class ExportService {
archive: archiver.Archiver,
user: User,
pond: { id: string; slug: string },
prefix: string,
read: ReadActor,
prefix = '',
): Promise<void> {
const pondId = pond.id;
const pages = await this.prisma.page.findMany({
@ -124,21 +113,6 @@ export class ExportService {
const readablePages = pages.filter((page) => readableIds.has(page.id));
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
// still exist in this pond, so the media directory matches the rewrites.
const referenced = new Set<string>();
@ -248,18 +222,14 @@ export class ExportService {
user: User,
pageId: string,
format: ExportFormat,
read: ReadActor,
): 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({
where: { id: pageId, deletedAt: null },
include: { contentCache: { select: { markdown: true } } },
});
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
// 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
* 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 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> {
private async enqueuePdfExport(user: User, pageId: string): Promise<ConversionJobView> {
const page = await this.prisma.page.findFirst({
where: { id: pageId, deletedAt: null },
include: {
@ -324,7 +274,6 @@ export class ExportService {
},
});
if (!page) throw new NotFoundException();
await this.recordClassifiedExport(page, read, 'pdf');
const fonts = pondSettingsSchema.parse(page.pond.settings ?? {}).fonts;
// Plugin blocks first become their best static form (#79: stored SVG
@ -336,9 +285,6 @@ export class ExportService {
pondName: page.pond.name,
bodyHtml,
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),
// Styled sections keep their look in the PDF (#75); a pond without
// 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
* come from the directory baked into the image (ADR 0016); operator-uploaded
* ones from `CUSTOM_FONTS_DIR` (issue #303) same on-disk layout, so only
* the base directory differs. A font file that is absent (a native dev run
* 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. */
/** Base64 `@font-face` rules for the pond's three fonts, read from the
* catalog baked into the image (ADR 0016). A font file that is absent (a
* native dev run without `FONTS_DIR` populated) is skipped the render falls
* back to the system stack rather than failing. */
private async fontFaceCss(fonts: PondFonts): Promise<string> {
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.
const seen = new Set<string>();
const faces: string[] = [];
@ -396,10 +336,8 @@ export class ExportService {
const key = `${slot.family}:${slot.weight}`;
if (seen.has(key)) continue;
seen.add(key);
const customSlug = customSlugs.get(slot.family);
const slug = customSlug ?? fontSlug(slot.family);
const baseDir = customSlug ? this.config.env.CUSTOM_FONTS_DIR : this.config.env.FONTS_DIR;
const file = join(baseDir, slug, `${slug}-${slot.weight}.woff2`);
const slug = fontSlug(slot.family);
const file = join(this.config.env.FONTS_DIR, slug, `${slug}-${slot.weight}.woff2`);
try {
const bytes = await readFile(file);
faces.push(
@ -407,7 +345,7 @@ export class ExportService {
` src: url('data:font/woff2;base64,${bytes.toString('base64')}') format('woff2'); }`,
);
} 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');

View File

@ -2,7 +2,6 @@ import { Module, OnModuleInit } from '@nestjs/common';
import { CommonModule } from '../common/common.module';
import { FilesModule } from '../files/files.module';
import { FontsModule } from '../fonts/fonts.module';
import { LabelsModule } from '../labels/labels.module';
import { PagesModule } from '../pages/pages.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 { DataExportController } from './data-export.controller';
import { DataExportService } from './data-export.service';
import { ExportController, PondArchiveAdminController } from './export.controller';
import { ExportController } from './export.controller';
import { ExportService } from './export.service';
import { GotenbergHttpRenderer, GotenbergRenderer } from './gotenberg.renderer';
import { IMPORT_PROCESSOR } from './import.constants';
@ -23,7 +22,6 @@ import { ImportController } from './import.controller';
import { ImportService } from './import.service';
import { JobsController } from './jobs.controller';
import { PandocConverter, PandocServerConverter } from './pandoc.converter';
import { PondArchiveService } from './pond-archive.service';
/** 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. */
@ -42,26 +40,18 @@ const PAYLOAD_PRUNE_CADENCE_SECONDS = 24 * 60 * 60;
imports: [
CommonModule,
FilesModule,
FontsModule,
LabelsModule,
PagesModule,
PluginsModule,
SchedulerModule,
SettingsModule,
],
controllers: [
JobsController,
ImportController,
ExportController,
PondArchiveAdminController,
DataExportController,
],
controllers: [JobsController, ImportController, ExportController, DataExportController],
providers: [
ConversionJobService,
ConversionWorker,
ImportService,
ExportService,
PondArchiveService,
DataExportService,
// 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).

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 {
title: string;
@ -8,12 +8,6 @@ export interface PdfHtmlParams {
fonts: PondFonts;
/** Pre-built `@font-face` rules (base64 WOFF2) for the pond's fonts. */
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
* at install time (scoped selectors, no external fetches, no `</style>`).
* 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 {
const { fonts } = params;
const extra = params.customFonts ?? [];
return `<!doctype html>
<html lang="en">
<head>
@ -51,9 +44,9 @@ export function buildPdfHtml(params: PdfHtmlParams): string {
${params.fontFaceCss}
@page { size: A4; }
:root {
--font-heading: ${fontStack(fonts.heading.family, extra)};
--font-body: ${fontStack(fonts.body.family, extra)};
--font-mono: ${fontStack(fonts.mono.family, extra)};
--font-heading: ${fontStack(fonts.heading.family)};
--font-body: ${fontStack(fonts.body.family)};
--font-mono: ${fontStack(fonts.mono.family)};
}
html { font-size: 11pt; }
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';
export type MailTemplate = 'verifyEmail' | 'resetPassword' | 'smtpTest' | 'invitation';
export type MailTemplate = 'verifyEmail' | 'resetPassword' | 'smtpTest';
export interface RenderedMail {
subject: string;
@ -15,15 +15,14 @@ export interface RenderedMail {
*/
export function renderMail(
template: MailTemplate,
// Extra keys (e.g. inviterName, #332) interpolate into the body text.
params: { displayName: string; link: string } & Record<string, string>,
params: { displayName: string; link: string },
locale: 'de' | 'en',
): RenderedMail {
const t = (key: string, options: Record<string, string> = {}): string =>
apiI18n.t(`mails:${key}`, { lng: locale, ...options });
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 expiry = t(`${template}.expiry`);
const ignore = t('common.ignoreHint');

View File

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

View File

@ -112,9 +112,7 @@ export class McpService {
case 'read_page':
await this.assertPondExposed(input.pond!, token);
await this.requirePage(user, input.pond!, input.page!, 'read');
// The shared getPage also records the read-trail event (#222) —
// MCP reads of classified pages are covered by the same emission.
return asJson(await this.publicApi.getPage(user, token, input.pond!, input.page!));
return asJson(await this.publicApi.getPage(user, input.pond!, input.page!));
case 'search': {
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 { 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';
/**
@ -97,7 +97,7 @@ describe.skipIf(!hasTestDb)('pond members (e2e, issue #54)', () => {
const ids = Object.values(userIds);
await prisma.roleGrant.deleteMany({ where: { pond: { ownerId: { in: ids } } } });
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.$disconnect();
await app.close();

View File

@ -3,7 +3,11 @@ import { createHmac } from 'node:crypto';
import { deriveTokenKey } from '@dorfteich/shared/token-crypto';
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 TTL_MS = 90 * 24 * 60 * 60 * 1000;
@ -46,15 +50,23 @@ describe('unsubscribe token', () => {
expect(verifyUnsubscribeToken(`${body}.${sig}`, secret)).toBeNull();
});
it('rejects pre-separation legacy tokens — the dual-verify window is gone (#296)', () => {
const now = Date.now();
// Unexpired on its own terms — rejected because only the subkey verifies.
expect(verifyUnsubscribeToken(legacyToken('u1', now - 1000), secret, now)).toBeNull();
it('accepts a pre-separation legacy token inside the dual-verify window', () => {
const inWindow = LEGACY_VERIFY_UNTIL - 24 * 60 * 60 * 1000;
expect(verifyUnsubscribeToken(legacyToken('u1', inWindow - TTL_MS / 2), secret, inWindow)).toBe(
'u1',
);
});
it('verifies freshly minted tokens via the subkey', () => {
const now = Date.now();
const token = signUnsubscribeToken('u1', secret, now);
expect(verifyUnsubscribeToken(token, secret, now + 1000)).toBe('u1');
it('rejects a legacy token once the dual-verify window has closed', () => {
const afterWindow = LEGACY_VERIFY_UNTIL + 1000;
// Unexpired on its own terms — rejected purely because the window closed.
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;
// The pre-#188 dual-verify window (root secret + `digest-unsubscribe.`
// prefix) was removed EARLY by operator decision at the ADR 0020
// acceptance (issue #296): links in mails sent before the key separation
// no longer work — recipients use the in-app notification settings.
// Verification is subkey-only; the regression test pins that the legacy
// derivation can never verify again.
/**
* Dual-verify window (#188, ADR 0020): before the key separation, tokens
* were HMACed with the root secret over a `digest-unsubscribe.` prefix.
* Those links live in digest mails that are already sent and stay valid
* for their full 90-day TTL, so verification accepts the legacy derivation
* 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 {
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 {
const body = Buffer.from(
JSON.stringify({ userId, exp: Math.floor(now / 1000) + TTL_SECONDS }),
@ -50,7 +59,10 @@ export function verifyUnsubscribeToken(
} catch {
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 {
const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) as {
userId?: string;

View File

@ -39,7 +39,6 @@ import {
RequiresPagePermission,
RequiresPondRole,
} from '../permissions/permission.decorators';
import { readActorOf } from '../read-trail/read-actor';
import { PagesService } from './pages.service';
/** Page CRUD and Yjs state persistence (issue #23; permission guard since #52). */
@ -76,7 +75,7 @@ export class PagesController {
@Get('pages/:id')
@RequiresPagePermission('read', { idParam: 'id' })
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
@ -107,7 +106,7 @@ export class PagesController {
@Param('id') id: string,
@Req() request: AuthedRequest,
): 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`. */
@ -118,11 +117,7 @@ export class PagesController {
@Req() request: AuthedRequest,
@Res({ passthrough: true }) response: Response,
): Promise<string> {
const { slug, markdown } = await this.pages.exportMarkdown(
request.user!,
id,
readActorOf(request),
);
const { slug, markdown } = await this.pages.exportMarkdown(request.user!, id);
response.set('Content-Type', 'text/markdown; charset=utf-8');
response.set('Content-Disposition', `attachment; filename="${slug}.md"`);
return markdown;
@ -138,7 +133,7 @@ export class PagesController {
@Param('slug') slug: string,
@Req() request: AuthedRequest,
): 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 { PondsModule } from '../ponds/ponds.module';
import { WatchesModule } from '../watches/watches.module';
import { SearchModule } from '../search/search.module';
@ -9,7 +10,7 @@ import { PluginApiController } from './plugin-api.controller';
import { TasksService } from './tasks.service';
@Module({
imports: [SearchModule, WatchesModule],
imports: [PondsModule, SearchModule, WatchesModule],
controllers: [PagesController, PluginApiController],
providers: [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 { PermissionService } from '../permissions/permission.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 { WatchesService } from '../watches/watches.service';
import { SearchProvider } from '../search/search.provider';
@ -85,29 +80,10 @@ export class PagesService {
private readonly watches: WatchesService,
private readonly settings: InstanceSettingsService,
private readonly audit: AuditService,
private readonly readTrail: ReadTrailService,
) {
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 {
return {
id: page.id,
@ -424,9 +400,8 @@ export class PagesService {
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);
await this.recordRead(page, read, 'page_view');
return this.stateViewOf(page);
}
@ -440,21 +415,12 @@ export class PagesService {
* guard has already granted read access via a `public` grant, so they receive
* an `ro` token with a `null` subject.
*/
async issueCollabToken(
user: User | null,
id: string,
read: ReadActor,
): Promise<CollabTokenResponse> {
async issueCollabToken(user: User | null, id: string): Promise<CollabTokenResponse> {
const page = await this.findLivePage(id);
const canWrite = await this.permissions.canAccessPage(user, page, 'write');
const mode = canWrite ? 'rw' : 'ro';
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(
{ userId, pageId: page.id, mode },
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. */
async getStateBySlug(
_user: User,
pondId: string,
slug: string,
read: ReadActor,
): Promise<PageStateView> {
async getStateBySlug(_user: User, pondId: string, slug: string): Promise<PageStateView> {
const page = await this.prisma.page.findFirst({ where: { pondId, slug, deletedAt: null } });
if (!page) throw new NotFoundException();
await this.recordRead(page, read, 'page_view');
return this.stateViewOf(page);
}
@ -688,16 +648,8 @@ export class PagesService {
* `page_content_cache.markdown` (refreshed on every state save, #23)
* rather than re-decoding the Yjs state, so export always matches what
* the app itself considers the page's current Markdown representation. */
async exportMarkdown(
_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 }> {
async exportMarkdown(_user: User, id: string): Promise<{ slug: string; markdown: string }> {
const page = await this.findLivePage(id);
await this.recordRead(page, read, channel);
const cache = await this.prisma.pageContentCache.findUnique({ where: { pageId: page.id } });
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 { RequiresPagePermission, RequiresPondRole } from '../permissions/permission.decorators';
import { readActorOf } from '../read-trail/read-actor';
import { PagesService } from './pages.service';
@ -46,12 +45,7 @@ export class PluginApiController {
@Param('pageId') pageId: string,
@Req() request: AuthedRequest,
): Promise<PluginPageContent> {
const { markdown } = await this.pages.exportMarkdown(
request.user!,
pageId,
readActorOf(request),
'page_view',
);
const { markdown } = await this.pages.exportMarkdown(request.user!, pageId);
return { markdown };
}

View File

@ -5,7 +5,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { PondsService } from '../ponds/ponds.service';
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';
/**
@ -23,7 +23,6 @@ describe.skipIf(!hasTestDb)('plugin API (e2e, issue #74)', () => {
const userIds: Record<string, string> = {};
const cookies: Record<string, string> = {};
let pondId: string;
let startPageId: string;
let openPageId: string;
let secretPageId: string;
@ -72,10 +71,6 @@ describe.skipIf(!hasTestDb)('plugin API (e2e, issue #74)', () => {
.send({ name: `PlugApi Pond ${suffix}` })
.expect(201);
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()
.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.pageUpdate.deleteMany({ where: { page: { pondId } } });
await prisma.page.deleteMany({ where: { pondId } });
await deletePondsWhere(prisma, { id: pondId });
await prisma.pond.deleteMany({ where: { id: pondId } });
const ids = Object.values(userIds);
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.user.deleteMany({ where: { id: { in: ids } } });
await prisma.$disconnect();
@ -149,7 +144,7 @@ describe.skipIf(!hasTestDb)('plugin API (e2e, issue #74)', () => {
.set('Cookie', cookies.owner!)
.expect(200);
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) });
// Label *names* travel with each summary (issue #77, page-index filter).

View File

@ -26,56 +26,48 @@ describe('sort-key helpers (issue #45)', () => {
* pattern repeatedly drop the last page between the first two must never
* collide and never overflow the key length, because the caller rebalances
* 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(
'10.000 adversarial reorders never collide or overflow (rebalance verified)',
{ timeout: 30_000 },
() => {
// Start with five pages in a fixed order.
let order = evenlySpacedKeys(5).map((key, i) => ({ id: `p${i}`, key }));
let rebalances = 0;
it('10.000 adversarial reorders never collide or overflow (rebalance verified)', () => {
// Start with five pages in a fixed order.
let order = evenlySpacedKeys(5).map((key, i) => ({ id: `p${i}`, key }));
let rebalances = 0;
const rebalance = (): void => {
const keys = evenlySpacedKeys(order.length);
order = order.map((page, i) => ({ ...page, key: keys[i]! }));
rebalances += 1;
};
const rebalance = (): void => {
const keys = evenlySpacedKeys(order.length);
order = order.map((page, i) => ({ ...page, key: keys[i]! }));
rebalances += 1;
};
for (let i = 0; i < 10_000; i += 1) {
// Move the last page to sit between the first and second — the tightest
// possible gap, which is what grows key length fastest.
const moved = order[order.length - 1]!;
const rest = order.slice(0, -1);
const afterKey = rest[0]!.key;
const beforeKey = rest[1]!.key;
for (let i = 0; i < 10_000; i += 1) {
// Move the last page to sit between the first and second — the tightest
// possible gap, which is what grows key length fastest.
const moved = order[order.length - 1]!;
const rest = order.slice(0, -1);
const afterKey = rest[0]!.key;
const beforeKey = rest[1]!.key;
const key = nextKeyOrRebalance(afterKey, beforeKey);
if (key === null) {
// Rebalance keeps the CURRENT order, then retry the move once.
rebalance();
const k2 = nextKeyOrRebalance(order[0]!.key, order[1]!.key);
expect(k2).not.toBeNull();
order = [order[0]!, { ...moved, key: k2! }, ...order.slice(1)];
} else {
order = [rest[0]!, { ...moved, key }, ...rest.slice(1)];
}
// Invariants after every move: keys unique, bounded, and consistent with
// the intended array order.
const keys = order.map((p) => p.key);
expect(new Set(keys).size).toBe(keys.length);
expect(Math.max(...keys.map((k) => k.length))).toBeLessThanOrEqual(MAX_SORT_KEY_LENGTH);
for (let j = 1; j < keys.length; j += 1) {
expect(keys[j - 1]! < keys[j]!).toBe(true);
}
const key = nextKeyOrRebalance(afterKey, beforeKey);
if (key === null) {
// Rebalance keeps the CURRENT order, then retry the move once.
rebalance();
const k2 = nextKeyOrRebalance(order[0]!.key, order[1]!.key);
expect(k2).not.toBeNull();
order = [order[0]!, { ...moved, key: k2! }, ...order.slice(1)];
} else {
order = [rest[0]!, { ...moved, key }, ...rest.slice(1)];
}
// The adversarial pattern must have forced at least one rebalance.
expect(rebalances).toBeGreaterThan(0);
},
);
// Invariants after every move: keys unique, bounded, and consistent with
// the intended array order.
const keys = order.map((p) => p.key);
expect(new Set(keys).size).toBe(keys.length);
expect(Math.max(...keys.map((k) => k.length))).toBeLessThanOrEqual(MAX_SORT_KEY_LENGTH);
for (let j = 1; j < keys.length; j += 1) {
expect(keys[j - 1]! < keys[j]!).toBe(true);
}
}
// The adversarial pattern must have forced at least one rebalance.
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 { 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';
/**
@ -120,11 +120,11 @@ describe.skipIf(!hasTestDb)('permission enforcement (e2e, issue #52)', () => {
await prisma.pageContentCache.deleteMany({ where: { page: { pondId } } });
await prisma.pageUpdate.deleteMany({ where: { page: { 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.
const ids = Object.values(userIds);
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.user.deleteMany({ where: { id: { in: ids } } });
await prisma.$disconnect();

View File

@ -2,7 +2,6 @@ import {
BadRequestException,
Body,
ConflictException,
ForbiddenException,
Controller,
Delete,
Get,
@ -41,10 +40,6 @@ function toHttpException(error: PluginPackageError): HttpException {
case 'plugin_version_not_higher':
case 'plugin_not_optional':
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':
return new PayloadTooLargeException(body);
default:

View File

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

View File

@ -1,5 +1,3 @@
import { createHash } from 'node:crypto';
import { Injectable } from '@nestjs/common';
import { Plugin, PluginInstanceMode as DbPluginMode, Prisma, User } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino';
@ -14,7 +12,6 @@ import type {
import { ClockService } from '../common/clock.service';
import { AuditService } from '../audit/audit.service';
import { PrismaService } from '../prisma/prisma.service';
import { InstanceSettingsService } from '../settings/instance-settings.service';
import { PluginPackageService } from './plugin-package.service';
import { PluginStorageService } from './plugin-storage.service';
@ -47,7 +44,6 @@ export class PluginsService {
private readonly storage: PluginStorageService,
private readonly clock: ClockService,
private readonly audit: AuditService,
private readonly settings: InstanceSettingsService,
private readonly logger: PinoLogger,
) {
this.logger.setContext(PluginsService.name);
@ -62,32 +58,6 @@ export class PluginsService {
/** `actor` is absent for dropzone installs (watcher, no session). */
async install(zip: Buffer, actor?: User): Promise<PluginView> {
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 isActiveUpdate = existing !== null && existing.removedAt === null;
@ -111,7 +81,6 @@ export class PluginsService {
apiVersion: manifest.apiVersion,
kind: manifest.kind,
manifest: manifest as unknown as Prisma.InputJsonValue,
bundleHash,
},
update: {
name: manifest.name,
@ -119,7 +88,6 @@ export class PluginsService {
apiVersion: manifest.apiVersion,
kind: manifest.kind,
manifest: manifest as unknown as Prisma.InputJsonValue,
bundleHash,
// Reinstalling a previously removed plugin clears the tombstone.
removedAt: null,
},
@ -137,7 +105,7 @@ export class PluginsService {
targetId: manifest.id,
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,
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 } }),
]);
const enabled = new Map(activations.map((a) => [a.pluginId, a.enabled]));
const allowlist = await this.settings.get('plugins.allowlist');
return (
plugins
.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))
);
return plugins
.filter((p) => p.mode === 'REQUIRED' || (p.mode === 'OPTIONAL' && enabled.get(p.id) === true))
.map((p) => this.toView(p));
}
/**
@ -260,11 +219,7 @@ export class PluginsService {
this.prisma.pondPlugin.findMany({ where: { pondId } }),
]);
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, allowlist),
enabled: enabled.get(p.id) === true,
}));
return plugins.map((p) => ({ plugin: this.toView(p), enabled: enabled.get(p.id) === true }));
}
/**
@ -332,66 +287,19 @@ export class PluginsService {
where: { removedAt: null },
orderBy: { name: 'asc' },
});
const allowlist = await this.settings.get('plugins.allowlist');
return plugins.map((plugin) => this.toView(plugin, allowlist));
return plugins.map((plugin) => this.toView(plugin));
}
/** One installed plugin, or `null` if absent/removed. */
async get(id: string): Promise<PluginView | null> {
const plugin = await this.prisma.plugin.findUnique({ where: { id } });
if (!plugin || plugin.removedAt !== null) return null;
return this.toView(plugin, await this.settings.get('plugins.allowlist'));
return this.toView(plugin);
}
/**
* 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 {
private toView(plugin: Plugin): PluginView {
const manifest = plugin.manifest as unknown as PluginManifest;
return {
bundleSha256: plugin.bundleHash,
pinning: this.verdict(plugin, allowlist),
id: plugin.id,
name: plugin.name,
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 { 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 { PondAccessNotifier } from './pond-access-notifier.service';
@ -81,7 +81,7 @@ describe.skipIf(!hasTestDb)('ponds (e2e, issue #21)', () => {
await prisma.quotaOverride.deleteMany({
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.$disconnect();
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);
});
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 () => {
const name = `Gartenteich ${suffix}`;
const first = await api()

View File

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

View File

@ -9,8 +9,6 @@ import {
import { Pond, Prisma, User } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino';
import { apiI18n } from '../i18n/api-i18n';
import { PagesService } from '../pages/pages.service';
import { PermissionService } from '../permissions/permission.service';
import { PrismaService } from '../prisma/prisma.service';
import { QuotaService } from '../quotas/quota.service';
@ -25,52 +23,11 @@ export class PondsService {
private readonly quotas: QuotaService,
private readonly accessNotifier: PondAccessNotifier,
private readonly search: SearchProvider,
private readonly pages: PagesService,
private readonly logger: PinoLogger,
) {
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 {
return {
id: pond.id,
@ -148,12 +105,7 @@ export class PondsService {
return created;
});
this.logger.info({ pondId: pond.id, ownerId: owner.id }, 'audit: pond created');
await this.createStartPage(owner, pond.id);
// 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);
return this.viewOf(pond);
}
/**
@ -176,7 +128,6 @@ export class PondsService {
return 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[]> {
@ -206,8 +157,7 @@ export class PondsService {
input.commentPolicy !== undefined ||
input.apiEnabled !== undefined ||
input.mcpEnabled !== undefined ||
input.theme !== undefined ||
input.startPageId !== undefined;
input.theme !== undefined;
const settings = !settingsChanged
? undefined
: {
@ -219,7 +169,6 @@ export class PondsService {
...(input.apiEnabled !== undefined ? { apiEnabled: input.apiEnabled } : {}),
...(input.mcpEnabled !== undefined ? { mcpEnabled: input.mcpEnabled } : {}),
...(input.theme !== undefined ? { theme: input.theme } : {}),
...(input.startPageId !== undefined ? { startPageId: input.startPageId } : {}),
};
const updated = await this.prisma.pond.update({
where: { id },

View File

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

View File

@ -38,7 +38,6 @@ import { PagesService } from '../pages/pages.service';
import { PermissionService } from '../permissions/permission.service';
import { PondsService } from '../ponds/ponds.service';
import { PrismaService } from '../prisma/prisma.service';
import { ReadTrailService } from '../read-trail/read-trail.service';
import { SearchProvider } from '../search/search.provider';
import { VersionsService } from '../versions/versions.service';
import { ApiTokensService } from './api-tokens.service';
@ -64,7 +63,6 @@ export class PublicApiService {
private readonly tokens: ApiTokensService,
private readonly audit: AuditService,
private readonly permissions: PermissionService,
private readonly readTrail: ReadTrailService,
) {}
async me(user: User, token: ApiToken): Promise<PublicMeView> {
@ -121,24 +119,8 @@ export class PublicApiService {
}));
}
async getPage(
user: User,
token: ApiToken,
pondSlug: string,
pageSlug: string,
): Promise<PublicPageView> {
async getPage(user: User, pondSlug: string, pageSlug: string): Promise<PublicPageView> {
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([
this.prisma.pageContentCache.findUnique({ where: { pageId: page.id } }),
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 page = await this.pages.createWithState(user, pond.id, input.title, state, parentId);
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, token, pondSlug, page.slug);
return this.getPage(user, pondSlug, page.slug);
}
/**
@ -205,7 +186,7 @@ export class PublicApiService {
await this.pages.moveToEnd(user, page.id, parentId);
}
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> {

View File

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

View File

@ -8,18 +8,9 @@ import { TasksService } from '../pages/tasks.service';
import { PermissionService } from '../permissions/permission.service';
import { PluginFallbackRenderer } from '../plugins/plugin-fallback-renderer';
import { PrismaService } from '../prisma/prisma.service';
import { ReadTrailService, type ReadActor } from '../read-trail/read-trail.service';
import { InstanceSettingsService } from '../settings/instance-settings.service';
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. */
export interface PublicPageContent {
pondName: string;
@ -62,7 +53,6 @@ export class PublicService {
private readonly settings: InstanceSettingsService,
private readonly commentsService: CommentsService,
private readonly tasks: TasksService,
private readonly readTrail: ReadTrailService,
) {}
private async resolve(
@ -84,24 +74,8 @@ export class PublicService {
}
/** The page content for the read view (public and authenticated, issue #56). */
async content(
user: User | null,
pondSlug: string,
pageSlug: string,
read: ReadContext,
): Promise<PublicPageContent> {
async content(user: User | null, pondSlug: string, pageSlug: string): Promise<PublicPageContent> {
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 } });
// 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.
@ -109,7 +83,7 @@ export class PublicService {
// 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
// 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 {
pondName: pond.name,
pondSlug: pond.slug,
@ -135,12 +109,11 @@ export class PublicService {
page: { id: string },
depth: number,
visited: Set<string>,
read: ReadContext,
): Promise<string> {
const cache = await this.prisma.pageContentCache.findUnique({ where: { pageId: page.id } });
const withFallbacks = await this.fallbacks.applyToHtml(cache?.html ?? '');
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,
@ -170,7 +143,6 @@ export class PublicService {
pondId: string,
depth: number,
visited: Set<string>,
read: ReadContext,
): Promise<string> {
const placeholder =
/<div class="dt-transclusion" data-transclusion="([^"]+)"( data-transclusion-bare="1")?>[^<]*<\/div>/g;
@ -179,31 +151,18 @@ export class PublicService {
const bare = Boolean(bareAttr);
const target = await this.prisma.page.findFirst({
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'));
if (!target || !readable || depth >= PublicService.MAX_EMBED_DEPTH || visited.has(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(
user,
pondId,
target,
depth + 1,
new Set(visited).add(slug),
read,
);
// A bare embed (`$[[…]]`, #146) reads as part of the host page: no
// frame, no title — just the expanded content.
@ -234,13 +193,8 @@ export class PublicService {
pondSlug: string,
pageSlug: string,
canonical: string,
actor: ReadActor,
): Promise<string> {
// `content` records the read-trail event (#222) under the shell's channel.
const content = await this.content(user, pondSlug, pageSlug, {
actor,
channel: 'no_js_shell',
});
const content = await this.content(user, pondSlug, pageSlug);
// 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
// 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 { TasksService } from '../pages/tasks.service';
import { AuthenticatedOnly } from '../permissions/permission.decorators';
import { readActorOf } from '../read-trail/read-actor';
import { PublicPageContent, PublicService } from './public.service';
/**
@ -44,9 +43,6 @@ export class ReadContentController {
@Param('pageSlug') pageSlug: string,
@Req() request: AuthedRequest,
): Promise<PublicPageContent> {
return this.publicPages.content(request.user ?? null, pondSlug, pageSlug, {
actor: readActorOf(request),
channel: 'page_view',
});
return this.publicPages.content(request.user ?? null, pondSlug, pageSlug);
}
}

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