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
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>
456 lines
16 KiB
TypeScript
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 },
|
|
});
|
|
}
|
|
}
|