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
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
154 lines
5.0 KiB
TypeScript
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);
|
|
}
|
|
}
|