dorfteich/apps/api/src/public-api/public-api.service.ts
Claude Fable 5 521ea514b4
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m38s
CI / Build container images (pull_request) Successful in 4m14s
CI / Auth e2e pack (pull_request) Successful in 9m7s
CI / Import/export fidelity gate (pull_request) Successful in 1m6s
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CD / Build and push images (push) Has been cancelled
CI / Lint, typecheck, test (push) Has been cancelled
#211: classification through feeds, public API, search and the no-JS shell
Feeds: classified entries carry a standard Atom <category>
(term=level, scheme=urn:dorfteich:classification, label=the fixed
wording); the feed document states the highest contained level once;
all-open feeds carry none. Public API: page representations (list+get)
gain the classification field, OpenAPI + public-api.md documented.
Search: every hit carries the level and the palette renders the marking
with the snippet (compact form of the banner, text token only). No-JS
shell: banner above and below the content, own markup for the separate
render path; unclassified pages unchanged everywhere. One test per
channel (feed categories + count, public API list/get with the switch
on, search hit levels, shell top+bottom).

Also: fidelity CI sidecars get per-job container names — the fixed
names collided across parallel runs on the shared host (run 547's red
fidelity job; a fixed-name cleanup could even kill a sibling's live
sidecars).

Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com>
2026-07-31 07:23:53 +02:00

456 lines
16 KiB
TypeScript

import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import {
SEARCH_HIGHLIGHT_END,
SEARCH_HIGHLIGHT_START,
editorSchema,
markdownToDoc,
pondFeatureEnabled,
pondSettingsSchema,
type CommentListFilter,
type PageClassification,
type PageListQuery,
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 PondExposureFeature,
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 { PermissionService } from '../permissions/permission.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,
private readonly permissions: PermissionService,
) {}
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 exposed ponds visible to the token's user, within restriction. */
async listPonds(
user: User,
token: ApiToken,
feature: PondExposureFeature = 'api',
): Promise<PublicPondView[]> {
const ponds = await this.listPondRowsExposed(user, token, feature);
return ponds.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,
query?: PageListQuery,
): Promise<PublicPageListItemView[]> {
const pond = await this.requirePond(pondSlug);
const [items, labelNames] = await Promise.all([
this.pages.list(user, pond.id, query),
this.labelNames(pond.id),
]);
// parentId is already permission-nulled by the list (#106); mapping it
// to a slug within the same filtered list keeps that guarantee.
const slugById = new Map(items.map((item) => [item.id, item.slug]));
return items.map((item) => ({
slug: item.slug,
title: item.title,
classification: item.classification,
parent: (item.parentId && slugById.get(item.parentId)) || null,
labels: item.labelIds.map((id) => labelNames.get(id) ?? id).sort(),
createdAt: item.createdAt,
updatedAt: item.updatedAt,
}));
}
async getPage(user: User, pondSlug: string, pageSlug: string): Promise<PublicPageView> {
const page = await this.requirePage(pondSlug, pageSlug);
const [cache, pageLabels, labelNames, parent] = 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),
this.readableParentSlug(user, page),
]);
return {
slug: page.slug,
title: page.title,
pondSlug,
// VS-NfD level (#211): part of the versioned representation so API
// consumers can carry the marking onward.
classification: page.classification.toLowerCase() as PageClassification,
parent,
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 parentId = input.parent ? (await this.requirePage(pondSlug, input.parent)).id : null;
const state = this.stateFromMarkdown(input.markdown);
const page = await this.pages.createWithState(user, pond.id, input.title, state, parentId);
await this.auditWrite(user, token, 'page_created', page.id);
return this.getPage(user, 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');
}
if (input.parent !== undefined) {
// Move in the tree (issue #110): a slug nests, null goes to the root;
// cycle/depth refusals surface as their regular error codes.
const parentId =
input.parent === null ? null : (await this.requirePage(pondSlug, input.parent)).id;
await this.pages.moveToEnd(user, page.id, parentId);
}
await this.auditWrite(user, token, 'page_updated', page.id);
return this.getPage(user, 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,
feature: PondExposureFeature = 'api',
): Promise<PublicSearchResultView[]> {
const exposed = await this.exposedPondIds(user, token, feature);
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;
}
/** The parent's slug, or null when there is none or the user may not read
* it (the same no-leak rule as the internal list, issue #106). */
private async readableParentSlug(user: User, page: Page): Promise<string | null> {
if (!page.parentId) return null;
const parent = await this.prisma.page.findFirst({
where: { id: page.parentId, deletedAt: null },
});
if (!parent) return null;
const readable = await this.permissions.canAccessPage(user, parent, 'read');
return readable ? parent.slug : null;
}
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,
feature: PondExposureFeature,
): Promise<Set<string>> {
const ponds = await this.listPondRowsExposed(user, token, feature);
return new Set(ponds.map((pond) => pond.id));
}
private async listPondRowsExposed(
user: User,
token: ApiToken,
feature: PondExposureFeature,
): Promise<PondView[]> {
const visible = await this.ponds.listVisible(user);
return visible
.filter((pond) => pondFeatureEnabled(pond.settings, feature))
.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 },
});
}
}