#222: read-access trail for classified pages #278
@ -0,0 +1,23 @@
|
||||
-- #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");
|
||||
@ -91,6 +91,37 @@ 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.
|
||||
model ReadEvent {
|
||||
id String @id @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?
|
||||
|
||||
@@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
|
||||
|
||||
@ -32,6 +32,7 @@ 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';
|
||||
@ -47,6 +48,7 @@ import { VersionsModule } from './versions/versions.module';
|
||||
ConfigModule,
|
||||
PrismaModule,
|
||||
AuditModule,
|
||||
ReadTrailModule,
|
||||
RateLimitModule,
|
||||
MailModule,
|
||||
SettingsModule,
|
||||
|
||||
@ -90,14 +90,16 @@ 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);
|
||||
const intact = await files.download(null, id, { actorId: null, sessionKey: 'anon' });
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of intact.stream) chunks.push(chunk as Buffer);
|
||||
expect(Buffer.concat(chunks).equals(bytes)).toBe(true);
|
||||
|
||||
// Tampered on disk (row untouched): fail closed with the dedicated code.
|
||||
await storage.save(pondId, id, pngBuffer('evil-replacement'));
|
||||
const failure = await files.download(null, id).catch((error: unknown) => error);
|
||||
const failure = await files
|
||||
.download(null, id, { actorId: null, sessionKey: 'anon' })
|
||||
.catch((error: unknown) => error);
|
||||
expect(failure).toBeInstanceOf(InternalServerErrorException);
|
||||
expect((failure as InternalServerErrorException).getResponse()).toMatchObject({
|
||||
code: 'attachment_integrity_failure',
|
||||
@ -124,7 +126,10 @@ 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);
|
||||
const unverified = await files.download(null, readable.id, {
|
||||
actorId: null,
|
||||
sessionKey: 'anon',
|
||||
});
|
||||
expect(unverified.attachment.sha256).toBeNull();
|
||||
|
||||
const first = await files.backfillHashes();
|
||||
|
||||
@ -27,6 +27,7 @@ import {
|
||||
RequiresPagePermission,
|
||||
RequiresPondRole,
|
||||
} from '../permissions/permission.decorators';
|
||||
import { readActorOf } from '../read-trail/read-actor';
|
||||
|
||||
import { FilesService } from './files.service';
|
||||
|
||||
@ -93,6 +94,7 @@ 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.
|
||||
|
||||
@ -26,6 +26,7 @@ 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';
|
||||
@ -62,6 +63,7 @@ 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);
|
||||
@ -230,7 +232,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): Promise<FileDownload> {
|
||||
async download(_user: User | null, id: string, read: ReadActor): 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);
|
||||
@ -248,6 +250,19 @@ 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),
|
||||
|
||||
@ -95,7 +95,14 @@ export class DataExportService implements DataExportProcessor {
|
||||
orderBy: { slug: 'asc' },
|
||||
});
|
||||
for (const pond of ponds) {
|
||||
await this.exports.appendPondMarkdown(archive, user, pond, `ponds/${pond.slug}/`);
|
||||
// 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 archive.finalize();
|
||||
|
||||
@ -5,6 +5,7 @@ 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 { ExportService } from './export.service';
|
||||
|
||||
@ -27,7 +28,7 @@ export class ExportController {
|
||||
@Req() request: AuthedRequest,
|
||||
@Res() response: Response,
|
||||
): Promise<void> {
|
||||
await this.exports.streamPondMarkdownZip(request.user!, pondId, response);
|
||||
await this.exports.streamPondMarkdownZip(request.user!, pondId, response, readActorOf(request));
|
||||
}
|
||||
|
||||
/** Enqueue a `.docx`/`.odt` export of one page; poll `GET /jobs/:id` and
|
||||
@ -39,6 +40,11 @@ export class ExportController {
|
||||
@Body(new ZodValidationPipe(pageExportInputSchema)) input: PageExportInput,
|
||||
@Req() request: AuthedRequest,
|
||||
): Promise<ConversionJobView> {
|
||||
return this.exports.enqueuePageExport(request.user!, pageId, input.format);
|
||||
return this.exports.enqueuePageExport(
|
||||
request.user!,
|
||||
pageId,
|
||||
input.format,
|
||||
readActorOf(request),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -24,6 +24,7 @@ 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';
|
||||
@ -52,6 +53,7 @@ export class ExportService {
|
||||
private readonly plugins: PluginsService,
|
||||
private readonly fallbacks: PluginFallbackRenderer,
|
||||
private readonly config: AppConfig,
|
||||
private readonly readTrail: ReadTrailService,
|
||||
private readonly logger: PinoLogger,
|
||||
) {
|
||||
this.logger.setContext(ExportService.name);
|
||||
@ -64,7 +66,12 @@ 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): Promise<void> {
|
||||
async streamPondMarkdownZip(
|
||||
user: User,
|
||||
pondId: string,
|
||||
res: Response,
|
||||
read: ReadActor,
|
||||
): Promise<void> {
|
||||
const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } });
|
||||
if (!pond) throw new NotFoundException();
|
||||
|
||||
@ -77,7 +84,7 @@ export class ExportService {
|
||||
res.destroy(error);
|
||||
});
|
||||
archive.pipe(res);
|
||||
await this.appendPondMarkdown(archive, user, pond);
|
||||
await this.appendPondMarkdown(archive, user, pond, '', read);
|
||||
await archive.finalize();
|
||||
}
|
||||
|
||||
@ -93,7 +100,8 @@ export class ExportService {
|
||||
archive: archiver.Archiver,
|
||||
user: User,
|
||||
pond: { id: string; slug: string },
|
||||
prefix = '',
|
||||
prefix: string,
|
||||
read: ReadActor,
|
||||
): Promise<void> {
|
||||
const pondId = pond.id;
|
||||
const pages = await this.prisma.page.findMany({
|
||||
@ -113,6 +121,21 @@ 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>();
|
||||
@ -222,14 +245,18 @@ export class ExportService {
|
||||
user: User,
|
||||
pageId: string,
|
||||
format: ExportFormat,
|
||||
read: ReadActor,
|
||||
): Promise<ConversionJobView> {
|
||||
if (format === 'pdf') return this.enqueuePdfExport(user, pageId);
|
||||
if (format === 'pdf') return this.enqueuePdfExport(user, pageId, read);
|
||||
|
||||
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
|
||||
@ -265,7 +292,27 @@ export class ExportService {
|
||||
* the job input; the worker sends it to Gotenberg (`html → pdf`). Building it
|
||||
* up front keeps the job a plain byte→byte render the worker can retry.
|
||||
*/
|
||||
private async enqueuePdfExport(user: User, pageId: string): Promise<ConversionJobView> {
|
||||
/** 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> {
|
||||
const page = await this.prisma.page.findFirst({
|
||||
where: { id: pageId, deletedAt: null },
|
||||
include: {
|
||||
@ -274,6 +321,7 @@ 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
|
||||
|
||||
@ -112,7 +112,9 @@ export class McpService {
|
||||
case 'read_page':
|
||||
await this.assertPondExposed(input.pond!, token);
|
||||
await this.requirePage(user, input.pond!, input.page!, 'read');
|
||||
return asJson(await this.publicApi.getPage(user, input.pond!, input.page!));
|
||||
// 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!));
|
||||
|
||||
case 'search': {
|
||||
if (input.pond) await this.assertPondExposed(input.pond, token);
|
||||
|
||||
@ -39,6 +39,7 @@ 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). */
|
||||
@ -75,7 +76,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);
|
||||
return this.pages.getState(request.user!, id, readActorOf(request));
|
||||
}
|
||||
|
||||
/** Toggles one task-list checkbox (issue #153). Applied asynchronously
|
||||
@ -106,7 +107,7 @@ export class PagesController {
|
||||
@Param('id') id: string,
|
||||
@Req() request: AuthedRequest,
|
||||
): Promise<CollabTokenResponse> {
|
||||
return this.pages.issueCollabToken(request.user ?? null, id);
|
||||
return this.pages.issueCollabToken(request.user ?? null, id, readActorOf(request));
|
||||
}
|
||||
|
||||
/** Markdown export (issue #30) — downloads `<slug>.md`. */
|
||||
@ -117,7 +118,11 @@ export class PagesController {
|
||||
@Req() request: AuthedRequest,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
): Promise<string> {
|
||||
const { slug, markdown } = await this.pages.exportMarkdown(request.user!, id);
|
||||
const { slug, markdown } = await this.pages.exportMarkdown(
|
||||
request.user!,
|
||||
id,
|
||||
readActorOf(request),
|
||||
);
|
||||
response.set('Content-Type', 'text/markdown; charset=utf-8');
|
||||
response.set('Content-Disposition', `attachment; filename="${slug}.md"`);
|
||||
return markdown;
|
||||
@ -133,7 +138,7 @@ export class PagesController {
|
||||
@Param('slug') slug: string,
|
||||
@Req() request: AuthedRequest,
|
||||
): Promise<PageStateView> {
|
||||
return this.pages.getStateBySlug(request.user!, pondId, slug);
|
||||
return this.pages.getStateBySlug(request.user!, pondId, slug, readActorOf(request));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -38,6 +38,11 @@ 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';
|
||||
@ -80,10 +85,29 @@ 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,
|
||||
@ -400,8 +424,9 @@ export class PagesService {
|
||||
AND from_page_id IN (SELECT id FROM pages WHERE pond_id = ${pondId})`;
|
||||
}
|
||||
|
||||
async getState(_user: User, id: string): Promise<PageStateView> {
|
||||
async getState(_user: User, id: string, read: ReadActor): Promise<PageStateView> {
|
||||
const page = await this.findLivePage(id);
|
||||
await this.recordRead(page, read, 'page_view');
|
||||
return this.stateViewOf(page);
|
||||
}
|
||||
|
||||
@ -415,12 +440,21 @@ 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): Promise<CollabTokenResponse> {
|
||||
async issueCollabToken(
|
||||
user: User | null,
|
||||
id: string,
|
||||
read: ReadActor,
|
||||
): 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,
|
||||
@ -432,9 +466,15 @@ export class PagesService {
|
||||
}
|
||||
|
||||
/** The trashed-page hint for editors (issue #31) moved into the guard. */
|
||||
async getStateBySlug(_user: User, pondId: string, slug: string): Promise<PageStateView> {
|
||||
async getStateBySlug(
|
||||
_user: User,
|
||||
pondId: string,
|
||||
slug: string,
|
||||
read: ReadActor,
|
||||
): 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);
|
||||
}
|
||||
|
||||
@ -648,8 +688,16 @@ 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): Promise<{ slug: string; markdown: string }> {
|
||||
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 }> {
|
||||
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 ?? '' };
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ 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';
|
||||
|
||||
@ -45,7 +46,12 @@ export class PluginApiController {
|
||||
@Param('pageId') pageId: string,
|
||||
@Req() request: AuthedRequest,
|
||||
): Promise<PluginPageContent> {
|
||||
const { markdown } = await this.pages.exportMarkdown(request.user!, pageId);
|
||||
const { markdown } = await this.pages.exportMarkdown(
|
||||
request.user!,
|
||||
pageId,
|
||||
readActorOf(request),
|
||||
'page_view',
|
||||
);
|
||||
return { markdown };
|
||||
}
|
||||
|
||||
|
||||
@ -119,7 +119,7 @@ export class PublicApiController {
|
||||
@Param('pageSlug') pageSlug: string,
|
||||
@Req() request: PublicApiRequest,
|
||||
): Promise<PublicPageView> {
|
||||
return this.publicApi.getPage(request.user!, pondSlug, pageSlug);
|
||||
return this.publicApi.getPage(request.user!, request.apiToken!, pondSlug, pageSlug);
|
||||
}
|
||||
|
||||
@Patch('ponds/:pondSlug/pages/:pageSlug')
|
||||
@ -169,7 +169,10 @@ export class PublicApiController {
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const pond = await this.publicApi.requirePond(pondSlug);
|
||||
await this.exports.streamPondMarkdownZip(request.user!, pond.id, res);
|
||||
await this.exports.streamPondMarkdownZip(request.user!, pond.id, res, {
|
||||
actorId: request.user!.id,
|
||||
sessionKey: `token:${request.apiToken!.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('ponds/:pondSlug/labels')
|
||||
|
||||
@ -38,6 +38,7 @@ 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';
|
||||
@ -63,6 +64,7 @@ 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> {
|
||||
@ -119,8 +121,24 @@ export class PublicApiService {
|
||||
}));
|
||||
}
|
||||
|
||||
async getPage(user: User, pondSlug: string, pageSlug: string): Promise<PublicPageView> {
|
||||
async getPage(
|
||||
user: User,
|
||||
token: ApiToken,
|
||||
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 } }),
|
||||
@ -154,7 +172,8 @@ 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);
|
||||
return this.getPage(user, pondSlug, page.slug);
|
||||
// The write echo returns the full page — getPage records the read (#222).
|
||||
return this.getPage(user, token, pondSlug, page.slug);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -186,7 +205,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, pondSlug, page.slug);
|
||||
return this.getPage(user, token, pondSlug, page.slug);
|
||||
}
|
||||
|
||||
async deletePage(user: User, token: ApiToken, pondSlug: string, pageSlug: string): Promise<void> {
|
||||
|
||||
@ -3,6 +3,7 @@ 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';
|
||||
@ -66,7 +67,10 @@ export class PublicController {
|
||||
@Param('pageSlug') pageSlug: string,
|
||||
@Req() request: AuthedRequest,
|
||||
): Promise<PublicPageContent> {
|
||||
return this.publicPages.content(request.user ?? null, pondSlug, pageSlug);
|
||||
return this.publicPages.content(request.user ?? null, pondSlug, pageSlug, {
|
||||
actor: readActorOf(request),
|
||||
channel: 'page_view',
|
||||
});
|
||||
}
|
||||
|
||||
@Get(':pondSlug/:pageSlug/comments')
|
||||
@ -88,7 +92,13 @@ 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);
|
||||
const html = await this.publicPages.html(
|
||||
request.user ?? null,
|
||||
pondSlug,
|
||||
pageSlug,
|
||||
canonical,
|
||||
readActorOf(request),
|
||||
);
|
||||
response.set('Content-Type', 'text/html; charset=utf-8');
|
||||
return html;
|
||||
}
|
||||
|
||||
@ -8,9 +8,18 @@ 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;
|
||||
@ -53,6 +62,7 @@ export class PublicService {
|
||||
private readonly settings: InstanceSettingsService,
|
||||
private readonly commentsService: CommentsService,
|
||||
private readonly tasks: TasksService,
|
||||
private readonly readTrail: ReadTrailService,
|
||||
) {}
|
||||
|
||||
private async resolve(
|
||||
@ -74,8 +84,24 @@ export class PublicService {
|
||||
}
|
||||
|
||||
/** The page content for the read view (public and authenticated, issue #56). */
|
||||
async content(user: User | null, pondSlug: string, pageSlug: string): Promise<PublicPageContent> {
|
||||
async content(
|
||||
user: User | null,
|
||||
pondSlug: string,
|
||||
pageSlug: string,
|
||||
read: ReadContext,
|
||||
): 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.
|
||||
@ -83,7 +109,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]));
|
||||
const body = await this.renderBody(user, pond.id, page, 0, new Set([page.slug]), read);
|
||||
return {
|
||||
pondName: pond.name,
|
||||
pondSlug: pond.slug,
|
||||
@ -109,11 +135,12 @@ 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);
|
||||
return this.expandEmbeds(withTasks, user, pondId, depth, visited, read);
|
||||
}
|
||||
|
||||
/** Replaces each task-overview placeholder (issue #154) with the static,
|
||||
@ -143,6 +170,7 @@ 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;
|
||||
@ -151,18 +179,31 @@ 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 },
|
||||
select: { id: true, pondId: true, slug: true, title: true, classification: 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.
|
||||
@ -193,8 +234,13 @@ export class PublicService {
|
||||
pondSlug: string,
|
||||
pageSlug: string,
|
||||
canonical: string,
|
||||
actor: ReadActor,
|
||||
): Promise<string> {
|
||||
const content = await this.content(user, pondSlug, pageSlug);
|
||||
// `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',
|
||||
});
|
||||
// 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
|
||||
|
||||
@ -4,6 +4,7 @@ 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';
|
||||
|
||||
/**
|
||||
@ -43,6 +44,9 @@ export class ReadContentController {
|
||||
@Param('pageSlug') pageSlug: string,
|
||||
@Req() request: AuthedRequest,
|
||||
): Promise<PublicPageContent> {
|
||||
return this.publicPages.content(request.user ?? null, pondSlug, pageSlug);
|
||||
return this.publicPages.content(request.user ?? null, pondSlug, pageSlug, {
|
||||
actor: readActorOf(request),
|
||||
channel: 'page_view',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
17
apps/api/src/read-trail/read-actor.ts
Normal file
17
apps/api/src/read-trail/read-actor.ts
Normal file
@ -0,0 +1,17 @@
|
||||
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 {
|
||||
return {
|
||||
actorId: request.user?.id ?? null,
|
||||
sessionKey: request.sessionId ? `session:${request.sessionId}` : 'anon',
|
||||
};
|
||||
}
|
||||
330
apps/api/src/read-trail/read-trail.e2e.db.test.ts
Normal file
330
apps/api/src/read-trail/read-trail.e2e.db.test.ts
Normal file
@ -0,0 +1,330 @@
|
||||
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;
|
||||
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: 'api.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);
|
||||
});
|
||||
});
|
||||
15
apps/api/src/read-trail/read-trail.module.ts
Normal file
15
apps/api/src/read-trail/read-trail.module.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
|
||||
import { ReadTrailService } from './read-trail.service';
|
||||
|
||||
/**
|
||||
* 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({
|
||||
providers: [ReadTrailService],
|
||||
exports: [ReadTrailService],
|
||||
})
|
||||
export class ReadTrailModule {}
|
||||
94
apps/api/src/read-trail/read-trail.service.ts
Normal file
94
apps/api/src/read-trail/read-trail.service.ts
Normal file
@ -0,0 +1,94 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
/**
|
||||
* Every read surface classified content can leave through (issue #222,
|
||||
* ADR 0023). The trail is worthless unless ALL of them are instrumented, so
|
||||
* the union is the checklist: adding a read surface means adding a channel
|
||||
* here and wiring the emission — `docs/architecture/security.md` §Logging
|
||||
* carries the documented list.
|
||||
*/
|
||||
export const READ_CHANNELS = [
|
||||
/** Authenticated SPA state fetch and rendered read view, plus the public
|
||||
* JSON content route the SPA's anonymous read view uses. */
|
||||
'page_view',
|
||||
/** The server-rendered `/public/:pond/:page` HTML document. */
|
||||
'no_js_shell',
|
||||
/** `GET /api/public/v1/.../pages/:slug` (PAT-authenticated). */
|
||||
'public_api',
|
||||
/** `GET /media/:fileId` for an attachment whose effective classification
|
||||
* is `vs_nfd` (#212 semantics: page level, or pond max when page-less). */
|
||||
'attachment',
|
||||
/** Markdown download, pond ZIP (one event per classified page included),
|
||||
* queued `.docx`/`.odt` export, and the account data export. */
|
||||
'export',
|
||||
/** Collab-token issuance — the api-side proxy for the collab WS join
|
||||
* (ADR 0023): tokens live 60 s, so a live session re-requests one every
|
||||
* minute, which gives per-minute granularity without touching the collab
|
||||
* server (it has no permission context and never learns classifications). */
|
||||
'collab_join',
|
||||
] as const;
|
||||
export type ReadChannel = (typeof READ_CHANNELS)[number];
|
||||
|
||||
/** Who read: resolved once per request by the controller layer. */
|
||||
export interface ReadActor {
|
||||
/** Account id, or null for an anonymous reader on a public grant. */
|
||||
actorId: string | null;
|
||||
/** `session:<id>` (cookie session), `token:<id>` (PAT), `job:<id>` (a
|
||||
* background build such as the account data export), or the documented
|
||||
* `anon` marker — the dedup-window key basis (#223). */
|
||||
sessionKey: string;
|
||||
}
|
||||
|
||||
export interface ReadEventInput extends ReadActor {
|
||||
pageId: string | null;
|
||||
pondId: string;
|
||||
channel: ReadChannel;
|
||||
/** Small structured context (export format, attachment id) — never content. */
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The read-access trail for classified pages (issue #222, ADR 0023):
|
||||
* callers record a `read_events` row whenever a `VS_NFD` page leaves the
|
||||
* system through one of the {@link READ_CHANNELS}. Unclassified pages are
|
||||
* never recorded (variant A — the purpose limitation depends on it).
|
||||
*
|
||||
* DELIBERATE contrast to `AuditService`: recording failures are NOT
|
||||
* swallowed. A lost event is a gap in evidence, so a failed write aborts
|
||||
* the read with the ordinary 500 — the reader retries, the evidence stays
|
||||
* complete (decision recorded in ADR 0023 and security.md).
|
||||
*/
|
||||
@Injectable()
|
||||
export class ReadTrailService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly logger: PinoLogger,
|
||||
) {
|
||||
this.logger.setContext(ReadTrailService.name);
|
||||
}
|
||||
|
||||
async record(event: ReadEventInput): Promise<void> {
|
||||
const { actorId, sessionKey, pageId, pondId, channel, details } = event;
|
||||
await this.prisma.readEvent.create({
|
||||
data: {
|
||||
actorId,
|
||||
sessionKey,
|
||||
pageId,
|
||||
pondId,
|
||||
channel,
|
||||
classification: 'vs_nfd',
|
||||
details: details ? (details as Prisma.InputJsonObject) : undefined,
|
||||
},
|
||||
});
|
||||
// The stdout line mirrors the row (SIEM forwarding beyond this is out of
|
||||
// scope, #224); it fires only after the row is safely persisted.
|
||||
this.logger.info(
|
||||
{ actor: actorId, sessionKey, pageId, pondId, channel },
|
||||
'read_trail: classified page read',
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -48,6 +48,25 @@ traffic per open document.
|
||||
left open: unbounded volume, the no-loss requirement, and a purpose
|
||||
limitation that is much harder to defend.
|
||||
|
||||
## Decisions taken in #222
|
||||
|
||||
- **Failure mode: hard failure.** A failed `read_events` write aborts the
|
||||
read with the ordinary 500. The alternative (documented degradation) was
|
||||
rejected: the reader retrying is cheap, a gap in evidence is not. This is
|
||||
the deliberate contrast to `AuditService`, which swallows failures.
|
||||
- **Collab WS join: the api emits at token issuance.** The collab server
|
||||
keeps zero permission/classification context; tokens live 60 s, so a live
|
||||
session re-requests one per minute — per-minute granularity for free,
|
||||
which the dedup window (#223) collapses. The trail therefore proves
|
||||
"held a live connection during this window", not individual sync frames.
|
||||
- **Scope: full-content channels.** Content _fragments_ (search snippets,
|
||||
task-overview rows, backlink titles) and the Atom feeds (off in the
|
||||
reference configuration) are deliberately not instrumented — recorded in
|
||||
`security.md` §Logging as a residual.
|
||||
- **Session key vocabulary:** `session:<id>` (cookie), `token:<id>` (PAT —
|
||||
also the MCP `read_page` path), `job:<id>` (background builds such as the
|
||||
account data export), `anon` (anonymous reader on a public grant).
|
||||
|
||||
## Consequences
|
||||
|
||||
- The scope limit is the feature's strongest argument in the works-council
|
||||
|
||||
@ -211,6 +211,37 @@ scan docker-archive:/image.tar -o cyclonedx-json` respectively
|
||||
`audit.pruned` with count and cutoff, so a gap in the trail is always
|
||||
explainable. The read-access trail (#222–#225) is deliberately not
|
||||
covered by this period — it gets its own.
|
||||
- **Read-access trail** (issue #222, ADR 0023): reads of pages with
|
||||
`classification = vs_nfd` land as `read_events` rows — only classified
|
||||
pages, which is what keeps the purpose limitation defensible (variant A).
|
||||
The instrumented channels, and the emission point of each:
|
||||
- `page_view` — authenticated SPA state fetch (`GET /pages/:id`, the
|
||||
by-slug variant), the rendered read view (`/read/...`), the public JSON
|
||||
content route, the plugin-API content route, and an expanded embed of a
|
||||
classified page inside another page's rendering.
|
||||
- `no_js_shell` — the server-rendered `/public/:pond/:page` document.
|
||||
- `public_api` — `GET /api/public/v1/.../pages/:slug` and the MCP
|
||||
`read_page` tool (same emission point); the write echo of the public
|
||||
API's create/update counts as a read of the returned page.
|
||||
- `attachment` — `GET /media/:fileId` when the attachment's effective
|
||||
classification (#212 semantics) is `vs_nfd`.
|
||||
- `export` — per-page markdown download, one event per classified page in
|
||||
a pond ZIP or the account data export, and the queued
|
||||
`.docx`/`.odt`/`.pdf` export (recorded at enqueue — the user's action;
|
||||
the worker's conversion is machinery, not a second read).
|
||||
- `collab_join` — collab-token issuance, the api-side proxy for the
|
||||
collab WS join: the collab server has no permission context, and the
|
||||
60 s token TTL yields per-minute granularity for live sessions.
|
||||
Each event carries timestamp, actor (or the documented `anon` marker),
|
||||
session key (`session:`/`token:`/`job:`/`anon`), page, pond, channel and
|
||||
the classification at read time (a later reclassification never rewrites
|
||||
history). **Failure is not silent**: a failed trail write aborts the read
|
||||
with a 500 — the deliberate contrast to the audit trail's swallow-and-log,
|
||||
because a lost event is a gap in evidence (ADR 0023). Deliberately NOT
|
||||
instrumented (recorded residual): content _fragments_ — search-result
|
||||
snippets, task-overview rows, backlink titles — and the Atom feeds
|
||||
(disabled in the VS-NfD reference configuration, #227). Digest mails
|
||||
carry titles only (the #231 residue).
|
||||
|
||||
## Privacy (GDPR)
|
||||
|
||||
|
||||
@ -132,7 +132,7 @@ Gestaltungsspielraum. Zwei Varianten:
|
||||
Protokolliert werden Lesezugriffe **ausschließlich** auf Seiten mit
|
||||
`classification = VS_NFD`. Setzt P1-2 voraus.
|
||||
|
||||
- [ ] Instrumentierung der Lesepfade: Seitenansicht, Public-API-GET,
|
||||
- [x] Instrumentierung der Lesepfade: Seitenansicht, Public-API-GET,
|
||||
Attachment-Download, Export, No-JS-Shell, Collab-WS-Join · 4 AT · #222
|
||||
- [ ] Dedup-Fenster (eine Sitzung + eine Seite innerhalb N Minuten = ein
|
||||
Ereignis), sonst erzeugt Yjs-Sync eine Ereignisflut · 2 AT · #223
|
||||
|
||||
Loading…
Reference in New Issue
Block a user