Public REST API v1: personal access tokens, instance switch + per-pond opt-in (#104)
Some checks failed
CI / Lint, typecheck, test (push) Failing after 1m39s
CI / Auth e2e pack (push) Has been skipped
CI / Import/export fidelity gate (push) Has been skipped
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m51s
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m10s
CD / Promote to Int (push) Successful in 11s

Token-authenticated machine access at /api/public/v1 — the foundation for
the built-in MCP endpoint (#105).

Personal access tokens:
- api_tokens table (SHA-256 hash, scope read|write, optional pond
  restriction, expiry, revocation, throttled last-used) + migration;
  secrets are dt_pat_<random>, shown exactly once
- lifecycle endpoints under /users/me/api-tokens (session-only — a leaked
  token can never mint more tokens) with audit entries
  api.token_created/api.token_revoked
- settings UI section (create with scope/expiry/pond restriction,
  one-time reveal with copy, list with status + revoke), de+en

Activation (404 semantics per #60 on both levels):
- instance setting api.enabled (default off, admin settings switch)
- pond setting apiEnabled (default off, pond settings toggle; the
  PondsService settings-merge learned the key — the #92 lesson)

Surface (/api/public/v1, excluded from the SPA's global prefix):
- me, ponds, pages (list/read as Markdown+HTML, create from Markdown via
  the shared pipeline, PATCH title/content, DELETE to trash), search
  (permission-filtered + narrowed to exposed ponds, highlights as **…**),
  markdown ZIP export, labels (tree, create/rename/recolour/move/delete,
  assign/unassign), comments (threads, create, resolve/reopen)
- content replacement travels the collab-owned document path: the new
  state lands as a MANUAL version "API update", then the established
  restore NOTIFY applies it — open editors converge, history stays
  append-only, no second lineage (VersionsService.replaceContent)
- hand-maintained OpenAPI 3.1 document at /openapi.json, pinned to the
  controller by a route-coverage test in both directions

Enforcement:
- PublicApiGuard: instance switch → bearer PAT auth (request.user is the
  token's user) → per-token rate limit (429 + Retry-After) → scope
  (403 scope_required) → pond opt-in + token restriction
- the shared PermissionGuard then applies the unchanged permission model;
  PageParamSource gained pondSlugParam for the slug+slug routes
- no cookies anywhere → no CSRF surface (pinned by a hostile-Origin test)
- every write audit-logged as api.write with the token attributed

Tests/verification:
- 12-test e2e pack: lifecycle, switches, permission matrix
  (reader/editor/outsider × scopes), restriction, page roundtrip incl.
  restore-NOTIFY assertion, labels, comments incl. policy, search
  narrowing, ZIP export, rate limit; full api suite 60/60 green
  (quota fixture via per-user override — never the instance default)
- new collab-pack test proves an open editor converges onto an API
  content replacement (green against a local seeded stack)
- UI smoke against the built SPA: token create/reveal/revoke, pond
  opt-in persists, admin switch persists (10/10)
- docs/self-hosting/public-api.md + README link

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
This commit is contained in:
Claude Fable 5 2026-07-12 11:17:03 +02:00
parent 5cef359b8f
commit 0c85293830
42 changed files with 3025 additions and 12 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -73,7 +73,11 @@ async function bootstrap(): Promise<void> {
// 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);

View File

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

View File

@ -108,7 +108,9 @@ export class PermissionGuard implements CanActivate {
): Promise<boolean> {
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: {

View File

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

View File

@ -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<ApiTokenView[]> {
return this.tokens.list(request.user!);
}
@Post()
create(
@Body(new ZodValidationPipe(createApiTokenInputSchema)) input: CreateApiTokenInput,
@Req() request: AuthedRequest,
): Promise<ApiTokenCreatedView> {
return this.tokens.create(request.user!, input);
}
@Delete(':id')
@HttpCode(204)
async revoke(@Param('id') id: string, @Req() request: AuthedRequest): Promise<void> {
await this.tokens.revoke(request.user!, id);
}
}

View File

@ -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_<random>`) 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<ApiTokenCreatedView> {
// 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<ApiTokenView[]> {
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<void> {
// 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<ApiTokenView> {
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');
}

View File

@ -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<number, string> = {
[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<string, unknown>;
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<string, Record<string, unknown>>;
};
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<string, unknown> };
};
expect(document.servers[0]!.url).toBe('/api/public/v1');
expect(document.components.securitySchemes.pat).toBeDefined();
});
});

View File

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

View File

@ -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<object> {
if (!(await this.settings.get('api.enabled'))) throw new NotFoundException();
return buildOpenApiDocument();
}
}

View File

@ -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<PublicMeView> {
return this.publicApi.me(request.user!, request.apiToken!);
}
@Get('ponds')
listPonds(@Req() request: PublicApiRequest): Promise<PublicPondView[]> {
return this.publicApi.listPonds(request.user!, request.apiToken!);
}
@Get('ponds/:pondSlug')
@RequiresPondRole('reader', POND)
getPond(@Param('pondSlug') pondSlug: string): Promise<PublicPondView> {
return this.publicApi.getPond(pondSlug);
}
@Get('ponds/:pondSlug/pages')
@RequiresPondRole('reader', POND)
listPages(
@Param('pondSlug') pondSlug: string,
@Req() request: PublicApiRequest,
): Promise<PublicPageListItemView[]> {
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<PublicPageView> {
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<PublicPageView> {
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<PublicPageView> {
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<void> {
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<PublicSearchResultView[]> {
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<void> {
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<LabelTreeNode[]> {
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<LabelView> {
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<LabelView> {
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<void> {
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<LabelView[]> {
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<void> {
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<PageCommentsView> {
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<PublicCommentView> {
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<PublicCommentView> {
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<PublicCommentView> {
return this.publicApi.setCommentResolved(
request.user!,
request.apiToken!,
pondSlug,
pageSlug,
commentId,
false,
);
}
}

View File

@ -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<string, string> = {};
const cookies: Record<string, string> = {};
const tokens: Record<string, string> = {};
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<void> {
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<string, unknown> = {},
): Promise<ApiTokenCreatedView> {
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('<strong>world</strong>');
// 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('<strong>question</strong>');
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);
});
});

View File

@ -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<boolean> {
if (!(await this.settings.get('api.enabled'))) throw new NotFoundException();
const request = context.switchToHttp().getRequest<PublicApiRequest>();
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<Response>()
.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<ApiTokenScope | undefined>(SCOPE_KEY, [
context.getHandler(),
context.getClass(),
]);
if (required === 'write' && request.apiTokenScope !== 'write') {
throw new ForbiddenException({ code: 'scope_required' });
}
const pondSlug = (request.params as Record<string, string | undefined>).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<void> {
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();
}
}
}

View File

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

View File

@ -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<PublicMeView> {
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<PublicPondView[]> {
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<PublicPondView> {
const pond = await this.requirePond(slug);
return this.pondView(pond);
}
async listPages(user: User, pondSlug: string): Promise<PublicPageListItemView[]> {
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<PublicPageView> {
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<PublicPageView> {
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<PublicPageView> {
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<void> {
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<PublicSearchResultView[]> {
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<LabelTreeNode[]> {
const pond = await this.requirePond(pondSlug);
return this.labels.list(user, pond.id);
}
async createLabel(
user: User,
token: ApiToken,
pondSlug: string,
input: CreateLabelInput,
): Promise<LabelView> {
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<LabelView> {
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<void> {
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<LabelView[]> {
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<void> {
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<PageCommentsView> {
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<PublicCommentView> {
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<PublicCommentView> {
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<PondView> {
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<Page> {
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<void> {
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<Map<string, string>> {
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<Set<string>> {
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<ArrayBuffer> {
const json = markdownToDoc(markdown).toJSON();
return docToState(Node.fromJSON(editorSchema, json));
}
private async auditWrite(
user: User,
token: ApiToken,
op: string,
targetId: string,
): Promise<void> {
await this.audit.record({
action: 'api.write',
actorId: user.id,
targetType: 'api_write',
targetId,
details: { op, tokenId: token.id, tokenName: token.name },
});
}
}

View File

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

View File

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

View File

@ -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<ArrayBuffer>,
label: string,
): Promise<void> {
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

View File

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

View File

@ -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<unknown>(null);
const [saved, setSaved] = useState(false);
const save = async (enabled: boolean): Promise<void> => {
setError(null);
setSaved(false);
try {
await apiPatch(`/ponds/${pondId}`, { apiEnabled: enabled });
await queryClient.invalidateQueries({ queryKey: ['pond', pondSlug] });
setSaved(true);
} catch (err) {
setError(err);
}
};
return (
<div className="api-opt-in">
<FormError error={error} />
<FormSuccess message={saved ? t('pond.saved') : null} />
<label className="api-opt-in__label">
<input
type="checkbox"
checked={value}
onChange={(event) => void save(event.target.checked)}
/>
{t('pond.label')}
</label>
<p className="api-opt-in__hint">{t('pond.hint')}</p>
</div>
);
}

View File

@ -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<ApiTokenView[]>('/users/me/api-tokens'),
});
return (
<section className="settings-section api-tokens">
<h2>{t('section.title')}</h2>
<p className="api-tokens__intro">{t('section.intro')}</p>
<CreateTokenForm />
{tokens.data && tokens.data.length === 0 && <p>{t('section.empty')}</p>}
{tokens.data && tokens.data.length > 0 && <TokenList tokens={tokens.data} />}
</section>
);
}
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<string[]>([]);
const [created, setCreated] = useState<ApiTokenCreatedView | null>(null);
const [copied, setCopied] = useState(false);
const [error, setError] = useState<unknown>(null);
const [busy, setBusy] = useState(false);
const ponds = useQuery({
queryKey: ['ponds'],
queryFn: () => apiGet<PondView[]>('/ponds'),
});
const submit = async (event: React.FormEvent): Promise<void> => {
event.preventDefault();
setError(null);
setCreated(null);
setCopied(false);
setBusy(true);
try {
const view = await apiPost<ApiTokenCreatedView>('/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 (
<form className="api-tokens__create" onSubmit={(event) => void submit(event)}>
<FormError error={error} />
<label>
{t('fields.name')}
<input
type="text"
value={name}
required
maxLength={80}
onChange={(event) => setName(event.target.value)}
/>
</label>
<label>
{t('fields.scope')}
<select
value={scope}
onChange={(event) => setScope(event.target.value as 'read' | 'write')}
>
<option value="read">{t('fields.scopeRead')}</option>
<option value="write">{t('fields.scopeWrite')}</option>
</select>
</label>
<label>
{t('fields.expiresAt')}
<input
type="date"
value={expiresAt}
onChange={(event) => setExpiresAt(event.target.value)}
/>
</label>
<fieldset className="api-tokens__ponds">
<legend>{t('fields.ponds')}</legend>
<p className="api-tokens__hint">{t('fields.pondsHint')}</p>
{(ponds.data ?? []).map((pond) => (
<label key={pond.id} className="api-tokens__pond">
<input
type="checkbox"
checked={pondIds.includes(pond.id)}
onChange={(event) =>
setPondIds((current) =>
event.target.checked
? [...current, pond.id]
: current.filter((id) => id !== pond.id),
)
}
/>
{pond.name}
</label>
))}
</fieldset>
<button type="submit" className="button" disabled={busy || !name.trim()}>
{busy ? t('create.pending') : t('create.button')}
</button>
{created && (
<div className="api-tokens__created" role="status">
<h3>{t('create.createdTitle')}</h3>
<p>{t('create.createdHint')}</p>
<code className="api-tokens__secret">{created.token}</code>
<button
type="button"
className="button"
onClick={() => {
void navigator.clipboard.writeText(created.token).then(() => setCopied(true));
}}
>
{copied ? t('create.copied') : t('create.copy')}
</button>
</div>
)}
</form>
);
}
function TokenList({ tokens }: { tokens: ApiTokenView[] }): React.JSX.Element {
const { t } = useTranslation('apiTokens');
const queryClient = useQueryClient();
const [error, setError] = useState<unknown>(null);
const revoke = async (id: string): Promise<void> => {
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 (
<>
<FormError error={error} />
<table className="table api-tokens__table">
<thead>
<tr>
<th>{t('fields.name')}</th>
<th>{t('fields.scope')}</th>
<th>{t('fields.ponds')}</th>
<th>{t('list.created')}</th>
<th>{t('list.lastUsed')}</th>
<th>{t('list.expires')}</th>
<th>{t('list.status')}</th>
<th></th>
</tr>
</thead>
<tbody>
{tokens.map((token) => (
<tr key={token.id}>
<td>{token.name}</td>
<td>{token.scope === 'write' ? t('fields.scopeWrite') : t('fields.scopeRead')}</td>
<td>
{token.ponds.length === 0
? t('list.allPonds')
: token.ponds.map((pond) => pond.name).join(', ')}
</td>
<td>{new Date(token.createdAt).toLocaleDateString()}</td>
<td>
{token.lastUsedAt ? new Date(token.lastUsedAt).toLocaleString() : t('list.never')}
</td>
<td>{token.expiresAt ? new Date(token.expiresAt).toLocaleDateString() : '—'}</td>
<td>{t(`list.${statusOf(token)}`)}</td>
<td>
{!token.revokedAt && (
<button
type="button"
className="button api-tokens__revoke"
onClick={() => void revoke(token.id)}
>
{t('list.revoke')}
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</>
);
}

View File

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

View File

@ -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 {
</section>
<UploadSettingsForm settings={settings.data} />
<PublicApiSettingsForm settings={settings.data} />
<LegalSettingsForm settings={settings.data} />
<PluginManager />
@ -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<unknown>(null);
const [saved, setSaved] = useState(false);
async function save(enabled: boolean): Promise<void> {
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 (
<section className="settings-section">
<h2>{t('admin.title')}</h2>
<FormError error={error} />
<FormSuccess message={saved ? t('admin.saved') : null} />
<label className="api-opt-in__label">
<input
type="checkbox"
checked={settings['api.enabled']}
onChange={(event) => void save(event.target.checked)}
/>
{t('admin.label')}
</label>
<p className="api-opt-in__hint">{t('admin.hint')}</p>
</section>
);
}
/**
* Legal pages (issue #82): imprint and privacy policy as Markdown, shown
* publicly at /legal/imprint and /legal/privacy. The preview renders through

View File

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

View File

@ -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 {
/>
</section>
)}
{canModify && (
<section>
<h2>{tApiTokens('pond.title')}</h2>
<ApiOptInSetting
pondId={pond.data.id}
pondSlug={pondSlug}
value={pond.data.settings.apiEnabled}
/>
</section>
)}
<section className="pond-export">
<h2>{tExport('pond.heading')}</h2>
<p className="pond-export__hint">{tExport('pond.hint')}</p>

View File

@ -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 {
<PasswordSection />
<SessionsSection />
<WatchesSection />
<ApiTokensSection />
<DataExportSection />
</>
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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_<random>`), 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<typeof createApiTokenInputSchema>;
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;
}

View File

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

View File

@ -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<typeof pondSettingsSchema>;
@ -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<typeof updatePondInputSchema>;

View File

@ -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<typeof publicCreatePageInputSchema>;
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<typeof publicUpdatePageInputSchema>;
/**
* 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<typeof publicUpdateLabelInputSchema>;
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<typeof publicSearchQuerySchema>;
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;