dorfteich/apps/api/src/pages/pages.controller.ts
Claude Opus 4.8 9d288b2ad0
All checks were successful
CD / Build and push images (push) Successful in 3m4s
CI / Lint, typecheck, test (push) Successful in 2m27s
CI / Auth e2e pack (push) Successful in 3m6s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m16s
CD / Promote to Int (push) Successful in 11s
Wire real permissions into collab tokens and revocation (#53)
Live editing now obeys the same rules as REST: the collab-token mode comes
from the shared grant resolution, anonymous visitors can join public pages,
and revoking write access flips a running session to read-only within
seconds.

- Anonymous public tokens: `GET /pages/:id/collab-token` is `@Public()` but
  still permission-guarded, so a logged-out visitor gets an `ro` token where
  a `public` grant makes the page readable (404 otherwise). The token's
  `userId` is nullable (shared schema + collab context) for anonymous
  subjects.
- Prompt revocation: the pond-level NOTIFY (#39) now also fires on label
  tree/assignment changes (LabelsService move/remove/assign/unassign), and
  the collab server closes the *actual* WebSocket instead of only sending an
  application-level close message. Hocuspocus' `closeConnections` leaves the
  socket open so the client only re-checks on its ~30s message timeout;
  `closeDocumentConnections` drops the socket so the client reconnects and
  re-authenticates with a freshly-resolved token at once — the "within
  seconds" downgrade the milestone promises.
- Tests: the #52 fixture matrix gains anonymous cases (public grant → `ro`,
  none → 404); a collab db test proves an editor downgraded to reader goes
  read-only on reconnect (its post-downgrade edits no longer reach a peer);
  a new browser `collab-permissions` pack covers the read-only participant
  and the live downgrade end to end (new plain `fixture-editor` account).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-09 18:48:42 +02:00

154 lines
5.0 KiB
TypeScript

import {
Body,
Controller,
Delete,
Get,
GoneException,
HttpCode,
Param,
Patch,
Post,
Put,
Req,
Res,
} from '@nestjs/common';
import {
CollabTokenResponse,
CreatePageInput,
PageListItemView,
PageStateView,
PageView,
RepositionPageInput,
UpdatePageInput,
createPageInputSchema,
repositionPageInputSchema,
updatePageInputSchema,
} from '@dorfteich/shared';
import type { Response } from 'express';
import { AuthedRequest, Public } from '../auth/auth.guard';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
import {
AuthenticatedOnly,
RequiresPagePermission,
RequiresPondRole,
} from '../permissions/permission.decorators';
import { PagesService } from './pages.service';
/** Page CRUD and Yjs state persistence (issue #23; permission guard since #52). */
@Controller()
export class PagesController {
constructor(private readonly pages: PagesService) {}
@Post('ponds/:pondId/pages')
@RequiresPondRole('editor', { idParam: 'pondId' })
async create(
@Param('pondId') pondId: string,
@Body(new ZodValidationPipe(createPageInputSchema)) input: CreatePageInput,
@Req() request: AuthedRequest,
): Promise<PageView> {
return this.pages.create(request.user!, pondId, input);
}
@Get('ponds/:pondId/pages')
@RequiresPondRole('reader', { idParam: 'pondId' }) // the service filters per page
async list(
@Param('pondId') pondId: string,
@Req() request: AuthedRequest,
): Promise<PageListItemView[]> {
return this.pages.list(request.user!, pondId);
}
@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);
}
/**
* Short-lived collaboration token for the collab server (issue #34).
* `@Public()` so an anonymous visitor to a public page can obtain a token
* (issue #53); the permission guard still enforces read access (404 when no
* grant makes the page readable) and downgrades non-writers to `ro`.
*/
@Get('pages/:id/collab-token')
@Public()
@RequiresPagePermission('read', { idParam: 'id' }) // readers (incl. public) get an `ro` token
async collabToken(
@Param('id') id: string,
@Req() request: AuthedRequest,
): Promise<CollabTokenResponse> {
return this.pages.issueCollabToken(request.user ?? null, id);
}
/** Markdown export (issue #30) — downloads `<slug>.md`. */
@Get('pages/:id/export/markdown')
@RequiresPagePermission('read', { idParam: 'id' })
async exportMarkdown(
@Param('id') id: string,
@Req() request: AuthedRequest,
@Res({ passthrough: true }) response: Response,
): Promise<string> {
const { slug, markdown } = await this.pages.exportMarkdown(request.user!, id);
response.set('Content-Type', 'text/markdown; charset=utf-8');
response.set('Content-Disposition', `attachment; filename="${slug}.md"`);
return markdown;
}
/** Resolves the pond-slug + page-slug pair the `/p/:pondSlug/:pageSlug` route
* navigates to (issue #25); the pond id must already be known to the caller
* (e.g. from `GET /ponds/:slug`). */
@Get('ponds/:pondId/pages/:slug')
@RequiresPagePermission('read', { pondIdParam: 'pondId', slugParam: 'slug' })
async getStateBySlug(
@Param('pondId') pondId: string,
@Param('slug') slug: string,
@Req() request: AuthedRequest,
): Promise<PageStateView> {
return this.pages.getStateBySlug(request.user!, pondId, slug);
}
/**
* The REST state-write path was retired when the editor moved to live
* collaboration (#36): document changes now flow through the collab server
* (ADR 0003), which is the sole writer of page state. The read paths (`GET`)
* remain. Kept as an explicit 410 so any stale client gets a clear signal.
*/
@Put('pages/:id/state')
@AuthenticatedOnly() // always 410 — never touches the page
saveState(): never {
throw new GoneException({
code: 'rest_state_write_retired',
details: { hint: 'Page content is edited live over the collaboration server (/collab).' },
});
}
/** Reposition a page in the manual sidebar order (issue #45). */
@Patch('pages/:id/position')
@RequiresPagePermission('write', { idParam: 'id' })
async reposition(
@Param('id') id: string,
@Body(new ZodValidationPipe(repositionPageInputSchema)) input: RepositionPageInput,
@Req() request: AuthedRequest,
): Promise<PageView> {
return this.pages.reposition(request.user!, id, input);
}
@Patch('pages/:id')
@RequiresPagePermission('write', { idParam: 'id' })
async update(
@Param('id') id: string,
@Body(new ZodValidationPipe(updatePageInputSchema)) input: UpdatePageInput,
@Req() request: AuthedRequest,
): Promise<PageView> {
return this.pages.update(request.user!, id, input);
}
@Delete('pages/:id')
@HttpCode(204)
@RequiresPagePermission('write', { idParam: 'id' })
async remove(@Param('id') id: string, @Req() request: AuthedRequest): Promise<void> {
await this.pages.softDelete(request.user!, id);
}
}