diff --git a/apps/api/prisma/migrations/20260712020000_api_tokens/migration.sql b/apps/api/prisma/migrations/20260712020000_api_tokens/migration.sql new file mode 100644 index 0000000..314bfab --- /dev/null +++ b/apps/api/prisma/migrations/20260712020000_api_tokens/migration.sql @@ -0,0 +1,27 @@ +-- CreateEnum +CREATE TYPE "ApiTokenScope" AS ENUM ('READ', 'WRITE'); + +-- CreateTable +CREATE TABLE "api_tokens" ( + "id" TEXT NOT NULL, + "token_hash" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "scope" "ApiTokenScope" NOT NULL, + "pond_ids" TEXT[] DEFAULT ARRAY[]::TEXT[], + "expires_at" TIMESTAMP(3), + "revoked_at" TIMESTAMP(3), + "last_used_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "api_tokens_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "api_tokens_token_hash_key" ON "api_tokens"("token_hash"); + +-- CreateIndex +CREATE INDEX "api_tokens_user_id_idx" ON "api_tokens"("user_id"); + +-- AddForeignKey +ALTER TABLE "api_tokens" ADD CONSTRAINT "api_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 0ed5b58..9be3039 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -50,6 +50,7 @@ model User { identities UserIdentity[] sessions Session[] authTokens AuthToken[] + apiTokens ApiToken[] ponds Pond[] pages Page[] attachments Attachment[] @@ -565,6 +566,35 @@ model AuthToken { @@map("auth_tokens") } +enum ApiTokenScope { + READ + WRITE +} + +/// Personal access tokens for the public API (issue #104). Only the SHA-256 +/// hash of the secret is stored (auth-tokens pattern); a token acts AS its +/// user — the whole permission model applies — narrowed by `scope` and the +/// optional pond restriction. Revoking keeps the row so the settings UI can +/// show history; validation skips revoked/expired rows. +model ApiToken { + id String @id @default(uuid()) + tokenHash String @unique @map("token_hash") + userId String @map("user_id") + name String + scope ApiTokenScope + /// Empty = every pond the user may access; else only these pond ids. + pondIds String[] @default([]) @map("pond_ids") + expiresAt DateTime? @map("expires_at") + revokedAt DateTime? @map("revoked_at") + lastUsedAt DateTime? @map("last_used_at") + createdAt DateTime @default(now()) @map("created_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@map("api_tokens") +} + /// Fixed-window rate-limit counters (ADR 0002: no Redis). `key` encodes /// scope and subject, e.g. "login:ip:203.0.113.7". model RateLimit { diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index c6b0b1a..b6a6c83 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -25,6 +25,7 @@ import { PermissionsModule } from './permissions/permissions.module'; import { PluginsModule } from './plugins/plugins.module'; import { PondsModule } from './ponds/ponds.module'; 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 { SearchModule } from './search/search.module'; @@ -69,6 +70,7 @@ import { VersionsModule } from './versions/versions.module'; GrantsModule, MembersModule, PublicModule, + PublicApiModule, ImportExportModule, PluginsModule, AuthModule, diff --git a/apps/api/src/import-export/import-export.module.ts b/apps/api/src/import-export/import-export.module.ts index 77bc087..e74ec26 100644 --- a/apps/api/src/import-export/import-export.module.ts +++ b/apps/api/src/import-export/import-export.module.ts @@ -49,7 +49,7 @@ const EXPORT_PURGE_CADENCE_SECONDS = 60 * 60; // Same pattern for the Gotenberg PDF renderer (#67). { provide: GotenbergRenderer, useClass: GotenbergHttpRenderer }, ], - exports: [ConversionJobService, PandocConverter], + exports: [ConversionJobService, PandocConverter, ExportService], }) export class ImportExportModule implements OnModuleInit { constructor( diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 396a517..0951022 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -73,7 +73,11 @@ async function bootstrap(): Promise { // Base64-encoded Yjs page state (max 5 MiB, operations.md) inflates by // ~4/3; 8 MiB leaves headroom for the JSON envelope around it. app.useBodyParser('json', { limit: '8mb' }); - app.setGlobalPrefix('api/v1'); + // The public API (issue #104) lives at /api/public/v1 — its controllers + // declare the full path and are excluded from the SPA prefix. + app.setGlobalPrefix('api/v1', { + exclude: ['api/public/v1', 'api/public/v1/{*path}'], + }); app.enableShutdownHooks(); const config = app.get(AppConfig); diff --git a/apps/api/src/permissions/permission.decorators.ts b/apps/api/src/permissions/permission.decorators.ts index 1430c45..00a3946 100644 --- a/apps/api/src/permissions/permission.decorators.ts +++ b/apps/api/src/permissions/permission.decorators.ts @@ -26,11 +26,13 @@ export interface PondParamSource { labelParam?: string; } -/** Where the guard finds the page: its id param, or a pond-id + page-slug - * param pair (`/ponds/:pondId/pages/:slug`). */ +/** Where the guard finds the page: its id param, a pond-id + page-slug + * param pair (`/ponds/:pondId/pages/:slug`), or a pond-slug + page-slug + * pair (the public API's `/ponds/:pondSlug/pages/:pageSlug`, issue #104). */ export interface PageParamSource { idParam?: string; pondIdParam?: string; + pondSlugParam?: string; slugParam?: string; /** Trash routes: the page must BE trashed, and access = write capability * on the page (ADR 0013); live pages 404 here. */ diff --git a/apps/api/src/permissions/permission.guard.ts b/apps/api/src/permissions/permission.guard.ts index 64932a1..38ababc 100644 --- a/apps/api/src/permissions/permission.guard.ts +++ b/apps/api/src/permissions/permission.guard.ts @@ -108,7 +108,9 @@ export class PermissionGuard implements CanActivate { ): Promise { const where = source.idParam ? { id: params[source.idParam] } - : { pondId: params[source.pondIdParam!], slug: params[source.slugParam!] }; + : source.pondSlugParam + ? { pond: { slug: params[source.pondSlugParam] }, slug: params[source.slugParam!] } + : { pondId: params[source.pondIdParam!], slug: params[source.slugParam!] }; const page: GuardedPage | null = await this.prisma.page.findFirst({ where, select: { diff --git a/apps/api/src/ponds/ponds.service.ts b/apps/api/src/ponds/ponds.service.ts index 072a5d6..280d05c 100644 --- a/apps/api/src/ponds/ponds.service.ts +++ b/apps/api/src/ponds/ponds.service.ts @@ -147,11 +147,12 @@ export class PondsService { const pond = await this.prisma.pond.findFirst({ where: { id, deletedAt: null } }); if (!pond) throw new NotFoundException(); // Stored settings hold only deviations from the defaults; merge in - // whichever of the settings keys this request changes (#26/#66/#91). + // whichever of the settings keys this request changes (#26/#66/#91/#104). const settingsChanged = input.sidebarSort !== undefined || input.fonts !== undefined || - input.commentPolicy !== undefined; + input.commentPolicy !== undefined || + input.apiEnabled !== undefined; const settings = !settingsChanged ? undefined : { @@ -159,6 +160,7 @@ export class PondsService { ...(input.sidebarSort !== undefined ? { sidebarSort: input.sidebarSort } : {}), ...(input.fonts !== undefined ? { fonts: input.fonts } : {}), ...(input.commentPolicy !== undefined ? { commentPolicy: input.commentPolicy } : {}), + ...(input.apiEnabled !== undefined ? { apiEnabled: input.apiEnabled } : {}), }; const updated = await this.prisma.pond.update({ where: { id }, diff --git a/apps/api/src/public-api/api-tokens.controller.ts b/apps/api/src/public-api/api-tokens.controller.ts new file mode 100644 index 0000000..c938994 --- /dev/null +++ b/apps/api/src/public-api/api-tokens.controller.ts @@ -0,0 +1,42 @@ +import { Body, Controller, Delete, Get, HttpCode, Param, Post, Req } from '@nestjs/common'; +import { + createApiTokenInputSchema, + type ApiTokenCreatedView, + type ApiTokenView, + type CreateApiTokenInput, +} from '@dorfteich/shared'; + +import { AuthedRequest } from '../auth/auth.guard'; +import { ZodValidationPipe } from '../common/zod-validation.pipe'; +import { AuthenticatedOnly } from '../permissions/permission.decorators'; +import { ApiTokensService } from './api-tokens.service'; + +/** + * Personal-access-token lifecycle for the settings UI (issue #104) — + * session-authenticated and owner-scoped; the public API itself never + * manages tokens (a leaked token must not be able to mint more). + */ +@Controller('users/me/api-tokens') +@AuthenticatedOnly() +export class ApiTokensController { + constructor(private readonly tokens: ApiTokensService) {} + + @Get() + list(@Req() request: AuthedRequest): Promise { + return this.tokens.list(request.user!); + } + + @Post() + create( + @Body(new ZodValidationPipe(createApiTokenInputSchema)) input: CreateApiTokenInput, + @Req() request: AuthedRequest, + ): Promise { + return this.tokens.create(request.user!, input); + } + + @Delete(':id') + @HttpCode(204) + async revoke(@Param('id') id: string, @Req() request: AuthedRequest): Promise { + await this.tokens.revoke(request.user!, id); + } +} diff --git a/apps/api/src/public-api/api-tokens.service.ts b/apps/api/src/public-api/api-tokens.service.ts new file mode 100644 index 0000000..dbd546d --- /dev/null +++ b/apps/api/src/public-api/api-tokens.service.ts @@ -0,0 +1,150 @@ +import { createHash, randomBytes } from 'node:crypto'; + +import { Injectable, NotFoundException } from '@nestjs/common'; +import { + API_TOKEN_PREFIX, + type ApiTokenCreatedView, + type ApiTokenScope, + type ApiTokenView, + type CreateApiTokenInput, +} from '@dorfteich/shared'; +import { ApiToken, User } from '@prisma/client'; + +import { AuditService } from '../audit/audit.service'; +import { PermissionService } from '../permissions/permission.service'; +import { PrismaService } from '../prisma/prisma.service'; + +/** Throttle for the last-used timestamp — one write per token per minute + * keeps busy clients from turning every request into an UPDATE. */ +const LAST_USED_WRITE_INTERVAL_MS = 60_000; + +/** + * Personal access tokens (issue #104). The secret (`dt_pat_`) is + * returned exactly once at creation and stored as its SHA-256 hash + * (auth-tokens pattern). A token authenticates AS its user; scope and the + * optional pond restriction only narrow what the public API lets it do — + * they never widen permissions. + */ +@Injectable() +export class ApiTokensService { + constructor( + private readonly prisma: PrismaService, + private readonly permissions: PermissionService, + private readonly audit: AuditService, + ) {} + + async create(user: User, input: CreateApiTokenInput): Promise { + // The restriction may only name ponds the user can currently see — + // anything else would leak pond ids into stored rows and confuse the + // settings list. Unknown ids are rejected, not silently dropped. + const pondIds = [...new Set(input.pondIds)]; + for (const pondId of pondIds) { + const pond = await this.prisma.pond.findFirst({ + where: { id: pondId, deletedAt: null }, + select: { id: true }, + }); + if (!pond || !(await this.permissions.canSeePond(user, pondId))) { + throw new NotFoundException({ code: 'pond_not_found' }); + } + } + + const raw = `${API_TOKEN_PREFIX}${randomBytes(32).toString('base64url')}`; + const row = await this.prisma.apiToken.create({ + data: { + tokenHash: hashApiToken(raw), + userId: user.id, + name: input.name, + scope: input.scope === 'write' ? 'WRITE' : 'READ', + pondIds, + expiresAt: input.expiresAt ?? null, + }, + }); + await this.audit.record({ + action: 'api.token_created', + actorId: user.id, + targetType: 'api_token', + targetId: row.id, + details: { name: row.name, scope: input.scope, ponds: pondIds.length }, + }); + return { ...(await this.viewOf(row)), token: raw }; + } + + async list(user: User): Promise { + const rows = await this.prisma.apiToken.findMany({ + where: { userId: user.id }, + orderBy: { createdAt: 'desc' }, + }); + return Promise.all(rows.map((row) => this.viewOf(row))); + } + + async revoke(user: User, tokenId: string): Promise { + // Owner-scoped update: someone else's token id reads as "not found". + const result = await this.prisma.apiToken.updateMany({ + where: { id: tokenId, userId: user.id, revokedAt: null }, + data: { revokedAt: new Date() }, + }); + if (result.count === 0) throw new NotFoundException(); + await this.audit.record({ + action: 'api.token_revoked', + actorId: user.id, + targetType: 'api_token', + targetId: tokenId, + }); + } + + /** + * Resolves a bearer secret to its live token + user, or null for unknown, + * revoked, expired tokens or disabled accounts. Updates the last-used + * timestamp (throttled, fire-and-forget). + */ + async validate(raw: string): Promise<{ user: User; token: ApiToken } | null> { + if (!raw.startsWith(API_TOKEN_PREFIX)) return null; + const token = await this.prisma.apiToken.findUnique({ + where: { tokenHash: hashApiToken(raw) }, + include: { user: true }, + }); + if (!token || token.revokedAt) return null; + if (token.expiresAt && token.expiresAt.getTime() <= Date.now()) return null; + if (token.user.status !== 'ACTIVE') return null; + + const lastUsed = token.lastUsedAt?.getTime() ?? 0; + if (Date.now() - lastUsed > LAST_USED_WRITE_INTERVAL_MS) { + this.prisma.apiToken + .update({ where: { id: token.id }, data: { lastUsedAt: new Date() } }) + .catch(() => undefined); + } + const { user, ...rest } = token; + return { user, token: rest as ApiToken }; + } + + scopeOf(token: ApiToken): ApiTokenScope { + return token.scope === 'WRITE' ? 'write' : 'read'; + } + + private async viewOf(row: ApiToken): Promise { + const ponds = + row.pondIds.length === 0 + ? [] + : await this.prisma.pond.findMany({ + where: { id: { in: row.pondIds } }, + select: { id: true, name: true }, + }); + return { + id: row.id, + name: row.name, + scope: this.scopeOf(row), + ponds: row.pondIds.map((id) => ({ + id, + name: ponds.find((pond) => pond.id === id)?.name ?? id, + })), + expiresAt: row.expiresAt?.toISOString() ?? null, + revokedAt: row.revokedAt?.toISOString() ?? null, + lastUsedAt: row.lastUsedAt?.toISOString() ?? null, + createdAt: row.createdAt.toISOString(), + }; + } +} + +export function hashApiToken(raw: string): string { + return createHash('sha256').update(raw).digest('hex'); +} diff --git a/apps/api/src/public-api/openapi.test.ts b/apps/api/src/public-api/openapi.test.ts new file mode 100644 index 0000000..12a1786 --- /dev/null +++ b/apps/api/src/public-api/openapi.test.ts @@ -0,0 +1,65 @@ +import 'reflect-metadata'; + +import { METHOD_METADATA, PATH_METADATA } from '@nestjs/common/constants'; +import { RequestMethod } from '@nestjs/common'; +import { describe, expect, it } from 'vitest'; + +import { buildOpenApiDocument } from './openapi'; +import { PublicApiController } from './public-api.controller'; + +/** + * The OpenAPI document is maintained by hand (openapi.ts) — this test walks + * the controller's real routes and asserts each one is described, so the + * document cannot silently drift from the implementation (issue #104 + * acceptance criterion), and nothing documented is stale. + */ +describe('public api OpenAPI document', () => { + const verbs: Record = { + [RequestMethod.GET]: 'get', + [RequestMethod.POST]: 'post', + [RequestMethod.PUT]: 'put', + [RequestMethod.PATCH]: 'patch', + [RequestMethod.DELETE]: 'delete', + }; + + function controllerRoutes(): { path: string; verb: string }[] { + const prototype = PublicApiController.prototype as unknown as Record; + const routes: { path: string; verb: string }[] = []; + for (const name of Object.getOwnPropertyNames(prototype)) { + if (name === 'constructor') continue; + const handler = prototype[name]; + if (typeof handler !== 'function') continue; + const method = Reflect.getMetadata(METHOD_METADATA, handler) as number | undefined; + if (method === undefined) continue; + const raw = Reflect.getMetadata(PATH_METADATA, handler) as string; + // Nest `:param` → OpenAPI `{param}`; the controller base is the server url. + const path = `/${raw}`.replace(/\/+/g, '/').replace(/:([A-Za-z0-9_]+)/g, '{$1}'); + routes.push({ path, verb: verbs[method]! }); + } + return routes; + } + + it('describes every controller route and nothing else', () => { + const document = buildOpenApiDocument() as { + paths: Record>; + }; + const documented = new Set( + Object.entries(document.paths).flatMap(([path, methods]) => + Object.keys(methods).map((verb) => `${verb} ${path}`), + ), + ); + const implemented = new Set(controllerRoutes().map(({ verb, path }) => `${verb} ${path}`)); + + expect([...implemented].filter((route) => !documented.has(route))).toEqual([]); + expect([...documented].filter((route) => !implemented.has(route))).toEqual([]); + }); + + it('declares bearer security and the versioned server url', () => { + const document = buildOpenApiDocument() as { + servers: { url: string }[]; + components: { securitySchemes: Record }; + }; + expect(document.servers[0]!.url).toBe('/api/public/v1'); + expect(document.components.securitySchemes.pat).toBeDefined(); + }); +}); diff --git a/apps/api/src/public-api/openapi.ts b/apps/api/src/public-api/openapi.ts new file mode 100644 index 0000000..01b616d --- /dev/null +++ b/apps/api/src/public-api/openapi.ts @@ -0,0 +1,397 @@ +/** + * The OpenAPI 3.1 description of the public REST API (issue #104), + * maintained by hand next to the controller — the api has no swagger + * tooling, and this surface is small and versioned. A test walks the + * controller's routes and asserts each one appears here, so the document + * cannot silently drift from the implementation. + */ + +const bearerSecurity = [{ pat: [] }] as const; + +const pondParam = { + name: 'pondSlug', + in: 'path', + required: true, + schema: { type: 'string' }, + description: 'Pond slug (the pond must have opted into the API).', +} as const; + +const pageParam = { + name: 'pageSlug', + in: 'path', + required: true, + schema: { type: 'string' }, +} as const; + +const labelParam = { + name: 'labelId', + in: 'path', + required: true, + schema: { type: 'string' }, +} as const; + +const commentParam = { + name: 'commentId', + in: 'path', + required: true, + schema: { type: 'string' }, +} as const; + +function jsonResponse(description: string, schema: object): object { + return { description, content: { 'application/json': { schema } } }; +} + +function jsonBody(schema: object): object { + return { required: true, content: { 'application/json': { schema } } }; +} + +const ref = (name: string): object => ({ $ref: `#/components/schemas/${name}` }); + +export function buildOpenApiDocument(): object { + return { + openapi: '3.1.0', + info: { + title: 'Dorfteich public API', + version: '1', + description: + 'Token-authenticated access to a Dorfteich instance. A personal access token ' + + '(created under Settings) acts as its user: the normal permission model applies, ' + + 'narrowed by the token scope (read/write) and an optional pond restriction. ' + + 'Only ponds that opted into the API are visible; everything else answers 404. ' + + 'Errors carry `{ code, message, details? }`; requests are rate-limited per token.', + }, + servers: [{ url: '/api/public/v1' }], + security: [...bearerSecurity], + components: { + securitySchemes: { + pat: { + type: 'http', + scheme: 'bearer', + description: 'Personal access token (`dt_pat_…`) from Settings → API tokens.', + }, + }, + schemas: { + Me: { + type: 'object', + properties: { + user: { + type: 'object', + properties: { + id: { type: 'string' }, + username: { type: 'string' }, + displayName: { type: 'string' }, + }, + }, + scope: { type: 'string', enum: ['read', 'write'] }, + pondSlugs: { type: 'array', items: { type: 'string' } }, + }, + }, + Pond: { + type: 'object', + properties: { + slug: { type: 'string' }, + name: { type: 'string' }, + description: { type: 'string' }, + type: { type: 'string', enum: ['personal', 'shared'] }, + createdAt: { type: 'string', format: 'date-time' }, + }, + }, + PageListItem: { + type: 'object', + properties: { + slug: { type: 'string' }, + title: { type: 'string' }, + labels: { type: 'array', items: { type: 'string' } }, + createdAt: { type: 'string', format: 'date-time' }, + updatedAt: { type: 'string', format: 'date-time' }, + }, + }, + Page: { + type: 'object', + properties: { + slug: { type: 'string' }, + title: { type: 'string' }, + pondSlug: { type: 'string' }, + markdown: { type: 'string' }, + html: { type: 'string', description: 'Server-rendered, sanitized HTML.' }, + labels: { type: 'array', items: { type: 'string' } }, + createdAt: { type: 'string', format: 'date-time' }, + updatedAt: { type: 'string', format: 'date-time' }, + }, + }, + CreatePage: { + type: 'object', + required: ['title'], + properties: { + title: { type: 'string', maxLength: 200 }, + markdown: { type: 'string', description: 'Initial content; empty allowed.' }, + }, + }, + UpdatePage: { + type: 'object', + minProperties: 1, + properties: { + title: { type: 'string', maxLength: 200 }, + markdown: { + type: 'string', + description: + 'Replaces the whole content. Applied through the collaborative document, ' + + 'so open editors converge; the response may briefly lag the change.', + }, + }, + }, + SearchResult: { + type: 'object', + properties: { + pondSlug: { type: 'string' }, + pageSlug: { type: 'string' }, + title: { type: 'string' }, + snippet: { type: 'string', description: 'Matches wrapped in `**…**`.' }, + }, + }, + Label: { + type: 'object', + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + color: { type: 'string' }, + parentId: { type: ['string', 'null'] }, + }, + }, + LabelTreeNode: { + allOf: [ + ref('Label'), + { + type: 'object', + properties: { children: { type: 'array', items: ref('LabelTreeNode') } }, + }, + ], + }, + CreateLabel: { + type: 'object', + required: ['name'], + properties: { + name: { type: 'string', maxLength: 60 }, + color: { type: 'string', pattern: '^#[0-9a-fA-F]{6}$' }, + parentId: { type: ['string', 'null'] }, + }, + }, + UpdateLabel: { + type: 'object', + minProperties: 1, + description: 'Rename, recolour and/or move (parentId null = to the root).', + properties: { + name: { type: 'string', maxLength: 60 }, + color: { type: 'string', pattern: '^#[0-9a-fA-F]{6}$' }, + parentId: { type: ['string', 'null'] }, + }, + }, + Comment: { + type: 'object', + properties: { + id: { type: 'string' }, + parentId: { type: ['string', 'null'] }, + body: { type: 'string', description: 'Raw Markdown.' }, + html: { type: 'string', description: 'Rendered, sanitized HTML.' }, + author: { type: ['object', 'null'] }, + resolvedAt: { type: ['string', 'null'], format: 'date-time' }, + createdAt: { type: 'string', format: 'date-time' }, + }, + }, + CreateComment: { + type: 'object', + required: ['body'], + properties: { + body: { type: 'string', description: 'Markdown.' }, + parentId: { + type: ['string', 'null'], + description: 'Reply target (a thread root id); absent = new thread.', + }, + }, + }, + PageComments: { + type: 'object', + properties: { + threads: { type: 'array', items: { type: 'object' } }, + openCount: { type: 'integer' }, + resolvedCount: { type: 'integer' }, + }, + }, + Error: { + type: 'object', + properties: { + code: { type: 'string' }, + message: { type: 'string' }, + details: { type: 'object' }, + }, + }, + }, + }, + paths: { + '/me': { + get: { + summary: "The token's user and scope (client smoke test).", + responses: { '200': jsonResponse('Token identity', ref('Me')) }, + }, + }, + '/ponds': { + get: { + summary: 'API-enabled ponds visible to the token.', + responses: { + '200': jsonResponse('Ponds', { type: 'array', items: ref('Pond') }), + }, + }, + }, + '/ponds/{pondSlug}': { + get: { + summary: 'Pond metadata.', + parameters: [pondParam], + responses: { '200': jsonResponse('Pond', ref('Pond')) }, + }, + }, + '/ponds/{pondSlug}/pages': { + get: { + summary: 'Readable pages of the pond.', + parameters: [pondParam], + responses: { + '200': jsonResponse('Pages', { type: 'array', items: ref('PageListItem') }), + }, + }, + post: { + summary: 'Create a page from title + Markdown (write scope).', + parameters: [pondParam], + requestBody: jsonBody(ref('CreatePage')), + responses: { '201': jsonResponse('Created page', ref('Page')) }, + }, + }, + '/ponds/{pondSlug}/pages/{pageSlug}': { + get: { + summary: 'One page as Markdown + rendered HTML.', + parameters: [pondParam, pageParam], + responses: { '200': jsonResponse('Page', ref('Page')) }, + }, + patch: { + summary: 'Update title and/or replace content (write scope).', + parameters: [pondParam, pageParam], + requestBody: jsonBody(ref('UpdatePage')), + responses: { '200': jsonResponse('Updated page', ref('Page')) }, + }, + delete: { + summary: 'Move the page to the trash (write scope).', + parameters: [pondParam, pageParam], + responses: { '204': { description: 'Trashed' } }, + }, + }, + '/search': { + get: { + summary: 'Permission-filtered full-text search across exposed ponds.', + parameters: [ + { name: 'q', in: 'query', required: true, schema: { type: 'string' } }, + { + name: 'pond', + in: 'query', + schema: { type: 'string' }, + description: 'Restrict to one pond (slug).', + }, + { + name: 'label', + in: 'query', + schema: { type: 'string' }, + description: 'Restrict to pages carrying this label id.', + }, + ], + responses: { + '200': jsonResponse('Hits', { type: 'array', items: ref('SearchResult') }), + }, + }, + }, + '/ponds/{pondSlug}/export/markdown': { + get: { + summary: 'ZIP of the readable pages as Markdown files (plus media).', + parameters: [pondParam], + responses: { + '200': { + description: 'ZIP stream', + content: { 'application/zip': { schema: { type: 'string', format: 'binary' } } }, + }, + }, + }, + }, + '/ponds/{pondSlug}/labels': { + get: { + summary: 'The pond label tree.', + parameters: [pondParam], + responses: { + '200': jsonResponse('Labels', { type: 'array', items: ref('LabelTreeNode') }), + }, + }, + post: { + summary: 'Create a label (write scope, Pond Admin).', + parameters: [pondParam], + requestBody: jsonBody(ref('CreateLabel')), + responses: { '201': jsonResponse('Created label', ref('Label')) }, + }, + }, + '/ponds/{pondSlug}/labels/{labelId}': { + patch: { + summary: 'Rename, recolour and/or move a label (write scope, Pond Admin).', + parameters: [pondParam, labelParam], + requestBody: jsonBody(ref('UpdateLabel')), + responses: { '200': jsonResponse('Updated label', ref('Label')) }, + }, + delete: { + summary: 'Delete an unused label (write scope, Pond Admin).', + parameters: [pondParam, labelParam], + responses: { '204': { description: 'Deleted' } }, + }, + }, + '/ponds/{pondSlug}/pages/{pageSlug}/labels/{labelId}': { + put: { + summary: 'Assign a label to the page (write scope).', + parameters: [pondParam, pageParam, labelParam], + responses: { + '200': jsonResponse("The page's labels", { type: 'array', items: ref('Label') }), + }, + }, + delete: { + summary: 'Unassign a label from the page (write scope, idempotent).', + parameters: [pondParam, pageParam, labelParam], + responses: { '204': { description: 'Unassigned' } }, + }, + }, + '/ponds/{pondSlug}/pages/{pageSlug}/comments': { + get: { + summary: 'Comment threads of the page.', + parameters: [ + pondParam, + pageParam, + { + name: 'filter', + in: 'query', + schema: { type: 'string', enum: ['all', 'open', 'resolved'] }, + }, + ], + responses: { '200': jsonResponse('Threads', ref('PageComments')) }, + }, + post: { + summary: "Comment on the page (write scope; the pond's comment policy applies).", + parameters: [pondParam, pageParam], + requestBody: jsonBody(ref('CreateComment')), + responses: { '201': jsonResponse('Created comment', ref('Comment')) }, + }, + }, + '/ponds/{pondSlug}/pages/{pageSlug}/comments/{commentId}/resolve': { + post: { + summary: 'Resolve a comment thread (write scope).', + parameters: [pondParam, pageParam, commentParam], + responses: { '201': jsonResponse('Resolved comment', ref('Comment')) }, + }, + delete: { + summary: 'Reopen a resolved thread (write scope).', + parameters: [pondParam, pageParam, commentParam], + responses: { '200': jsonResponse('Reopened comment', ref('Comment')) }, + }, + }, + }, + }; +} diff --git a/apps/api/src/public-api/public-api-docs.controller.ts b/apps/api/src/public-api/public-api-docs.controller.ts new file mode 100644 index 0000000..7017091 --- /dev/null +++ b/apps/api/src/public-api/public-api-docs.controller.ts @@ -0,0 +1,22 @@ +import { Controller, Get, NotFoundException } from '@nestjs/common'; + +import { Public } from '../auth/auth.guard'; +import { InstanceSettingsService } from '../settings/instance-settings.service'; +import { buildOpenApiDocument } from './openapi'; + +/** + * The OpenAPI document of the public API (issue #104). Reachable without a + * token — it is documentation — but only while the instance switch is on: + * a disabled instance stays indistinguishable from one without the feature. + */ +@Controller('api/public/v1') +@Public() +export class PublicApiDocsController { + constructor(private readonly settings: InstanceSettingsService) {} + + @Get('openapi.json') + async document(): Promise { + if (!(await this.settings.get('api.enabled'))) throw new NotFoundException(); + return buildOpenApiDocument(); + } +} diff --git a/apps/api/src/public-api/public-api.controller.ts b/apps/api/src/public-api/public-api.controller.ts new file mode 100644 index 0000000..4813f7b --- /dev/null +++ b/apps/api/src/public-api/public-api.controller.ts @@ -0,0 +1,315 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + Param, + Patch, + Post, + Put, + Query, + Req, + Res, + UseGuards, +} from '@nestjs/common'; +import { + commentListQuerySchema, + createCommentInputSchema, + createLabelInputSchema, + publicCreatePageInputSchema, + publicSearchQuerySchema, + publicUpdateLabelInputSchema, + publicUpdatePageInputSchema, + type CreateCommentInput, + type CreateLabelInput, + type LabelTreeNode, + type LabelView, + type PageCommentsView, + type PublicCommentView, + type PublicCreatePageInput, + type PublicMeView, + type PublicPageListItemView, + type PublicPageView, + type PublicPondView, + type PublicSearchQuery, + type PublicSearchResultView, + type PublicUpdateLabelInput, + type PublicUpdatePageInput, +} from '@dorfteich/shared'; +import type { Response } from 'express'; + +import { Public } from '../auth/auth.guard'; +import { ZodValidationPipe } from '../common/zod-validation.pipe'; +import { ExportService } from '../import-export/export.service'; +import { RequiresPagePermission, RequiresPondRole } from '../permissions/permission.decorators'; +import { PublicApiGuard, RequiresWriteScope, type PublicApiRequest } from './public-api.guard'; +import { PublicApiService } from './public-api.service'; + +/** Shorthands: every page route names the page the same way. */ +const PAGE = { pondSlugParam: 'pondSlug', slugParam: 'pageSlug' } as const; +const POND = { slugParam: 'pondSlug' } as const; + +/** + * The public REST API v1 (issue #104), served outside the SPA prefix at + * `/api/public/v1` (main.ts excludes it from the global prefix). `@Public()` + * only skips the cookie-session AuthGuard — the {@link PublicApiGuard} + * enforces PAT bearer auth, the instance switch, scope, per-token rate + * limits, and the pond opt-in; the method-level permission decorators then + * apply the unchanged permission model (404-vs-403 per #60) as the token's + * user. + */ +@Controller('api/public/v1') +@Public() +@UseGuards(PublicApiGuard) +export class PublicApiController { + constructor( + private readonly publicApi: PublicApiService, + private readonly exports: ExportService, + ) {} + + @Get('me') + me(@Req() request: PublicApiRequest): Promise { + return this.publicApi.me(request.user!, request.apiToken!); + } + + @Get('ponds') + listPonds(@Req() request: PublicApiRequest): Promise { + return this.publicApi.listPonds(request.user!, request.apiToken!); + } + + @Get('ponds/:pondSlug') + @RequiresPondRole('reader', POND) + getPond(@Param('pondSlug') pondSlug: string): Promise { + return this.publicApi.getPond(pondSlug); + } + + @Get('ponds/:pondSlug/pages') + @RequiresPondRole('reader', POND) + listPages( + @Param('pondSlug') pondSlug: string, + @Req() request: PublicApiRequest, + ): Promise { + return this.publicApi.listPages(request.user!, pondSlug); + } + + @Post('ponds/:pondSlug/pages') + @RequiresWriteScope() + @RequiresPondRole('editor', POND) + createPage( + @Param('pondSlug') pondSlug: string, + @Body(new ZodValidationPipe(publicCreatePageInputSchema)) input: PublicCreatePageInput, + @Req() request: PublicApiRequest, + ): Promise { + return this.publicApi.createPage(request.user!, request.apiToken!, pondSlug, input); + } + + @Get('ponds/:pondSlug/pages/:pageSlug') + @RequiresPagePermission('read', PAGE) + getPage( + @Param('pondSlug') pondSlug: string, + @Param('pageSlug') pageSlug: string, + ): Promise { + return this.publicApi.getPage(pondSlug, pageSlug); + } + + @Patch('ponds/:pondSlug/pages/:pageSlug') + @RequiresWriteScope() + @RequiresPagePermission('write', PAGE) + updatePage( + @Param('pondSlug') pondSlug: string, + @Param('pageSlug') pageSlug: string, + @Body(new ZodValidationPipe(publicUpdatePageInputSchema)) input: PublicUpdatePageInput, + @Req() request: PublicApiRequest, + ): Promise { + return this.publicApi.updatePage(request.user!, request.apiToken!, pondSlug, pageSlug, input); + } + + @Delete('ponds/:pondSlug/pages/:pageSlug') + @RequiresWriteScope() + @RequiresPagePermission('write', PAGE) + @HttpCode(204) + async deletePage( + @Param('pondSlug') pondSlug: string, + @Param('pageSlug') pageSlug: string, + @Req() request: PublicApiRequest, + ): Promise { + await this.publicApi.deletePage(request.user!, request.apiToken!, pondSlug, pageSlug); + } + + @Get('search') + search( + @Query('q') q: string, + @Query('pond') pond: string | undefined, + @Query('label') label: string | undefined, + @Req() request: PublicApiRequest, + ): Promise { + const query: PublicSearchQuery = new ZodValidationPipe(publicSearchQuerySchema).transform({ + q, + pond: pond || undefined, + label: label || undefined, + }); + return this.publicApi.searchPages(request.user!, request.apiToken!, query); + } + + @Get('ponds/:pondSlug/export/markdown') + @RequiresPondRole('reader', POND) + async exportMarkdown( + @Param('pondSlug') pondSlug: string, + @Req() request: PublicApiRequest, + @Res() res: Response, + ): Promise { + const pond = await this.publicApi.requirePond(pondSlug); + await this.exports.streamPondMarkdownZip(request.user!, pond.id, res); + } + + @Get('ponds/:pondSlug/labels') + @RequiresPondRole('reader', POND) + listLabels( + @Param('pondSlug') pondSlug: string, + @Req() request: PublicApiRequest, + ): Promise { + return this.publicApi.listLabels(request.user!, pondSlug); + } + + @Post('ponds/:pondSlug/labels') + @RequiresWriteScope() + @RequiresPondRole('pond_admin', POND) + createLabel( + @Param('pondSlug') pondSlug: string, + @Body(new ZodValidationPipe(createLabelInputSchema)) input: CreateLabelInput, + @Req() request: PublicApiRequest, + ): Promise { + return this.publicApi.createLabel(request.user!, request.apiToken!, pondSlug, input); + } + + @Patch('ponds/:pondSlug/labels/:labelId') + @RequiresWriteScope() + @RequiresPondRole('pond_admin', POND) + updateLabel( + @Param('pondSlug') pondSlug: string, + @Param('labelId') labelId: string, + @Body(new ZodValidationPipe(publicUpdateLabelInputSchema)) input: PublicUpdateLabelInput, + @Req() request: PublicApiRequest, + ): Promise { + return this.publicApi.updateLabel(request.user!, request.apiToken!, pondSlug, labelId, input); + } + + @Delete('ponds/:pondSlug/labels/:labelId') + @RequiresWriteScope() + @RequiresPondRole('pond_admin', POND) + @HttpCode(204) + async deleteLabel( + @Param('pondSlug') pondSlug: string, + @Param('labelId') labelId: string, + @Req() request: PublicApiRequest, + ): Promise { + await this.publicApi.deleteLabel(request.user!, request.apiToken!, pondSlug, labelId); + } + + @Put('ponds/:pondSlug/pages/:pageSlug/labels/:labelId') + @RequiresWriteScope() + @RequiresPagePermission('write', PAGE) + assignLabel( + @Param('pondSlug') pondSlug: string, + @Param('pageSlug') pageSlug: string, + @Param('labelId') labelId: string, + @Req() request: PublicApiRequest, + ): Promise { + return this.publicApi.assignLabel( + request.user!, + request.apiToken!, + pondSlug, + pageSlug, + labelId, + ); + } + + @Delete('ponds/:pondSlug/pages/:pageSlug/labels/:labelId') + @RequiresWriteScope() + @RequiresPagePermission('write', PAGE) + @HttpCode(204) + async unassignLabel( + @Param('pondSlug') pondSlug: string, + @Param('pageSlug') pageSlug: string, + @Param('labelId') labelId: string, + @Req() request: PublicApiRequest, + ): Promise { + await this.publicApi.unassignLabel( + request.user!, + request.apiToken!, + pondSlug, + pageSlug, + labelId, + ); + } + + @Get('ponds/:pondSlug/pages/:pageSlug/comments') + @RequiresPagePermission('read', PAGE) + listComments( + @Param('pondSlug') pondSlug: string, + @Param('pageSlug') pageSlug: string, + @Query('filter') filter: string | undefined, + ): Promise { + const parsed = commentListQuerySchema.parse({ filter: filter || undefined }); + return this.publicApi.listComments(pondSlug, pageSlug, parsed.filter); + } + + @Post('ponds/:pondSlug/pages/:pageSlug/comments') + @RequiresWriteScope() + // Read at route level: whether the token's user may COMMENT is the pond's + // comment policy, enforced in CommentsService (readers vs editors, #91). + @RequiresPagePermission('read', PAGE) + createComment( + @Param('pondSlug') pondSlug: string, + @Param('pageSlug') pageSlug: string, + @Body(new ZodValidationPipe(createCommentInputSchema)) input: CreateCommentInput, + @Req() request: PublicApiRequest, + ): Promise { + return this.publicApi.createComment( + request.user!, + request.apiToken!, + pondSlug, + pageSlug, + input, + ); + } + + @Post('ponds/:pondSlug/pages/:pageSlug/comments/:commentId/resolve') + @RequiresWriteScope() + @RequiresPagePermission('read', PAGE) + resolveComment( + @Param('pondSlug') pondSlug: string, + @Param('pageSlug') pageSlug: string, + @Param('commentId') commentId: string, + @Req() request: PublicApiRequest, + ): Promise { + return this.publicApi.setCommentResolved( + request.user!, + request.apiToken!, + pondSlug, + pageSlug, + commentId, + true, + ); + } + + @Delete('ponds/:pondSlug/pages/:pageSlug/comments/:commentId/resolve') + @RequiresWriteScope() + @RequiresPagePermission('read', PAGE) + unresolveComment( + @Param('pondSlug') pondSlug: string, + @Param('pageSlug') pageSlug: string, + @Param('commentId') commentId: string, + @Req() request: PublicApiRequest, + ): Promise { + return this.publicApi.setCommentResolved( + request.user!, + request.apiToken!, + pondSlug, + pageSlug, + commentId, + false, + ); + } +} diff --git a/apps/api/src/public-api/public-api.e2e.db.test.ts b/apps/api/src/public-api/public-api.e2e.db.test.ts new file mode 100644 index 0000000..a151f5f --- /dev/null +++ b/apps/api/src/public-api/public-api.e2e.db.test.ts @@ -0,0 +1,575 @@ +import { INestApplication } from '@nestjs/common'; +import { + API_TOKEN_PREFIX, + PAGE_RESTORE_CHANNEL, + type ApiTokenCreatedView, + type ApiTokenView, + type LabelView, + type PublicPageView, + type PublicPondView, + type PublicSearchResultView, +} from '@dorfteich/shared'; +import { PrismaClient } from '@prisma/client'; +import { Client } from 'pg'; +import request from 'supertest'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { InstanceSettingsService } from '../settings/instance-settings.service'; +import { createTestApp, sessionCookieOf } from '../testing/test-app'; +import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; +import { UsersService } from '../users/users.service'; + +/** + * Public REST API v1 end to end (issue #104): the token lifecycle, the + * instance switch and pond opt-in (404 semantics per #60), the token + * permission matrix (reader/editor/outsider × read/write scope), the page + * roundtrip through the shared Markdown pipeline (content replacement via + * the collab restore NOTIFY), labels, comments, search narrowing, and the + * per-token rate limit. The web session API never appears after setup — + * everything runs on bearer tokens. + */ +describe.skipIf(!hasTestDb)('public api v1 (e2e, issue #104)', () => { + let app: INestApplication; + let prisma: PrismaClient; + const suffix = uniqueSuffix(); + const password = 'publicapi ist stabil 1'; + const ids: Record = {}; + const cookies: Record = {}; + const tokens: Record = {}; + let pondId: string; + let pondSlug: string; + let hiddenPondSlug: string; + + const restoreNotifies: { pageId: string; versionId: string }[] = []; + let listenClient: Client; + + const api = () => request(app.getHttpServer()); + const pub = () => request(app.getHttpServer()); + const bearer = (who: string): string => `Bearer ${tokens[who]}`; + + async function makeUser(handle: string): Promise { + const users = app.get(UsersService); + const username = `pub-${handle}-${suffix}`; + const user = await users.createUser({ + username, + email: `${username}@example.org`, + displayName: `Pub ${handle}`, + password, + locale: 'en', + }); + await users.markEmailVerified(user.id); + ids[handle] = user.id; + cookies[handle] = sessionCookieOf( + await api() + .post('/api/v1/auth/login') + .send({ usernameOrEmail: username, password }) + .expect(200), + ); + } + + async function mintToken( + who: string, + scope: 'read' | 'write', + extra: Record = {}, + ): Promise { + const res = await api() + .post('/api/v1/users/me/api-tokens') + .set('Cookie', cookies[who]!) + .send({ name: `${who}-${scope}`, scope, ...extra }) + .expect(201); + return res.body as ApiTokenCreatedView; + } + + beforeAll(async () => { + prisma = createTestPrisma(); + await prisma.rateLimit.deleteMany({}); + app = await createTestApp(); + + for (const handle of ['owner', 'editor', 'reader', 'outsider', 'siteadmin']) { + await makeUser(handle); + } + await prisma.user.update({ where: { id: ids.siteadmin! }, data: { isSiteAdmin: true } }); + // Shared ponds need quota — a per-user override, never the instance + // default (raising that races the quota suites sharing this database). + await api() + .put(`/api/v1/admin/quotas/user/${ids.owner!}/additional_ponds`) + .set('Cookie', cookies.siteadmin!) + .send({ value: 100 }) + .expect(200); + + // The test pond (API opt-in comes later) and a hidden pond that never + // opts in — its content must stay invisible through the public API. + const pond = await api() + .post('/api/v1/ponds') + .set('Cookie', cookies.owner!) + .send({ name: `Public API Pond ${suffix}` }) + .expect(201); + pondId = pond.body.id; + pondSlug = pond.body.slug; + const hidden = await api() + .post('/api/v1/ponds') + .set('Cookie', cookies.owner!) + .send({ name: `Hidden Pond ${suffix}` }) + .expect(201); + hiddenPondSlug = hidden.body.slug; + + // Grants through the API — raw rows would bypass the permission cache. + for (const [handle, role] of [ + ['editor', 'editor'], + ['reader', 'reader'], + ] as const) { + await api() + .post(`/api/v1/ponds/${pondId}/grants`) + .set('Cookie', cookies.owner!) + .send({ + subjectType: 'user', + subjectId: ids[handle], + role, + scopeType: 'pond', + effect: 'allow', + }) + .expect(201); + } + + tokens.owner = (await mintToken('owner', 'write')).token; + tokens.editor = (await mintToken('editor', 'write')).token; + tokens.editorRead = (await mintToken('editor', 'read')).token; + tokens.reader = (await mintToken('reader', 'write')).token; + tokens.outsider = (await mintToken('outsider', 'write')).token; + + listenClient = new Client({ connectionString: process.env.TEST_DATABASE_URL }); + await listenClient.connect(); + listenClient.on('notification', (message) => { + if (message.channel === PAGE_RESTORE_CHANNEL && message.payload) { + restoreNotifies.push(JSON.parse(message.payload) as { pageId: string; versionId: string }); + } + }); + await listenClient.query(`LISTEN ${PAGE_RESTORE_CHANNEL}`); + }); + + afterAll(async () => { + await listenClient.end().catch(() => undefined); + const all = Object.values(ids); + await prisma.instanceSetting.deleteMany({ where: { key: 'api.enabled' } }); + await prisma.quotaOverride.deleteMany({ where: { subjectId: { in: all } } }); + await prisma.auditEntry.deleteMany({ where: { actorId: { in: all } } }); + await prisma.apiToken.deleteMany({ where: { userId: { in: all } } }); + const ponds = await prisma.pond.findMany({ + where: { OR: [{ ownerId: { in: all } }] }, + select: { id: true }, + }); + const pondIds = ponds.map((p) => p.id); + await prisma.comment.deleteMany({ where: { page: { pondId: { in: pondIds } } } }); + await prisma.pageVersion.deleteMany({ where: { page: { pondId: { in: pondIds } } } }); + await prisma.page.deleteMany({ where: { pondId: { in: pondIds } } }); + await prisma.label.deleteMany({ where: { pondId: { in: pondIds } } }); + await prisma.roleGrant.deleteMany({ where: { pondId: { in: pondIds } } }); + await prisma.pondUsage.deleteMany({ where: { pondId: { in: pondIds } } }); + await prisma.pond.deleteMany({ where: { id: { in: pondIds } } }); + await prisma.session.deleteMany({ where: { userId: { in: all } } }); + await prisma.userIdentity.deleteMany({ where: { userId: { in: all } } }); + await prisma.rateLimit.deleteMany({}); + await prisma.user.deleteMany({ where: { id: { in: all } } }); + await prisma.$disconnect(); + await app.close(); + }); + + it('answers 404 everywhere while the instance switch is off, then opens', async () => { + await pub().get('/api/public/v1/me').set('Authorization', bearer('owner')).expect(404); + await pub().get('/api/public/v1/openapi.json').expect(404); + + await app.get(InstanceSettingsService).set('api.enabled', true, ids.owner!); + await pub().get('/api/public/v1/me').set('Authorization', bearer('owner')).expect(200); + const doc = await pub().get('/api/public/v1/openapi.json').expect(200); + expect(doc.body.openapi).toBe('3.1.0'); + }); + + it('runs the token lifecycle: secret once, list without secret, revoke kills access', async () => { + const created = await mintToken('owner', 'read', { name: `lifecycle-${suffix}` }); + expect(created.token.startsWith(API_TOKEN_PREFIX)).toBe(true); + + const list = await api() + .get('/api/v1/users/me/api-tokens') + .set('Cookie', cookies.owner!) + .expect(200); + const entry = (list.body as ApiTokenView[]).find((t) => t.id === created.id)!; + expect(entry).toBeDefined(); + expect(JSON.stringify(entry)).not.toContain(created.token); + + await pub() + .get('/api/public/v1/me') + .set('Authorization', `Bearer ${created.token}`) + .expect(200); + await api() + .delete(`/api/v1/users/me/api-tokens/${created.id}`) + .set('Cookie', cookies.owner!) + .expect(204); + await pub() + .get('/api/public/v1/me') + .set('Authorization', `Bearer ${created.token}`) + .expect(401); + + // Another user cannot revoke my token. + const mine = await mintToken('owner', 'read', { name: `foreign-${suffix}` }); + await api() + .delete(`/api/v1/users/me/api-tokens/${mine.id}`) + .set('Cookie', cookies.outsider!) + .expect(404); + }); + + it('rejects expired tokens and garbage', async () => { + const expired = await mintToken('owner', 'read', { + name: `expired-${suffix}`, + expiresAt: new Date(Date.now() - 1000).toISOString(), + }); + await pub() + .get('/api/public/v1/me') + .set('Authorization', `Bearer ${expired.token}`) + .expect(401); + await pub().get('/api/public/v1/me').set('Authorization', 'Bearer dt_pat_nonsense').expect(401); + await pub().get('/api/public/v1/me').expect(401); + }); + + it('hides ponds that did not opt in, then exposes after the pond opt-in', async () => { + // No pond has opted in yet: list empty, direct access 404 for everyone. + const empty = await pub() + .get('/api/public/v1/ponds') + .set('Authorization', bearer('owner')) + .expect(200); + expect(empty.body).toEqual([]); + await pub() + .get(`/api/public/v1/ponds/${pondSlug}`) + .set('Authorization', bearer('owner')) + .expect(404); + + await api() + .patch(`/api/v1/ponds/${pondId}`) + .set('Cookie', cookies.owner!) + .send({ apiEnabled: true }) + .expect(200); + + const ponds = await pub() + .get('/api/public/v1/ponds') + .set('Authorization', bearer('owner')) + .expect(200); + expect((ponds.body as PublicPondView[]).map((p) => p.slug)).toEqual([pondSlug]); + // The hidden pond stays 404 — also for its own owner's token. + await pub() + .get(`/api/public/v1/ponds/${hiddenPondSlug}`) + .set('Authorization', bearer('owner')) + .expect(404); + }); + + it('applies the permission matrix as the token user', async () => { + // Outsider: the pond reads as nonexistent. + await pub() + .get(`/api/public/v1/ponds/${pondSlug}`) + .set('Authorization', bearer('outsider')) + .expect(404); + await pub() + .post(`/api/public/v1/ponds/${pondSlug}/pages`) + .set('Authorization', bearer('outsider')) + .send({ title: 'Nope', markdown: '' }) + .expect(404); + + // Reader: sees, cannot write (403 — the pond is visible to them). + await pub() + .get(`/api/public/v1/ponds/${pondSlug}`) + .set('Authorization', bearer('reader')) + .expect(200); + await pub() + .post(`/api/public/v1/ponds/${pondSlug}/pages`) + .set('Authorization', bearer('reader')) + .send({ title: 'Nope', markdown: '' }) + .expect(403); + + // Editor with a read-only token: scope blocks before permissions. + const scoped = await pub() + .post(`/api/public/v1/ponds/${pondSlug}/pages`) + .set('Authorization', bearer('editorRead')) + .send({ title: 'Nope', markdown: '' }) + .expect(403); + expect(scoped.body.code).toBe('scope_required'); + }); + + it('restricts a pond-bound token to its ponds', async () => { + const bound = await mintToken('owner', 'read', { + name: `bound-${suffix}`, + pondIds: [pondId], + }); + await pub() + .get(`/api/public/v1/ponds/${pondSlug}`) + .set('Authorization', `Bearer ${bound.token}`) + .expect(200); + + // Bind to the hidden pond only: the opted-in pond becomes invisible. + const hiddenId = (await prisma.pond.findFirst({ where: { slug: hiddenPondSlug } }))!.id; + const boundElsewhere = await mintToken('owner', 'read', { + name: `bound2-${suffix}`, + pondIds: [hiddenId], + }); + await pub() + .get(`/api/public/v1/ponds/${pondSlug}`) + .set('Authorization', `Bearer ${boundElsewhere.token}`) + .expect(404); + const list = await pub() + .get('/api/public/v1/ponds') + .set('Authorization', `Bearer ${boundElsewhere.token}`) + .expect(200); + expect(list.body).toEqual([]); + + // Restrictions may only name visible ponds. + await api() + .post('/api/v1/users/me/api-tokens') + .set('Cookie', cookies.outsider!) + .send({ name: 'sneaky', scope: 'read', pondIds: [pondId] }) + .expect(404); + }); + + it('round-trips a page through Markdown, replaces content via the collab path', async () => { + const markdown = '# Heading\n\nHello **world** from the API.\n'; + const created = await pub() + .post(`/api/public/v1/ponds/${pondSlug}/pages`) + .set('Authorization', bearer('editor')) + .send({ title: `API Page ${suffix}`, markdown }) + .expect(201); + const page = created.body as PublicPageView; + expect(page.markdown).toContain('Hello **world**'); + expect(page.html).toContain('world'); + + // Listed with metadata; readable by the reader token. + const listed = await pub() + .get(`/api/public/v1/ponds/${pondSlug}/pages`) + .set('Authorization', bearer('reader')) + .expect(200); + expect(listed.body.map((p: { slug: string }) => p.slug)).toContain(page.slug); + + // Title-only update. + const renamed = await pub() + .patch(`/api/public/v1/ponds/${pondSlug}/pages/${page.slug}`) + .set('Authorization', bearer('editor')) + .send({ title: `Renamed ${suffix}` }) + .expect(200); + expect((renamed.body as PublicPageView).title).toBe(`Renamed ${suffix}`); + + // Content replacement: lands as a MANUAL version + restore NOTIFY (the + // collab server applies it in the running stack — no second lineage). + restoreNotifies.length = 0; + await pub() + .patch(`/api/public/v1/ponds/${pondSlug}/pages/${page.slug}`) + .set('Authorization', bearer('editor')) + .send({ markdown: 'Replaced content.' }) + .expect(200); + const pageRow = await prisma.page.findFirst({ where: { pondId, slug: page.slug } }); + const versions = await prisma.pageVersion.findMany({ + where: { pageId: pageRow!.id, trigger: 'MANUAL' }, + }); + expect(versions.some((v) => v.label === 'API update')).toBe(true); + await new Promise((resolve) => setTimeout(resolve, 200)); + expect(restoreNotifies.some((n) => n.pageId === pageRow!.id)).toBe(true); + + // Reader token cannot modify, outsider cannot even see. + await pub() + .patch(`/api/public/v1/ponds/${pondSlug}/pages/${page.slug}`) + .set('Authorization', bearer('reader')) + .send({ title: 'nope' }) + .expect(403); + await pub() + .get(`/api/public/v1/ponds/${pondSlug}/pages/${page.slug}`) + .set('Authorization', bearer('outsider')) + .expect(404); + + // Bearer requests ignore Origin — no cookie, no CSRF surface. + await pub() + .patch(`/api/public/v1/ponds/${pondSlug}/pages/${page.slug}`) + .set('Authorization', bearer('editor')) + .set('Origin', 'https://evil.example') + .send({ title: `Renamed again ${suffix}` }) + .expect(200); + + // Trash it; afterwards it reads as gone. + await pub() + .delete(`/api/public/v1/ponds/${pondSlug}/pages/${page.slug}`) + .set('Authorization', bearer('editor')) + .expect(204); + await pub() + .get(`/api/public/v1/ponds/${pondSlug}/pages/${page.slug}`) + .set('Authorization', bearer('reader')) + .expect(404); + + // The writes left an audit trail attributed to the token. + const audit = await prisma.auditEntry.findMany({ + where: { action: 'api.write', actorId: ids.editor! }, + }); + const ops = audit.map((entry) => (entry.details as { op?: string }).op); + expect(ops).toEqual(expect.arrayContaining(['page_created', 'page_updated', 'page_trashed'])); + }); + + it('manages labels with pond-admin rights and assigns them to pages', async () => { + const label = await pub() + .post(`/api/public/v1/ponds/${pondSlug}/labels`) + .set('Authorization', bearer('owner')) + .send({ name: `api-label-${suffix}` }) + .expect(201); + const labelId = (label.body as LabelView).id; + + // Editor is not Pond Admin → 403; reader token lists fine. + await pub() + .post(`/api/public/v1/ponds/${pondSlug}/labels`) + .set('Authorization', bearer('editor')) + .send({ name: 'nope' }) + .expect(403); + const tree = await pub() + .get(`/api/public/v1/ponds/${pondSlug}/labels`) + .set('Authorization', bearer('reader')) + .expect(200); + expect(JSON.stringify(tree.body)).toContain(`api-label-${suffix}`); + + // Rename + recolor + move-to-root in one PATCH. + const renamed = await pub() + .patch(`/api/public/v1/ponds/${pondSlug}/labels/${labelId}`) + .set('Authorization', bearer('owner')) + .send({ name: `api-label-2-${suffix}`, color: '#ff0000', parentId: null }) + .expect(200); + expect((renamed.body as LabelView).name).toBe(`api-label-2-${suffix}`); + + // Assign to a fresh page, unassign, delete the label. + const page = await pub() + .post(`/api/public/v1/ponds/${pondSlug}/pages`) + .set('Authorization', bearer('editor')) + .send({ title: `Labelled ${suffix}`, markdown: 'labelled' }) + .expect(201); + const assigned = await pub() + .put(`/api/public/v1/ponds/${pondSlug}/pages/${page.body.slug}/labels/${labelId}`) + .set('Authorization', bearer('editor')) + .expect(200); + expect((assigned.body as LabelView[]).map((l) => l.id)).toContain(labelId); + const view = await pub() + .get(`/api/public/v1/ponds/${pondSlug}/pages/${page.body.slug}`) + .set('Authorization', bearer('reader')) + .expect(200); + expect((view.body as PublicPageView).labels).toContain(`api-label-2-${suffix}`); + await pub() + .delete(`/api/public/v1/ponds/${pondSlug}/pages/${page.body.slug}/labels/${labelId}`) + .set('Authorization', bearer('editor')) + .expect(204); + await pub() + .delete(`/api/public/v1/ponds/${pondSlug}/labels/${labelId}`) + .set('Authorization', bearer('owner')) + .expect(204); + }); + + it('supports comment threads including resolve, honoring the comment policy', async () => { + const page = await pub() + .post(`/api/public/v1/ponds/${pondSlug}/pages`) + .set('Authorization', bearer('editor')) + .send({ title: `Commented ${suffix}`, markdown: 'discuss' }) + .expect(201); + const path = `/api/public/v1/ponds/${pondSlug}/pages/${page.body.slug}/comments`; + + // Default policy: readers may comment (write scope still required). + const comment = await pub() + .post(path) + .set('Authorization', bearer('reader')) + .send({ body: 'A **question** from the API' }) + .expect(201); + expect(comment.body.html).toContain('question'); + + const listed = await pub().get(path).set('Authorization', bearer('reader')).expect(200); + expect(listed.body.openCount).toBe(1); + + await pub() + .post(`${path}/${comment.body.id}/resolve`) + .set('Authorization', bearer('editor')) + .expect(201); + const resolved = await pub() + .get(`${path}?filter=resolved`) + .set('Authorization', bearer('reader')) + .expect(200); + expect(resolved.body.resolvedCount).toBe(1); + await pub() + .delete(`${path}/${comment.body.id}/resolve`) + .set('Authorization', bearer('editor')) + .expect(200); + + // Editors-only policy blocks the reader with a readable 403. + await api() + .patch(`/api/v1/ponds/${pondId}`) + .set('Cookie', cookies.owner!) + .send({ commentPolicy: 'editors' }) + .expect(200); + const blocked = await pub() + .post(path) + .set('Authorization', bearer('reader')) + .send({ body: 'nope' }) + .expect(403); + expect(blocked.body.code).toBe('comments_editors_only'); + await api() + .patch(`/api/v1/ponds/${pondId}`) + .set('Cookie', cookies.owner!) + .send({ commentPolicy: 'readers' }) + .expect(200); + }); + + it('searches within exposed ponds only', async () => { + const needle = `wasserlilie${suffix}`; + await pub() + .post(`/api/public/v1/ponds/${pondSlug}/pages`) + .set('Authorization', bearer('editor')) + .send({ title: 'Findable', markdown: `The ${needle} blooms.` }) + .expect(201); + // Same content in the hidden pond — via the internal API (no opt-in). + await api() + .post(`/api/v1/ponds`) + .set('Cookie', cookies.owner!) + .send({ name: `ignored ${suffix}` }) + .expect(201); + const hiddenId = (await prisma.pond.findFirst({ where: { slug: hiddenPondSlug } }))!.id; + await api() + .post(`/api/v1/ponds/${hiddenId}/import`) + .set('Cookie', cookies.owner!) + .attach('file', Buffer.from(`# Secret\n\nThe ${needle} hides.`), 'secret.md') + .expect(201); + + const results = await pub() + .get(`/api/public/v1/search?q=${needle}`) + .set('Authorization', bearer('owner')) + .expect(200); + const hits = results.body as PublicSearchResultView[]; + expect(hits.length).toBeGreaterThan(0); + expect(hits.every((hit) => hit.pondSlug === pondSlug)).toBe(true); + expect(hits[0]!.snippet).toContain('**'); + }); + + it('exports the pond as a Markdown ZIP', async () => { + const res = await pub() + .get(`/api/public/v1/ponds/${pondSlug}/export/markdown`) + .set('Authorization', bearer('reader')) + .buffer(true) + .parse((response, callback) => { + const chunks: Buffer[] = []; + response.on('data', (chunk: Buffer) => chunks.push(chunk)); + response.on('end', () => callback(null, Buffer.concat(chunks))); + }) + .expect(200); + expect(res.headers['content-type']).toContain('zip'); + expect((res.body as Buffer).length).toBeGreaterThan(0); + }); + + it('rate-limits per token', async () => { + const throwaway = await mintToken('reader', 'read', { name: `ratelimit-${suffix}` }); + let limited = false; + for (let i = 0; i < 130 && !limited; i += 1) { + const res = await pub() + .get('/api/public/v1/me') + .set('Authorization', `Bearer ${throwaway.token}`); + if (res.status === 429) { + limited = true; + expect(res.headers['retry-after']).toBeDefined(); + } + } + expect(limited).toBe(true); + // Other tokens are unaffected. + await pub().get('/api/public/v1/me').set('Authorization', bearer('owner')).expect(200); + }); +}); diff --git a/apps/api/src/public-api/public-api.guard.ts b/apps/api/src/public-api/public-api.guard.ts new file mode 100644 index 0000000..fc4f9ff --- /dev/null +++ b/apps/api/src/public-api/public-api.guard.ts @@ -0,0 +1,118 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + HttpException, + HttpStatus, + Injectable, + NotFoundException, + SetMetadata, + UnauthorizedException, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { pondSettingsSchema, type ApiTokenScope } from '@dorfteich/shared'; +import type { ApiToken } from '@prisma/client'; +import type { Response } from 'express'; + +import { AuthedRequest } from '../auth/auth.guard'; +import { PrismaService } from '../prisma/prisma.service'; +import { RateLimitService } from '../rate-limit/rate-limit.service'; +import { InstanceSettingsService } from '../settings/instance-settings.service'; +import { ApiTokensService } from './api-tokens.service'; + +const SCOPE_KEY = 'publicApiScope'; + +/** Marks a public-API route as requiring the write scope (read is default). */ +export const RequiresWriteScope = (): MethodDecorator => SetMetadata(SCOPE_KEY, 'write'); + +/** Fixed per-token budget — generous for scripts, hard stop for runaways. */ +const RATE_LIMIT = { limit: 120, windowSeconds: 60 }; + +export interface PublicApiRequest extends AuthedRequest { + apiToken?: ApiToken; + apiTokenScope?: ApiTokenScope; +} + +/** + * The gate in front of every public-API route (issue #104): + * + * 1. Instance switch `api.enabled` — off means the whole surface answers + * 404, indistinguishable from an instance without the feature. + * 2. Bearer PAT authentication (`Authorization: Bearer dt_pat_…`); the + * token's user lands on the request, so the shared PermissionGuard and + * services apply the normal permission model unchanged. No cookies are + * involved anywhere, hence no CSRF surface. + * 3. Per-token rate limit (fixed window, PostgreSQL-backed). + * 4. Scope: routes marked {@link RequiresWriteScope} reject read-only + * tokens with 403 `scope_required`. + * 5. Pond opt-in + token pond restriction whenever the route names a pond + * (`:pondSlug`): a pond that did not opt in — or that the token was + * restricted away from — answers 404 (the #60 policy: existence stays + * hidden). + */ +@Injectable() +export class PublicApiGuard implements CanActivate { + constructor( + private readonly reflector: Reflector, + private readonly settings: InstanceSettingsService, + private readonly tokens: ApiTokensService, + private readonly rateLimits: RateLimitService, + private readonly prisma: PrismaService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + if (!(await this.settings.get('api.enabled'))) throw new NotFoundException(); + + const request = context.switchToHttp().getRequest(); + const header = request.headers.authorization ?? ''; + const raw = header.startsWith('Bearer ') ? header.slice('Bearer '.length).trim() : ''; + const validated = raw ? await this.tokens.validate(raw) : null; + if (!validated) throw new UnauthorizedException(); + + const limited = await this.rateLimits.hit( + 'public_api', + `token:${validated.token.id}`, + RATE_LIMIT.limit, + RATE_LIMIT.windowSeconds, + ); + if (!limited.allowed) { + context + .switchToHttp() + .getResponse() + .setHeader('Retry-After', String(limited.retryAfterSeconds)); + throw new HttpException({ code: 'rate_limited' }, HttpStatus.TOO_MANY_REQUESTS); + } + + request.user = validated.user; + request.apiToken = validated.token; + request.apiTokenScope = this.tokens.scopeOf(validated.token); + + const required = this.reflector.getAllAndOverride(SCOPE_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (required === 'write' && request.apiTokenScope !== 'write') { + throw new ForbiddenException({ code: 'scope_required' }); + } + + const pondSlug = (request.params as Record).pondSlug; + if (pondSlug) { + await this.assertPondExposed(pondSlug, validated.token); + } + return true; + } + + /** The pond must have opted in AND be within the token's restriction. */ + private async assertPondExposed(slug: string, token: ApiToken): Promise { + const pond = await this.prisma.pond.findFirst({ + where: { slug, deletedAt: null }, + select: { id: true, settings: true }, + }); + if (!pond) throw new NotFoundException(); + const settings = pondSettingsSchema.safeParse(pond.settings ?? {}); + if (!settings.success || !settings.data.apiEnabled) throw new NotFoundException(); + if (token.pondIds.length > 0 && !token.pondIds.includes(pond.id)) { + throw new NotFoundException(); + } + } +} diff --git a/apps/api/src/public-api/public-api.module.ts b/apps/api/src/public-api/public-api.module.ts new file mode 100644 index 0000000..e074cf6 --- /dev/null +++ b/apps/api/src/public-api/public-api.module.ts @@ -0,0 +1,44 @@ +import { Module } from '@nestjs/common'; + +import { AuditModule } from '../audit/audit.module'; +import { CommentsModule } from '../comments/comments.module'; +import { ImportExportModule } from '../import-export/import-export.module'; +import { LabelsModule } from '../labels/labels.module'; +import { PagesModule } from '../pages/pages.module'; +import { PermissionsModule } from '../permissions/permissions.module'; +import { PondsModule } from '../ponds/ponds.module'; +import { RateLimitModule } from '../rate-limit/rate-limit.module'; +import { SearchModule } from '../search/search.module'; +import { SettingsModule } from '../settings/settings.module'; +import { VersionsModule } from '../versions/versions.module'; +import { ApiTokensController } from './api-tokens.controller'; +import { ApiTokensService } from './api-tokens.service'; +import { PublicApiDocsController } from './public-api-docs.controller'; +import { PublicApiController } from './public-api.controller'; +import { PublicApiGuard } from './public-api.guard'; +import { PublicApiService } from './public-api.service'; + +/** + * Public REST API v1 + personal access tokens (issue #104): thin + * controllers over the existing feature services; nothing here owns + * domain logic beyond the token lifecycle and the public wire shapes. + */ +@Module({ + imports: [ + SettingsModule, + PermissionsModule, + PondsModule, + PagesModule, + LabelsModule, + CommentsModule, + VersionsModule, + SearchModule, + ImportExportModule, + RateLimitModule, + AuditModule, + ], + controllers: [ApiTokensController, PublicApiController, PublicApiDocsController], + providers: [ApiTokensService, PublicApiService, PublicApiGuard], + exports: [ApiTokensService, PublicApiService], +}) +export class PublicApiModule {} diff --git a/apps/api/src/public-api/public-api.service.ts b/apps/api/src/public-api/public-api.service.ts new file mode 100644 index 0000000..c1d5914 --- /dev/null +++ b/apps/api/src/public-api/public-api.service.ts @@ -0,0 +1,405 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { + SEARCH_HIGHLIGHT_END, + SEARCH_HIGHLIGHT_START, + editorSchema, + markdownToDoc, + pondSettingsSchema, + type CommentListFilter, + type CreateCommentInput, + type CreateLabelInput, + type LabelTreeNode, + type LabelView, + type PageCommentsView, + type PublicCommentView, + type PublicCreatePageInput, + type PublicMeView, + type PublicPageListItemView, + type PublicPageView, + type PublicPondView, + type PublicSearchQuery, + type PublicSearchResultView, + type PublicUpdateLabelInput, + type PublicUpdatePageInput, + type PondView, +} from '@dorfteich/shared'; +import { Node } from 'prosemirror-model'; +import { ApiToken, Page, User } from '@prisma/client'; + +import { AuditService } from '../audit/audit.service'; +import { CommentsService } from '../comments/comments.service'; +import { LabelsService } from '../labels/labels.service'; +import { docToState } from '../pages/yjs-content'; +import { PagesService } from '../pages/pages.service'; +import { PondsService } from '../ponds/ponds.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { SearchProvider } from '../search/search.provider'; +import { VersionsService } from '../versions/versions.service'; +import { ApiTokensService } from './api-tokens.service'; + +/** + * The public REST API surface (issue #104): thin wrappers over the existing + * services — pond/page permission enforcement sits in the route decorators + * (the shared PermissionGuard) and in the services themselves; the guard in + * front (PublicApiGuard) already handled token auth, scope, rate limit, and + * the pond opt-in. This service adds the slug-based resolution, the public + * wire shapes, and the write audit trail. + */ +@Injectable() +export class PublicApiService { + constructor( + private readonly prisma: PrismaService, + private readonly ponds: PondsService, + private readonly pages: PagesService, + private readonly labels: LabelsService, + private readonly comments: CommentsService, + private readonly versions: VersionsService, + private readonly search: SearchProvider, + private readonly tokens: ApiTokensService, + private readonly audit: AuditService, + ) {} + + async me(user: User, token: ApiToken): Promise { + const ponds = + token.pondIds.length === 0 + ? [] + : await this.prisma.pond.findMany({ + where: { id: { in: token.pondIds }, deletedAt: null }, + select: { slug: true }, + }); + return { + user: { id: user.id, username: user.username, displayName: user.displayName }, + scope: this.tokens.scopeOf(token), + pondSlugs: ponds.map((pond) => pond.slug).sort(), + }; + } + + /** The API-enabled ponds visible to the token's user, within restriction. */ + async listPonds(user: User, token: ApiToken): Promise { + const visible = await this.ponds.listVisible(user); + return visible + .filter((pond) => pond.settings.apiEnabled) + .filter((pond) => token.pondIds.length === 0 || token.pondIds.includes(pond.id)) + .map((pond) => this.pondView(pond)); + } + + async getPond(slug: string): Promise { + const pond = await this.requirePond(slug); + return this.pondView(pond); + } + + async listPages(user: User, pondSlug: string): Promise { + const pond = await this.requirePond(pondSlug); + const [items, labelNames] = await Promise.all([ + this.pages.list(user, pond.id), + this.labelNames(pond.id), + ]); + return items.map((item) => ({ + slug: item.slug, + title: item.title, + labels: item.labelIds.map((id) => labelNames.get(id) ?? id).sort(), + createdAt: item.createdAt, + updatedAt: item.updatedAt, + })); + } + + async getPage(pondSlug: string, pageSlug: string): Promise { + const page = await this.requirePage(pondSlug, pageSlug); + const [cache, pageLabels, labelNames] = await Promise.all([ + this.prisma.pageContentCache.findUnique({ where: { pageId: page.id } }), + this.prisma.pageLabel.findMany({ where: { pageId: page.id }, select: { labelId: true } }), + this.labelNames(page.pondId), + ]); + return { + slug: page.slug, + title: page.title, + pondSlug, + markdown: cache?.markdown ?? '', + html: cache?.html ?? '', + labels: pageLabels.map((row) => labelNames.get(row.labelId) ?? row.labelId).sort(), + createdAt: page.createdAt.toISOString(), + updatedAt: page.updatedAt.toISOString(), + }; + } + + async createPage( + user: User, + token: ApiToken, + pondSlug: string, + input: PublicCreatePageInput, + ): Promise { + const pond = await this.requirePond(pondSlug); + const state = this.stateFromMarkdown(input.markdown); + const page = await this.pages.createWithState(user, pond.id, input.title, state); + await this.auditWrite(user, token, 'page_created', page.id); + return this.getPage(pondSlug, page.slug); + } + + /** + * Title and/or content update. Content replacement travels through the + * collab-owned document path (a MANUAL version + the restore NOTIFY), so + * open editors converge and no second document lineage appears — the + * rendered content in the response may therefore lag by a moment. + */ + async updatePage( + user: User, + token: ApiToken, + pondSlug: string, + pageSlug: string, + input: PublicUpdatePageInput, + ): Promise { + const page = await this.requirePage(pondSlug, pageSlug); + if (input.title !== undefined) { + await this.pages.update(user, page.id, { title: input.title }); + } + if (input.markdown !== undefined) { + const state = this.stateFromMarkdown(input.markdown); + await this.versions.replaceContent(user, page.id, state, 'API update'); + } + await this.auditWrite(user, token, 'page_updated', page.id); + return this.getPage(pondSlug, page.slug); + } + + async deletePage(user: User, token: ApiToken, pondSlug: string, pageSlug: string): Promise { + const page = await this.requirePage(pondSlug, pageSlug); + await this.pages.softDelete(user, page.id); + await this.auditWrite(user, token, 'page_trashed', page.id); + } + + /** + * Permission-filtered search, additionally narrowed to API-exposed ponds: + * what a pond did not opt into must not leak through snippets. Highlight + * sentinels become Markdown `**…**` — the public surface never asks + * clients to know our private-use codepoints. + */ + async searchPages( + user: User, + token: ApiToken, + query: PublicSearchQuery, + ): Promise { + const exposed = await this.exposedPondIds(user, token); + let pondId: string | undefined; + if (query.pond) { + const pond = await this.requirePond(query.pond); + pondId = pond.id; + } + const results = await this.search.search( + { q: query.q, pondId, labels: query.label ? [query.label] : undefined }, + user, + ); + return results + .filter((result) => exposed.has(result.pondId)) + .map((result) => ({ + pondSlug: result.pondSlug, + pageSlug: result.slug, + title: result.title, + snippet: result.snippet + .replaceAll(SEARCH_HIGHLIGHT_START, '**') + .replaceAll(SEARCH_HIGHLIGHT_END, '**'), + })); + } + + async listLabels(user: User, pondSlug: string): Promise { + const pond = await this.requirePond(pondSlug); + return this.labels.list(user, pond.id); + } + + async createLabel( + user: User, + token: ApiToken, + pondSlug: string, + input: CreateLabelInput, + ): Promise { + const pond = await this.requirePond(pondSlug); + const label = await this.labels.create(user, pond.id, input); + await this.auditWrite(user, token, 'label_created', label.id); + return label; + } + + /** Rename/recolour and/or move in one PATCH (the public surface's shape). */ + async updateLabel( + user: User, + token: ApiToken, + pondSlug: string, + labelId: string, + input: PublicUpdateLabelInput, + ): Promise { + await this.requireLabelInPond(pondSlug, labelId); + if (input.name === undefined && input.color === undefined && input.parentId === undefined) { + throw new BadRequestException({ code: 'bad_request' }); + } + let label: LabelView | undefined; + if (input.name !== undefined || input.color !== undefined) { + label = await this.labels.update(user, labelId, { name: input.name, color: input.color }); + } + if (input.parentId !== undefined) { + label = await this.labels.move(user, labelId, { parentId: input.parentId }); + } + await this.auditWrite(user, token, 'label_updated', labelId); + return label!; + } + + async deleteLabel(user: User, token: ApiToken, pondSlug: string, labelId: string): Promise { + await this.requireLabelInPond(pondSlug, labelId); + await this.labels.remove(user, labelId, false); + await this.auditWrite(user, token, 'label_deleted', labelId); + } + + async assignLabel( + user: User, + token: ApiToken, + pondSlug: string, + pageSlug: string, + labelId: string, + ): Promise { + const page = await this.requirePage(pondSlug, pageSlug); + await this.requireLabelInPond(pondSlug, labelId); + const labels = await this.labels.assign(user, page.id, labelId); + await this.auditWrite(user, token, 'label_assigned', page.id); + return labels; + } + + async unassignLabel( + user: User, + token: ApiToken, + pondSlug: string, + pageSlug: string, + labelId: string, + ): Promise { + const page = await this.requirePage(pondSlug, pageSlug); + await this.requireLabelInPond(pondSlug, labelId); + await this.labels.unassign(user, page.id, labelId); + await this.auditWrite(user, token, 'label_unassigned', page.id); + } + + async listComments( + pondSlug: string, + pageSlug: string, + filter: CommentListFilter, + ): Promise { + const page = await this.requirePage(pondSlug, pageSlug); + return this.comments.list(page.id, filter); + } + + async createComment( + user: User, + token: ApiToken, + pondSlug: string, + pageSlug: string, + input: CreateCommentInput, + ): Promise { + const page = await this.requirePage(pondSlug, pageSlug); + const comment = await this.comments.create(user, page.id, input); + await this.auditWrite(user, token, 'comment_created', comment.id); + return comment; + } + + async setCommentResolved( + user: User, + token: ApiToken, + pondSlug: string, + pageSlug: string, + commentId: string, + resolved: boolean, + ): Promise { + const page = await this.requirePage(pondSlug, pageSlug); + // The path names the page — a comment id from elsewhere reads not-found, + // whatever its own permissions would say (the opt-in gate is per pond). + const row = await this.prisma.comment.findFirst({ + where: { id: commentId, pageId: page.id }, + select: { id: true }, + }); + if (!row) throw new NotFoundException(); + const comment = await this.comments.setResolved(user, commentId, resolved); + await this.auditWrite( + user, + token, + resolved ? 'comment_resolved' : 'comment_reopened', + commentId, + ); + return comment; + } + + /** Live pond by slug as a full PondView (the guard already vetted opt-in). */ + async requirePond(slug: string): Promise { + const pond = await this.prisma.pond.findFirst({ where: { slug, deletedAt: null } }); + if (!pond) throw new NotFoundException(); + return { + id: pond.id, + slug: pond.slug, + name: pond.name, + description: pond.description, + type: pond.type === 'PERSONAL' ? 'personal' : 'shared', + ownerId: pond.ownerId, + settings: pondSettingsSchema.parse(pond.settings ?? {}), + createdAt: pond.createdAt.toISOString(), + deletedAt: null, + }; + } + + private pondView(pond: PondView): PublicPondView { + return { + slug: pond.slug, + name: pond.name, + description: pond.description, + type: pond.type, + createdAt: pond.createdAt, + }; + } + + private async requirePage(pondSlug: string, pageSlug: string): Promise { + const page = await this.prisma.page.findFirst({ + where: { slug: pageSlug, deletedAt: null, pond: { slug: pondSlug, deletedAt: null } }, + }); + if (!page) throw new NotFoundException(); + return page; + } + + private async requireLabelInPond(pondSlug: string, labelId: string): Promise { + const label = await this.prisma.label.findFirst({ + where: { id: labelId, pond: { slug: pondSlug, deletedAt: null } }, + select: { id: true }, + }); + if (!label) throw new NotFoundException(); + } + + private async labelNames(pondId: string): Promise> { + const labels = await this.prisma.label.findMany({ + where: { pondId }, + select: { id: true, name: true }, + }); + return new Map(labels.map((label) => [label.id, label.name])); + } + + private async exposedPondIds(user: User, token: ApiToken): Promise> { + const ponds = await this.listPondRowsExposed(user, token); + return new Set(ponds.map((pond) => pond.id)); + } + + private async listPondRowsExposed(user: User, token: ApiToken): Promise<{ id: string }[]> { + const visible = await this.ponds.listVisible(user); + return visible + .filter((pond) => pond.settings.apiEnabled) + .filter((pond) => token.pondIds.length === 0 || token.pondIds.includes(pond.id)); + } + + private stateFromMarkdown(markdown: string): Uint8Array { + const json = markdownToDoc(markdown).toJSON(); + return docToState(Node.fromJSON(editorSchema, json)); + } + + private async auditWrite( + user: User, + token: ApiToken, + op: string, + targetId: string, + ): Promise { + await this.audit.record({ + action: 'api.write', + actorId: user.id, + targetType: 'api_write', + targetId, + details: { op, tokenId: token.id, tokenName: token.name }, + }); + } +} diff --git a/apps/api/src/settings/instance-settings.service.ts b/apps/api/src/settings/instance-settings.service.ts index 5c4f7b4..394204f 100644 --- a/apps/api/src/settings/instance-settings.service.ts +++ b/apps/api/src/settings/instance-settings.service.ts @@ -49,6 +49,10 @@ export const INSTANCE_SETTINGS = { // SVG upload handling (security.md §Uploads): sanitize strips scripts and // event handlers with a maintained library; reject refuses SVG outright. 'upload.svgPolicy': z.enum(['reject', 'sanitize']).default('sanitize'), + // Public REST API master switch (issue #104, default off): every + // /api/public/v1 route answers 404 while disabled. Individual ponds + // additionally opt in through their pond settings (`apiEnabled`). + 'api.enabled': z.boolean().default(false), // Backup targets (ADR 0015, issue #103). The backup sidecar reads these // rows directly (apps/backup settings.ts — keep the schemas in sync); the // Nextcloud app password is NOT here, it lives in the secret store diff --git a/apps/api/src/testing/test-app.ts b/apps/api/src/testing/test-app.ts index 1684465..184d940 100644 --- a/apps/api/src/testing/test-app.ts +++ b/apps/api/src/testing/test-app.ts @@ -34,7 +34,10 @@ export async function createTestApp( app.use(cookieParser()); // Mirrors main.ts: base64 Yjs page state needs more than Express's 100kb default. app.useBodyParser('json', { limit: '8mb' }); - app.setGlobalPrefix('api/v1'); + // Mirrors main.ts: the public API (issue #104) declares its full path. + app.setGlobalPrefix('api/v1', { + exclude: ['api/public/v1', 'api/public/v1/{*path}'], + }); await app.init(); return app; } diff --git a/apps/api/src/versions/versions.service.ts b/apps/api/src/versions/versions.service.ts index 3635f85..09ecd24 100644 --- a/apps/api/src/versions/versions.service.ts +++ b/apps/api/src/versions/versions.service.ts @@ -139,6 +139,34 @@ export class VersionsService { return this.viewOf(version, await this.contributorNames([version])); } + /** + * Replace the page's content with an externally built state — the public + * API's Markdown update (issue #104). The new content lands as a MANUAL + * version first (append-only history: the change is inspectable and + * revertible), then the normal restore path applies it: the collab + * server owns the live document, so open editors converge and no second + * document lineage appears. Permission (write) is the caller's guard. + */ + async replaceContent( + user: User, + pageId: string, + state: Uint8Array, + label: string, + ): Promise { + await this.findLivePage(pageId); + const created = await this.prisma.pageVersion.create({ + data: { + pageId, + ydocSnapshot: state, + trigger: 'MANUAL', + label, + createdBy: user.id, + contributorIds: [user.id], + }, + }); + await this.restore(user, pageId, created.id); + } + /** * Create a named version (requires write access, ADR 0013 / permissions.md). * The snapshot is the page's current persisted state (base state plus the diff --git a/apps/web/e2e/collab.spec.ts b/apps/web/e2e/collab.spec.ts index 0abc1d0..5506764 100644 --- a/apps/web/e2e/collab.spec.ts +++ b/apps/web/e2e/collab.spec.ts @@ -124,3 +124,41 @@ test('remote carets and the presence strip reflect participants (#37)', async ({ // Read-only participants (live changes visible, typing blocked) and the live // read-write→read-only downgrade need real grants, so they live in the // `collab-permissions` pack (issue #53) alongside the grant setup they require. + +test('a public-API content replacement converges an open editor (#104)', async ({ browser }) => { + const owner = await contextForUser(browser, BASE_URL, 'fixture-user'); + const admin = await contextForUser(browser, BASE_URL, 'fixture-admin'); + const pond = await personalPond(owner); + const created = await owner.request.post(`/api/v1/ponds/${pond.id}/pages`, { + data: { title: `API Replace ${Date.now()}` }, + }); + const { slug } = await created.json(); + + // Expose the surface: instance switch (Site Admin) + pond opt-in (owner), + // then mint a write token for the owner. + await admin.request.patch('/api/v1/admin/settings', { data: { 'api.enabled': true } }); + await owner.request.patch(`/api/v1/ponds/${pond.id}`, { data: { apiEnabled: true } }); + const minted = await owner.request.post('/api/v1/users/me/api-tokens', { + data: { name: `collab-e2e-${Date.now()}`, scope: 'write' }, + }); + const { token } = await minted.json(); + + const pageA = await openEditor(owner, pond.slug, slug); + const editorA = pageA.locator('.ProseMirror'); + await editorA.click(); + await pageA.keyboard.type('typed live before the API replace'); + await expect(editorA).toContainText('typed live before the API replace'); + + // Replace the whole content through the public API: the change travels + // over the collab-owned restore path, so the open editor converges. + const replaced = await owner.request.patch(`/api/public/v1/ponds/${pond.slug}/pages/${slug}`, { + headers: { Authorization: `Bearer ${token}` }, + data: { markdown: 'Replaced through the public API.' }, + }); + expect(replaced.ok()).toBeTruthy(); + await expect(editorA).toContainText('Replaced through the public API.', { timeout: 15000 }); + await expect(editorA).not.toContainText('typed live before the API replace'); + + await owner.close(); + await admin.close(); +}); diff --git a/apps/web/src/api-tokens/ApiOptInSetting.tsx b/apps/web/src/api-tokens/ApiOptInSetting.tsx new file mode 100644 index 0000000..5a34108 --- /dev/null +++ b/apps/web/src/api-tokens/ApiOptInSetting.tsx @@ -0,0 +1,54 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { FormError, FormSuccess } from '../components/forms'; +import { apiPatch } from '../lib/api'; + +/** + * The pond's public-API opt-in (issue #104, default off). Rides the generic + * pond PATCH like the comment policy; only the pond owner sees the pond + * settings page at all. + */ +export function ApiOptInSetting({ + pondId, + pondSlug, + value, +}: { + pondId: string; + pondSlug: string; + value: boolean; +}): React.JSX.Element { + const { t } = useTranslation('apiTokens'); + const queryClient = useQueryClient(); + const [error, setError] = useState(null); + const [saved, setSaved] = useState(false); + + const save = async (enabled: boolean): Promise => { + setError(null); + setSaved(false); + try { + await apiPatch(`/ponds/${pondId}`, { apiEnabled: enabled }); + await queryClient.invalidateQueries({ queryKey: ['pond', pondSlug] }); + setSaved(true); + } catch (err) { + setError(err); + } + }; + + return ( +
+ + + +

