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
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:
parent
5cef359b8f
commit
0c85293830
@ -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;
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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. */
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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 },
|
||||
|
||||
42
apps/api/src/public-api/api-tokens.controller.ts
Normal file
42
apps/api/src/public-api/api-tokens.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
150
apps/api/src/public-api/api-tokens.service.ts
Normal file
150
apps/api/src/public-api/api-tokens.service.ts
Normal 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');
|
||||
}
|
||||
65
apps/api/src/public-api/openapi.test.ts
Normal file
65
apps/api/src/public-api/openapi.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
397
apps/api/src/public-api/openapi.ts
Normal file
397
apps/api/src/public-api/openapi.ts
Normal 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')) },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
22
apps/api/src/public-api/public-api-docs.controller.ts
Normal file
22
apps/api/src/public-api/public-api-docs.controller.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
315
apps/api/src/public-api/public-api.controller.ts
Normal file
315
apps/api/src/public-api/public-api.controller.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
575
apps/api/src/public-api/public-api.e2e.db.test.ts
Normal file
575
apps/api/src/public-api/public-api.e2e.db.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
118
apps/api/src/public-api/public-api.guard.ts
Normal file
118
apps/api/src/public-api/public-api.guard.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
44
apps/api/src/public-api/public-api.module.ts
Normal file
44
apps/api/src/public-api/public-api.module.ts
Normal 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 {}
|
||||
405
apps/api/src/public-api/public-api.service.ts
Normal file
405
apps/api/src/public-api/public-api.service.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
54
apps/web/src/api-tokens/ApiOptInSetting.tsx
Normal file
54
apps/web/src/api-tokens/ApiOptInSetting.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
221
apps/web/src/api-tokens/ApiTokensSection.tsx
Normal file
221
apps/web/src/api-tokens/ApiTokensSection.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 />
|
||||
</>
|
||||
);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 =
|
||||
|
||||
63
docs/self-hosting/public-api.md
Normal file
63
docs/self-hosting/public-api.md
Normal 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.
|
||||
51
packages/shared/i18n/de/apiTokens.json
Normal file
51
packages/shared/i18n/de/apiTokens.json
Normal 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."
|
||||
}
|
||||
}
|
||||
@ -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."
|
||||
}
|
||||
|
||||
@ -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": {
|
||||
|
||||
51
packages/shared/i18n/en/apiTokens.json
Normal file
51
packages/shared/i18n/en/apiTokens.json
Normal 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."
|
||||
}
|
||||
}
|
||||
@ -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."
|
||||
}
|
||||
|
||||
@ -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": {
|
||||
|
||||
43
packages/shared/src/api-tokens.ts
Normal file
43
packages/shared/src/api-tokens.ts
Normal 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;
|
||||
}
|
||||
@ -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';
|
||||
|
||||
@ -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>;
|
||||
|
||||
100
packages/shared/src/public-api.ts
Normal file
100
packages/shared/src/public-api.ts
Normal 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;
|
||||
Loading…
Reference in New Issue
Block a user