#222: read-access trail for classified pages
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m25s
CI / Build container images (pull_request) Successful in 2m58s
CI / Auth e2e pack (pull_request) Successful in 8m35s
CI / Import/export fidelity gate (pull_request) Successful in 1m7s
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CI / Lint, typecheck, test (push) Has been cancelled
CD / Build and push images (push) Has been cancelled

Instrument every full-content read channel for pages with
classification = vs_nfd (ADR 0023, variant A): SPA state fetch and read
rendering, public JSON content, no-JS shell, expanded embeds, public API
GET (incl. the MCP read_page path and write echoes), attachment download
under the #212 effective classification, all export shapes (markdown,
pond ZIP, account data export, queued docx/odt/pdf at enqueue), and
collab-token issuance as the api-side proxy for the WS join.

Events land in the new read_events table (no FKs — evidence survives
page purges and hard user deletions) with actor, session key
(session:/token:/job:/anon), page, pond, channel and the classification
at read time. Recording failures are NOT swallowed: a failed write
aborts the read (hard failure, the deliberate contrast to AuditService —
decision recorded in ADR 0023 and security.md §Logging, together with
the recorded residuals: content fragments and feeds).

One e2e test per channel proves both the event and its absence for
unclassified pages, plus the hard-failure semantics.

Refs #222.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8
This commit is contained in:
Claude Fable 5 2026-07-31 12:06:15 +02:00
parent 919201d9b0
commit 05a979bac3
25 changed files with 824 additions and 36 deletions

View File

@ -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");

View File

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

View File

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

View File

@ -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();

View File

@ -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.

View File

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

View File

@ -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();

View File

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

View File

@ -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 bytebyte 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

View File

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

View File

@ -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));
}
/**

View File

@ -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 ?? '' };
}

View File

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

View File

@ -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')

View File

@ -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> {

View File

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

View File

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

View File

@ -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',
});
}
}

View 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',
};
}

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

View 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 {}

View 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',
);
}
}

View File

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

View File

@ -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)

View File

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