{t('pond.hint')}

+
+ ); +} diff --git a/apps/web/src/api-tokens/ApiTokensSection.tsx b/apps/web/src/api-tokens/ApiTokensSection.tsx new file mode 100644 index 0000000..d7895bf --- /dev/null +++ b/apps/web/src/api-tokens/ApiTokensSection.tsx @@ -0,0 +1,221 @@ +import type { ApiTokenCreatedView, ApiTokenView, PondView } from '@dorfteich/shared'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { FormError } from '../components/forms'; +import { apiDelete, apiGet, apiPost } from '../lib/api'; + +const TOKENS_QUERY_KEY = ['users', 'me', 'api-tokens'] as const; + +/** + * Personal-access-token management in the user settings (issue #104): + * create (scope, optional expiry, optional pond restriction), the one-time + * secret reveal, and the list with revoke. + */ +export function ApiTokensSection(): React.JSX.Element { + const { t } = useTranslation('apiTokens'); + const tokens = useQuery({ + queryKey: TOKENS_QUERY_KEY, + queryFn: () => apiGet('/users/me/api-tokens'), + }); + + return ( +
+

{t('section.title')}

+

{t('section.intro')}

+ + {tokens.data && tokens.data.length === 0 &&

{t('section.empty')}

} + {tokens.data && tokens.data.length > 0 && } +
+ ); +} + +function CreateTokenForm(): React.JSX.Element { + const { t } = useTranslation('apiTokens'); + const queryClient = useQueryClient(); + const [name, setName] = useState(''); + const [scope, setScope] = useState<'read' | 'write'>('read'); + const [expiresAt, setExpiresAt] = useState(''); + const [pondIds, setPondIds] = useState([]); + const [created, setCreated] = useState(null); + const [copied, setCopied] = useState(false); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const ponds = useQuery({ + queryKey: ['ponds'], + queryFn: () => apiGet('/ponds'), + }); + + const submit = async (event: React.FormEvent): Promise => { + event.preventDefault(); + setError(null); + setCreated(null); + setCopied(false); + setBusy(true); + try { + const view = await apiPost('/users/me/api-tokens', { + name, + scope, + expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null, + pondIds, + }); + setCreated(view); + setName(''); + setExpiresAt(''); + setPondIds([]); + await queryClient.invalidateQueries({ queryKey: TOKENS_QUERY_KEY }); + } catch (err) { + setError(err); + } finally { + setBusy(false); + } + }; + + return ( +
void submit(event)}> + + + + +
+ {t('fields.ponds')} +

{t('fields.pondsHint')}

+ {(ponds.data ?? []).map((pond) => ( + + ))} +
+ + {created && ( +
+

{t('create.createdTitle')}

+

{t('create.createdHint')}

+ {created.token} + +
+ )} + + ); +} + +function TokenList({ tokens }: { tokens: ApiTokenView[] }): React.JSX.Element { + const { t } = useTranslation('apiTokens'); + const queryClient = useQueryClient(); + const [error, setError] = useState(null); + + const revoke = async (id: string): Promise => { + if (!window.confirm(t('list.revokeConfirm'))) return; + setError(null); + try { + await apiDelete(`/users/me/api-tokens/${id}`); + await queryClient.invalidateQueries({ queryKey: TOKENS_QUERY_KEY }); + } catch (err) { + setError(err); + } + }; + + const statusOf = (token: ApiTokenView): 'active' | 'revoked' | 'expired' => { + if (token.revokedAt) return 'revoked'; + if (token.expiresAt && new Date(token.expiresAt).getTime() <= Date.now()) return 'expired'; + return 'active'; + }; + + return ( + <> + + + + + + + + + + + + + + + + {tokens.map((token) => ( + + + + + + + + + + + ))} + +
{t('fields.name')}{t('fields.scope')}{t('fields.ponds')}{t('list.created')}{t('list.lastUsed')}{t('list.expires')}{t('list.status')}
{token.name}{token.scope === 'write' ? t('fields.scopeWrite') : t('fields.scopeRead')} + {token.ponds.length === 0 + ? t('list.allPonds') + : token.ponds.map((pond) => pond.name).join(', ')} + {new Date(token.createdAt).toLocaleDateString()} + {token.lastUsedAt ? new Date(token.lastUsedAt).toLocaleString() : t('list.never')} + {token.expiresAt ? new Date(token.expiresAt).toLocaleDateString() : '—'}{t(`list.${statusOf(token)}`)} + {!token.revokedAt && ( + + )} +
+ + ); +} diff --git a/apps/web/src/i18n/index.ts b/apps/web/src/i18n/index.ts index 0ebc659..b5761c9 100644 --- a/apps/web/src/i18n/index.ts +++ b/apps/web/src/i18n/index.ts @@ -18,6 +18,7 @@ import dePublic from '@dorfteich/shared/i18n/de/public.json'; import deQuotas from '@dorfteich/shared/i18n/de/quotas.json'; import deSearch from '@dorfteich/shared/i18n/de/search.json'; import deSetup from '@dorfteich/shared/i18n/de/setup.json'; +import deApiTokens from '@dorfteich/shared/i18n/de/apiTokens.json'; import deSystem from '@dorfteich/shared/i18n/de/system.json'; import deUsers from '@dorfteich/shared/i18n/de/users.json'; import deWatches from '@dorfteich/shared/i18n/de/watches.json'; @@ -42,6 +43,7 @@ import enPublic from '@dorfteich/shared/i18n/en/public.json'; import enQuotas from '@dorfteich/shared/i18n/en/quotas.json'; import enSearch from '@dorfteich/shared/i18n/en/search.json'; import enSetup from '@dorfteich/shared/i18n/en/setup.json'; +import enApiTokens from '@dorfteich/shared/i18n/en/apiTokens.json'; import enSystem from '@dorfteich/shared/i18n/en/system.json'; import enUsers from '@dorfteich/shared/i18n/en/users.json'; import enWatches from '@dorfteich/shared/i18n/en/watches.json'; @@ -83,6 +85,7 @@ void i18n quotas: enQuotas, search: enSearch, setup: enSetup, + apiTokens: enApiTokens, system: enSystem, users: enUsers, watches: enWatches, @@ -109,6 +112,7 @@ void i18n quotas: deQuotas, search: deSearch, setup: deSetup, + apiTokens: deApiTokens, system: deSystem, users: deUsers, watches: deWatches, diff --git a/apps/web/src/pages/AdminSettingsPage.tsx b/apps/web/src/pages/AdminSettingsPage.tsx index 418da71..7ffd275 100644 --- a/apps/web/src/pages/AdminSettingsPage.tsx +++ b/apps/web/src/pages/AdminSettingsPage.tsx @@ -20,6 +20,7 @@ interface InstanceSettings { 'quota.additionalPonds': number; 'quota.storageBytes': number; 'quota.maxFileBytes': number; + 'api.enabled': boolean; 'upload.allowedExtensions': string[]; 'upload.svgPolicy': 'reject' | 'sanitize'; 'legal.imprint': string; @@ -108,6 +109,7 @@ export function AdminSettingsPage(): React.JSX.Element { + @@ -183,6 +185,46 @@ function UploadSettingsForm({ settings }: { settings: InstanceSettings }): React ); } +/** + * Public REST API master switch (issue #104, default off). Users create + * their tokens in the user settings; ponds opt in individually. + */ +function PublicApiSettingsForm({ settings }: { settings: InstanceSettings }): React.JSX.Element { + const { t } = useTranslation('apiTokens'); + const queryClient = useQueryClient(); + const [error, setError] = useState(null); + const [saved, setSaved] = useState(false); + + async function save(enabled: boolean): Promise { + setError(null); + setSaved(false); + try { + await apiPatch('/admin/settings', { 'api.enabled': enabled }); + await queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] }); + setSaved(true); + } catch (err) { + setError(err); + } + } + + return ( +
+

{t('admin.title')}

+ + + +

{t('admin.hint')}

+
+ ); +} + /** * Legal pages (issue #82): imprint and privacy policy as Markdown, shown * publicly at /legal/imprint and /legal/privacy. The preview renders through diff --git a/apps/web/src/pages/AdminSystemPage.tsx b/apps/web/src/pages/AdminSystemPage.tsx index 3b68f9f..c98b7af 100644 --- a/apps/web/src/pages/AdminSystemPage.tsx +++ b/apps/web/src/pages/AdminSystemPage.tsx @@ -301,6 +301,9 @@ const KNOWN_ACTIONS = [ 'backup.settings_changed', 'backup.run_triggered', 'backup.restore_requested', + 'api.token_created', + 'api.token_revoked', + 'api.write', ]; function AuditRow({ entry }: { entry: AuditEntryView }): React.JSX.Element { diff --git a/apps/web/src/pages/PondSettingsPage.tsx b/apps/web/src/pages/PondSettingsPage.tsx index 6aa3596..cd8ef34 100644 --- a/apps/web/src/pages/PondSettingsPage.tsx +++ b/apps/web/src/pages/PondSettingsPage.tsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router-dom'; import { useAuth } from '../auth/auth-context'; +import { ApiOptInSetting } from '../api-tokens/ApiOptInSetting'; import { CommentPolicySetting } from '../comments/CommentPolicySetting'; import { WatchToggle } from '../watches/WatchToggle'; import { FormError } from '../components/forms'; @@ -33,6 +34,7 @@ export function PondSettingsPage(): React.JSX.Element { const { t: tFiles } = useTranslation('files'); const { t: tExport } = useTranslation('export'); const { t: tComments } = useTranslation('comments'); + const { t: tApiTokens } = useTranslation('apiTokens'); const { t: tFont } = useTranslation('font'); const { pondSlug = '' } = useParams<{ pondSlug: string }>(); const { user } = useAuth(); @@ -102,6 +104,16 @@ export function PondSettingsPage(): React.JSX.Element { /> )} + {canModify && ( +
+

{tApiTokens('pond.title')}

+ +
+ )}

{tExport('pond.heading')}

{tExport('pond.hint')}

diff --git a/apps/web/src/pages/SettingsPage.tsx b/apps/web/src/pages/SettingsPage.tsx index 8249ae5..4ac3cf7 100644 --- a/apps/web/src/pages/SettingsPage.tsx +++ b/apps/web/src/pages/SettingsPage.tsx @@ -9,6 +9,7 @@ import { useAuth } from '../auth/auth-context'; import { Field, FormError, FormSuccess, applyFieldErrors } from '../components/forms'; import { useDataExport } from '../export/use-data-export'; import { apiDelete, apiGet, apiPatch, apiPost } from '../lib/api'; +import { ApiTokensSection } from '../api-tokens/ApiTokensSection'; import { WatchesSection } from '../watches/WatchesSection'; interface SessionView { @@ -28,6 +29,7 @@ export function SettingsPage(): React.JSX.Element { + ); diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css index d886a2d..56678db 100644 --- a/apps/web/src/styles/base.css +++ b/apps/web/src/styles/base.css @@ -2602,6 +2602,74 @@ button { color: var(--color-danger); } +/* API tokens (issue #104) */ +.api-tokens__create label { + display: block; + margin-top: var(--space-2); + font-weight: 600; +} + +.api-tokens__create input[type='text'], +.api-tokens__create input[type='date'], +.api-tokens__create select { + display: block; + margin-top: var(--space-1); + width: min(24rem, 100%); +} + +.api-tokens__ponds { + border: none; + padding: 0; + margin: var(--space-2) 0; +} + +.api-tokens__ponds legend { + font-weight: 600; + padding: 0; +} + +.api-tokens__pond { + display: block; + font-weight: 400 !important; + margin-top: var(--space-1) !important; +} + +.api-tokens__pond input { + margin-right: var(--space-1); +} + +.api-tokens__create .button { + margin-top: var(--space-2); +} + +.api-tokens__created { + margin-top: var(--space-3); + padding: var(--space-3); + border: 1px solid var(--color-border, #cbd5e1); + border-radius: 8px; +} + +.api-tokens__secret { + display: block; + margin: var(--space-2) 0; + word-break: break-all; + user-select: all; +} + +.api-tokens__intro, +.api-tokens__hint, +.api-opt-in__hint { + color: var(--color-text-muted); +} + +.api-tokens__table { + margin-top: var(--space-3); +} + +.api-opt-in__label input { + margin-right: var(--space-1); +} + /* Maintenance screen during an in-app restore (issue #103) */ .maintenance-page { max-width: 32rem; diff --git a/docs/self-hosting/README.md b/docs/self-hosting/README.md index d7ee076..99abe27 100644 --- a/docs/self-hosting/README.md +++ b/docs/self-hosting/README.md @@ -134,6 +134,13 @@ If the app itself is gone, use the operator path in `docs/operations/restore-runbook.md` instead — it documents fetching a bundle from Nextcloud by hand. +## Public REST API + +Scripts and integrations can talk to the instance through a +token-authenticated API at `/api/public/v1` — off by default, enabled per +instance and per pond. Details, token walkthrough, and the OpenAPI +document: [public-api.md](public-api.md). + ## Health & troubleshooting - `GET /api/v1/readyz` is the instance's own diagnosis. HTTP 503 = diff --git a/docs/self-hosting/public-api.md b/docs/self-hosting/public-api.md new file mode 100644 index 0000000..2bd0e7b --- /dev/null +++ b/docs/self-hosting/public-api.md @@ -0,0 +1,63 @@ +# Public REST API (issue #104) + +Dorfteich instances can expose a token-authenticated REST API at +`/api/public/v1` for scripts and integrations. It is **off by default**, +twice: a Site Admin enables the instance switch (_Admin → Settings → +Public API_), and every pond that should be reachable opts in separately +(_Pond settings → Public API_). Anything not enabled answers 404 — +indistinguishable from an instance without the feature. + +## Personal access tokens + +Every user manages their tokens under _Settings → API tokens_: a name, a +scope (`read` or `read+write`), an optional expiry, and an optional +restriction to selected ponds. The secret (`dt_pat_…`) is shown exactly +once and stored hashed; tokens are revocable and show their last use. + +A token acts **as its user**: the normal permission model — grants, label +scopes, the 404-vs-403 policy — applies unchanged. Scope and pond +restriction only narrow it further; they never grant anything the user +could not do in the app. + +## Using the API + +```sh +curl -H "Authorization: Bearer dt_pat_..." \ + https://your-instance.example/api/public/v1/me +``` + +- The machine-readable description lives at + `/api/public/v1/openapi.json` (reachable without a token while the + instance switch is on). +- Requests are rate-limited per token (HTTP 429 + `Retry-After`). +- Errors carry the api's uniform body: `{ code, message, details? }`. +- Page content is Markdown in, Markdown + rendered HTML out. A content + update (`PATCH …/pages/{slug}` with `markdown`) **replaces** the whole + page; it is applied through the collaborative document, so open editors + converge live, and the previous state stays in the version history + (the change itself appears as a manual version named "API update"). + +### Endpoint overview + +| Area | Endpoints | +| -------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| Identity | `GET /me` | +| Ponds | `GET /ponds`, `GET /ponds/{slug}` | +| Pages | `GET/POST /ponds/{slug}/pages`, `GET/PATCH/DELETE /ponds/{slug}/pages/{pageSlug}` | +| Search | `GET /search?q=&pond=&label=` | +| Export | `GET /ponds/{slug}/export/markdown` (ZIP) | +| Labels | `GET/POST /ponds/{slug}/labels`, `PATCH/DELETE /ponds/{slug}/labels/{id}`, `PUT/DELETE /ponds/{slug}/pages/{pageSlug}/labels/{id}` | +| Comments | `GET/POST /ponds/{slug}/pages/{pageSlug}/comments`, `POST/DELETE …/comments/{id}/resolve` | + +Deliberately not in v1 (stage 2): attachment upload, version endpoints, +webhooks. + +## Security notes + +- Bearer tokens only — no cookies are involved, so there is no CSRF + surface; browser sessions cannot call the public API and tokens cannot + manage tokens. +- Token creation and revocation are audit-logged, as is every write + through the API (action `api.write`, with the token attributed). +- Treat a token like a password. Revoke it under _Settings → API tokens_ + the moment it may have leaked. diff --git a/packages/shared/i18n/de/apiTokens.json b/packages/shared/i18n/de/apiTokens.json new file mode 100644 index 0000000..dd520e5 --- /dev/null +++ b/packages/shared/i18n/de/apiTokens.json @@ -0,0 +1,51 @@ +{ + "section": { + "title": "API-Tokens", + "intro": "Mit Personal-Access-Tokens nutzen Skripte und Integrationen die öffentliche API in deinem Namen. Ein Token hat deine Berechtigungen, eingeschränkt durch seinen Scope und optional auf bestimmte Teiche.", + "instanceDisabled": "Die öffentliche API ist auf dieser Instanz derzeit deaktiviert — Tokens lassen sich anlegen, funktionieren aber erst, wenn ein Site-Admin sie aktiviert.", + "empty": "Noch keine API-Tokens." + }, + "fields": { + "name": "Name", + "scope": "Scope", + "scopeRead": "Nur lesen", + "scopeWrite": "Lesen und schreiben", + "expiresAt": "Läuft ab (optional)", + "ponds": "Auf Teiche beschränken (optional)", + "pondsHint": "Keine Auswahl = alle Teiche, auf die du Zugriff hast (und die die API aktiviert haben)." + }, + "list": { + "created": "Erstellt", + "lastUsed": "Zuletzt benutzt", + "never": "nie", + "expires": "Läuft ab", + "status": "Status", + "active": "Aktiv", + "revoked": "Widerrufen", + "expired": "Abgelaufen", + "allPonds": "alle Teiche", + "revoke": "Widerrufen", + "revokeConfirm": "Dieses Token widerrufen? Clients, die es verwenden, funktionieren sofort nicht mehr." + }, + "create": { + "button": "Token erstellen", + "pending": "Erstelle…", + "createdTitle": "Token erstellt", + "createdHint": "Jetzt kopieren — es wird nur dieses eine Mal angezeigt.", + "copy": "Kopieren", + "copied": "Kopiert." + }, + "pond": { + "title": "Öffentliche API", + "label": "Diesen Teich über die öffentliche API freigeben", + "hint": "Standardmäßig aus. Wenn aktiviert, erreichen Nutzer diesen Teich mit ihren API-Tokens — mit genau den Berechtigungen, die sie hier ohnehin haben. Der instanzweite API-Schalter muss ebenfalls an sein.", + "saved": "Gespeichert." + }, + "admin": { + "title": "Öffentliche API", + "label": "Öffentliche REST-API aktivieren", + "hint": "Hauptschalter (standardmäßig aus). Nutzer erstellen dann Personal-Access-Tokens in ihren Einstellungen; jeder Teich gibt sich zusätzlich über seine Teich-Einstellungen frei. Dokumentation: /api/public/v1/openapi.json", + "save": "Speichern", + "saved": "Gespeichert." + } +} diff --git a/packages/shared/i18n/de/errors.json b/packages/shared/i18n/de/errors.json index 1b3e66a..fd449f9 100644 --- a/packages/shared/i18n/de/errors.json +++ b/packages/shared/i18n/de/errors.json @@ -101,5 +101,7 @@ "backup_restore_confirm_mismatch": "Der Bestätigungstext stimmt nicht mit der Backup-ID überein.", "backup_restore_running": "Es läuft bereits eine Wiederherstellung.", "backup_remote_not_configured": "Es ist kein Nextcloud-Backup-Ziel konfiguriert.", - "backup_set_not_found": "Das gewählte Backup-Set wurde nicht gefunden." + "backup_set_not_found": "Das gewählte Backup-Set wurde nicht gefunden.", + "scope_required": "Dieses API-Token hat nicht den erforderlichen Scope.", + "pond_not_found": "Der Teich existiert nicht." } diff --git a/packages/shared/i18n/de/system.json b/packages/shared/i18n/de/system.json index 5ba51ac..8cc3c21 100644 --- a/packages/shared/i18n/de/system.json +++ b/packages/shared/i18n/de/system.json @@ -165,7 +165,10 @@ "job.triggered": "Job manuell gestartet", "backup.settings_changed": "Backup-Einstellungen geändert", "backup.run_triggered": "Manuelles Backup ausgelöst", - "backup.restore_requested": "Backup-Wiederherstellung angefordert" + "backup.restore_requested": "Backup-Wiederherstellung angefordert", + "api.token_created": "API-Token erstellt", + "api.token_revoked": "API-Token widerrufen", + "api.write": "Schreibzugriff über öffentliche API" } }, "storage": { diff --git a/packages/shared/i18n/en/apiTokens.json b/packages/shared/i18n/en/apiTokens.json new file mode 100644 index 0000000..6ad8296 --- /dev/null +++ b/packages/shared/i18n/en/apiTokens.json @@ -0,0 +1,51 @@ +{ + "section": { + "title": "API tokens", + "intro": "Personal access tokens let scripts and integrations use the public API as you. A token has your permissions, narrowed by its scope and an optional pond restriction.", + "instanceDisabled": "The public API is currently disabled on this instance — tokens can be created but will not work until a Site Admin enables it.", + "empty": "No API tokens yet." + }, + "fields": { + "name": "Name", + "scope": "Scope", + "scopeRead": "Read only", + "scopeWrite": "Read and write", + "expiresAt": "Expires (optional)", + "ponds": "Restrict to ponds (optional)", + "pondsHint": "No selection = every pond you can access (that has the API enabled)." + }, + "list": { + "created": "Created", + "lastUsed": "Last used", + "never": "never", + "expires": "Expires", + "status": "Status", + "active": "Active", + "revoked": "Revoked", + "expired": "Expired", + "allPonds": "all ponds", + "revoke": "Revoke", + "revokeConfirm": "Revoke this token? Clients using it stop working immediately." + }, + "create": { + "button": "Create token", + "pending": "Creating…", + "createdTitle": "Token created", + "createdHint": "Copy it now — it is shown only this once.", + "copy": "Copy", + "copied": "Copied." + }, + "pond": { + "title": "Public API", + "label": "Expose this pond through the public API", + "hint": "Off by default. When enabled, users can reach this pond with their API tokens — with exactly the permissions they have here anyway. The instance-wide API switch must also be on.", + "saved": "Saved." + }, + "admin": { + "title": "Public API", + "label": "Enable the public REST API", + "hint": "Master switch (default off). Users then create personal access tokens in their settings; each pond additionally opts in via its pond settings. Documentation: /api/public/v1/openapi.json", + "save": "Save", + "saved": "Saved." + } +} diff --git a/packages/shared/i18n/en/errors.json b/packages/shared/i18n/en/errors.json index bc103a0..732291e 100644 --- a/packages/shared/i18n/en/errors.json +++ b/packages/shared/i18n/en/errors.json @@ -101,5 +101,7 @@ "backup_restore_confirm_mismatch": "The confirmation text does not match the backup id.", "backup_restore_running": "A restore is already running.", "backup_remote_not_configured": "No Nextcloud backup target is configured.", - "backup_set_not_found": "The selected backup set was not found." + "backup_set_not_found": "The selected backup set was not found.", + "scope_required": "This API token does not have the required scope.", + "pond_not_found": "The pond does not exist." } diff --git a/packages/shared/i18n/en/system.json b/packages/shared/i18n/en/system.json index 605ed5a..d56b8a3 100644 --- a/packages/shared/i18n/en/system.json +++ b/packages/shared/i18n/en/system.json @@ -165,7 +165,10 @@ "job.triggered": "Job triggered manually", "backup.settings_changed": "Backup settings changed", "backup.run_triggered": "Manual backup triggered", - "backup.restore_requested": "Backup restore requested" + "backup.restore_requested": "Backup restore requested", + "api.token_created": "API token created", + "api.token_revoked": "API token revoked", + "api.write": "Write via public API" } }, "storage": { diff --git a/packages/shared/src/api-tokens.ts b/packages/shared/src/api-tokens.ts new file mode 100644 index 0000000..9f81439 --- /dev/null +++ b/packages/shared/src/api-tokens.ts @@ -0,0 +1,43 @@ +import { z } from 'zod'; + +/** + * Personal access tokens for the public API (issue #104). A token acts AS + * its user — the whole permission model (grants, label scopes, 404-vs-403) + * applies unchanged — narrowed by a scope and an optional pond restriction. + * The secret is shown exactly once at creation and stored hashed. + */ + +/** Recognizable secret prefix (`dt_pat_`), like GitHub's `ghp_`. */ +export const API_TOKEN_PREFIX = 'dt_pat_'; + +export const API_TOKEN_SCOPES = ['read', 'write'] as const; +/** `write` includes `read` — scopes are a ladder, not a matrix. */ +export type ApiTokenScope = (typeof API_TOKEN_SCOPES)[number]; + +export const createApiTokenInputSchema = z.object({ + name: z.string().trim().min(1, 'validation.required').max(80, 'validation.tooLong'), + scope: z.enum(API_TOKEN_SCOPES), + /** Optional expiry; null/absent = the token lives until revoked. */ + expiresAt: z.coerce.date().nullable().optional(), + /** Empty = every pond the user may access; else only these ponds. */ + pondIds: z.array(z.string().uuid()).max(100).default([]), +}); + +export type CreateApiTokenInput = z.infer; + +export interface ApiTokenView { + id: string; + name: string; + scope: ApiTokenScope; + /** Pond restriction as ids plus display names for the settings list. */ + ponds: { id: string; name: string }[]; + expiresAt: string | null; + revokedAt: string | null; + lastUsedAt: string | null; + createdAt: string; +} + +/** Creation response — the only time the secret ever leaves the server. */ +export interface ApiTokenCreatedView extends ApiTokenView { + token: string; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index b41a993..0895a13 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,4 +1,5 @@ export * from './admin-users'; +export * from './api-tokens'; export * from './api-error'; export * from './auth'; export * from './backup-set'; @@ -25,6 +26,7 @@ export * from './secret-store'; export * from './setup'; export * from './system'; export * from './ponds'; +export * from './public-api'; export * from './quotas'; export * from './text-diff'; export * from './watches'; diff --git a/packages/shared/src/ponds.ts b/packages/shared/src/ponds.ts index 079ff4a..3a4ec1d 100644 --- a/packages/shared/src/ponds.ts +++ b/packages/shared/src/ponds.ts @@ -39,6 +39,10 @@ export const pondSettingsSchema = z.object({ fonts: pondFontsSchema.default({}), /** Who may write comments (issue #91): every reader, or editors only. */ commentPolicy: z.enum(COMMENT_POLICIES).default('readers'), + /** Per-pond opt-in to the public REST API (issue #104, default off): + * without it the pond and its content answer 404 through the API even + * for a token whose user could see them in the app. */ + apiEnabled: z.boolean().default(false), }); export type PondSettings = z.infer; @@ -61,6 +65,7 @@ export const updatePondInputSchema = z sidebarSort: z.enum(SIDEBAR_SORT_MODES), fonts: pondFontsSchema, commentPolicy: z.enum(COMMENT_POLICIES), + apiEnabled: z.boolean(), }) .partial(); export type UpdatePondInput = z.infer; diff --git a/packages/shared/src/public-api.ts b/packages/shared/src/public-api.ts new file mode 100644 index 0000000..405bf89 --- /dev/null +++ b/packages/shared/src/public-api.ts @@ -0,0 +1,100 @@ +import { z } from 'zod'; + +import type { ApiTokenScope } from './api-tokens'; +import type { CommentView } from './comments'; +import type { LabelTreeNode, LabelView } from './labels'; + +/** + * Wire types of the public REST API (`/api/public/v1`, issue #104). The + * shapes are deliberately independent of the internal views: this surface + * is versioned and consumed by scripts and MCP clients, so it exposes + * slugs and stable ids, never internal implementation details. + */ + +export interface PublicMeView { + user: { id: string; username: string; displayName: string }; + scope: ApiTokenScope; + /** Pond restriction (slugs); empty = every pond the user may access. */ + pondSlugs: string[]; +} + +export interface PublicPondView { + slug: string; + name: string; + description: string; + type: 'personal' | 'shared'; + createdAt: string; +} + +export interface PublicPageListItemView { + slug: string; + title: string; + labels: string[]; + createdAt: string; + updatedAt: string; +} + +export interface PublicPageView { + slug: string; + title: string; + pondSlug: string; + markdown: string; + html: string; + labels: string[]; + createdAt: string; + updatedAt: string; +} + +/** Bounded well above every realistic page (the editor caps documents far + * lower); the limit only stops abuse of the raw endpoint. */ +const MARKDOWN_MAX_BYTES = 2 * 1024 * 1024; + +export const publicCreatePageInputSchema = z.object({ + title: z.string().trim().min(1, 'validation.required').max(200, 'validation.tooLong'), + markdown: z.string().max(MARKDOWN_MAX_BYTES).default(''), +}); +export type PublicCreatePageInput = z.infer; + +export const publicUpdatePageInputSchema = z + .object({ + title: z.string().trim().min(1, 'validation.required').max(200, 'validation.tooLong'), + /** Replace semantics: the whole content becomes this Markdown. */ + markdown: z.string().max(MARKDOWN_MAX_BYTES), + }) + .partial() + .refine((input) => input.title !== undefined || input.markdown !== undefined, { + message: 'validation.required', + }); +export type PublicUpdatePageInput = z.infer; + +/** + * One PATCH covers rename, recolour, and move (the internal API splits + * update and move); `parentId: null` moves the label to the root. + */ +export const publicUpdateLabelInputSchema = z + .object({ + name: z.string().trim().min(1, 'validation.required').max(60, 'validation.tooLong'), + color: z.string().regex(/^#[0-9a-fA-F]{6}$/, 'validation.invalid'), + parentId: z.string().min(1).nullable(), + }) + .partial(); +export type PublicUpdateLabelInput = z.infer; + +export const publicSearchQuerySchema = z.object({ + q: z.string().trim().min(1, 'validation.required').max(200), + pond: z.string().trim().optional(), + label: z.string().trim().optional(), +}); +export type PublicSearchQuery = z.infer; + +export interface PublicSearchResultView { + pondSlug: string; + pageSlug: string; + title: string; + snippet: string; +} + +/** Re-exported internal shapes the public surface serves verbatim. */ +export type PublicLabelTree = LabelTreeNode[]; +export type PublicLabelView = LabelView; +export type PublicCommentView = CommentView;