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
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
208 lines
7.6 KiB
TypeScript
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;
|
|
}
|
|
}
|