dorfteich/apps/api/src/versions/versions.service.ts
Claude Fable 5 0c6494f209
All checks were successful
CD / Build and push images (push) Successful in 2m54s
CI / Lint, typecheck, test (push) Successful in 2m25s
CI / Auth e2e pack (push) Successful in 2m58s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m12s
CD / Promote to Int (push) Successful in 12s
Enforce permissions in API guards and retire interim access (#52)
Every route now declares its access rule explicitly and is enforced
through the shared resolution algorithm (permissions.md):

- PermissionGuard + decorators (@RequiresPondRole, @RequiresPagePermission,
  @RequiresAttachmentPermission, @AuthenticatedOnly) applied to every
  route; a route-enumeration test proves full coverage alongside
  @Public()/Site-Admin-guarded routes.
- 404/403 policy (documented in README conventions): denied reads answer
  404 (existence hiding), denied writes on readable things answer 403;
  trash views need write capability (ADR 0013).
- PermissionService resolves page/pond questions via the shared resolver,
  with an in-process pond-context cache (grants + label parents) that is
  invalidated on every grant/label-tree change and TTL-bounded as a
  multi-process safety net. Grant changes also fire pond_access_changed
  for collab revalidation (#39/#53).
- shared: pond-scope resolution (hasPondRole, canSeePond) next to the
  page resolver; grant wire schemas + GrantView.
- Owner Pond-Admin grants: migration backfill for all existing ponds,
  created transactionally with every new pond (shared + personal + seed).
- Grant CRUD under /ponds/:id/grants (pond_admin-gated) with structural
  and referential validation, last-admin protection, audit logs.
- InterimAccessService deleted; page lists, search, backlinks, phantom
  links, and trash listings are filtered per page through the resolver;
  collab tokens are now truly ro for readers.
- Fixture-matrix e2e (reader/editor/pond admin/foreign, label-deny,
  authenticated-subject, revoke-then-immediate-deny cache test).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
2026-07-09 16:31:41 +02:00

208 lines
7.6 KiB
TypeScript

import { Injectable, NotFoundException } from '@nestjs/common';
import {
CreateVersionInput,
PAGE_RESTORE_CHANNEL,
PageRestoreRequest,
PageVersionContentView,
PageVersionTrigger,
PageVersionView,
} from '@dorfteich/shared';
import { Page, PageVersion, PageVersionTrigger as PrismaTrigger, User } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino';
import * as Y from 'yjs';
import { deriveContent } from '../pages/yjs-content';
import { PrismaService } from '../prisma/prisma.service';
/**
* Auto-versions older than this are thinned to one snapshot per day; everything
* within the window is kept in full (ADR 0013, default 90 days). Manual and
* pre-restore versions are intentional and never thinned.
*/
export const VERSION_RETENTION_DAYS = 90;
const TRIGGER_TO_VIEW: Record<PrismaTrigger, PageVersionTrigger> = {
AUTO: 'auto',
MANUAL: 'manual',
PRE_RESTORE: 'pre_restore',
};
/**
* Page version history (issue #41, ADR 0013). Named versions are created here,
* permission-gated; automatic versions are created by the collab server. Both
* consume the shared `page_pending_contributors` accumulator so a version's
* contributor set reflects who edited since the previous version. The daily
* thinning job keeps history bounded.
*/
@Injectable()
export class VersionsService {
constructor(
private readonly prisma: PrismaService,
private readonly logger: PinoLogger,
) {
this.logger.setContext(VersionsService.name);
}
viewOf(version: Omit<PageVersion, 'ydocSnapshot'>): PageVersionView {
return {
id: version.id,
pageId: version.pageId,
trigger: TRIGGER_TO_VIEW[version.trigger],
label: version.label,
createdBy: version.createdBy,
contributorIds: version.contributorIds,
createdAt: version.createdAt.toISOString(),
};
}
/**
* Load a live page. Viewing history requires the same permission as
* editing (ADR 0013) — the guard enforces write access on every history
* route (#52); here only existence is checked.
*/
private async findLivePage(pageId: string): Promise<Page> {
const page = await this.prisma.page.findFirst({ where: { id: pageId, deletedAt: null } });
if (!page) throw new NotFoundException();
return page;
}
/** The page's versions, newest first (no snapshot bytes). Write access only. */
async list(_user: User, pageId: string): Promise<PageVersionView[]> {
await this.findLivePage(pageId);
const versions = await this.prisma.pageVersion.findMany({
where: { pageId },
orderBy: { createdAt: 'desc' },
// Exclude the (potentially large) snapshot bytes from the list.
omit: { ydocSnapshot: true },
});
return versions.map((version) => this.viewOf(version));
}
/** A single version rendered read-only (HTML) with its Markdown for diffing. */
async getContent(
_user: User,
pageId: string,
versionId: string,
): Promise<PageVersionContentView> {
await this.findLivePage(pageId);
const version = await this.prisma.pageVersion.findFirst({
where: { id: versionId, pageId },
});
if (!version) throw new NotFoundException();
const derived = deriveContent(new Uint8Array(version.ydocSnapshot));
return { ...this.viewOf(version), html: derived.html, markdown: derived.markdown };
}
/**
* Restore the page to `versionId` (requires write access, ADR 0013). The
* permission check happens here; the collab server, which owns the live
* document, does the actual work: it snapshots the current state as a
* `PRE_RESTORE` version and applies the restored content as a normal edit so
* every open client converges. History is append-only — nothing is deleted.
* Returns the version being restored.
*/
async restore(user: User, pageId: string, versionId: string): Promise<PageVersionView> {
await this.findLivePage(pageId);
const version = await this.prisma.pageVersion.findFirst({
where: { id: versionId, pageId },
omit: { ydocSnapshot: true },
});
if (!version) throw new NotFoundException();
const payload: PageRestoreRequest = { pageId, versionId, userId: user.id };
await this.prisma
.$executeRaw`SELECT pg_notify(${PAGE_RESTORE_CHANNEL}, ${JSON.stringify(payload)})`;
this.logger.info(
{ event: 'audit: version restore requested', pageId, versionId, userId: user.id },
'version restore requested',
);
return this.viewOf(version);
}
/**
* Create a named version (requires write access, ADR 0013 / permissions.md).
* The snapshot is the page's current persisted state (base state plus the
* update log); it can lag the very latest live keystrokes by the collab
* store debounce, a deliberate simplification for a manual "save this state".
*/
async createNamed(
user: User,
pageId: string,
input: CreateVersionInput,
): Promise<PageVersionView> {
await this.findLivePage(pageId);
const snapshot = await this.reconstructSnapshot(pageId);
const created = await this.prisma.$transaction(async (tx) => {
// Consume the contributors accumulated since the previous version.
const pending = await tx.pagePendingContributor.findMany({ where: { pageId } });
const contributorIds = pending.map((row) => row.userId);
await tx.pagePendingContributor.deleteMany({ where: { pageId } });
return tx.pageVersion.create({
data: {
pageId,
ydocSnapshot: snapshot,
trigger: 'MANUAL',
label: input.label,
createdBy: user.id,
contributorIds,
},
});
});
this.logger.info(
{ event: 'audit: version created', pageId, versionId: created.id, userId: user.id },
'named version created',
);
return this.viewOf(created);
}
/** Reconstruct the page's full current Yjs state (base + update log). */
private async reconstructSnapshot(pageId: string): Promise<Uint8Array<ArrayBuffer>> {
const page = await this.prisma.page.findUniqueOrThrow({
where: { id: pageId },
select: { ydocState: true },
});
const updates = await this.prisma.pageUpdate.findMany({
where: { pageId },
orderBy: { seq: 'asc' },
select: { update: true },
});
const doc = new Y.Doc();
try {
Y.applyUpdate(doc, new Uint8Array(page.ydocState));
for (const row of updates) Y.applyUpdate(doc, new Uint8Array(row.update));
// A fresh copy: Prisma's Bytes input type is Uint8Array<ArrayBuffer>,
// which the ArrayBufferLike-typed encode result does not satisfy.
return new Uint8Array(Y.encodeStateAsUpdate(doc));
} finally {
doc.destroy();
}
}
/**
* Thin automatic versions older than the retention window down to the newest
* one per day, keeping every manual/pre-restore version and everything within
* the window (ADR 0013). Returns the number of versions removed.
*/
async thinDueVersions(): Promise<number> {
const result = await this.prisma.$executeRaw`
DELETE FROM page_versions v
WHERE v.trigger = 'AUTO'
AND v.created_at < now() - make_interval(days => ${VERSION_RETENTION_DAYS}::int)
AND EXISTS (
SELECT 1 FROM page_versions newer
WHERE newer.page_id = v.page_id
AND newer.trigger = 'AUTO'
AND newer.created_at < now() - make_interval(days => ${VERSION_RETENTION_DAYS}::int)
AND date_trunc('day', newer.created_at) = date_trunc('day', v.created_at)
AND newer.created_at > v.created_at
)`;
if (result > 0) {
this.logger.info({ event: 'version.thinning.run', removed: result }, 'thinned old versions');
}
return result;
}
}