Page-tree parity for the public REST API and MCP (#110)
Some checks failed
CD / Build and push images (push) Successful in 3m57s
CD / Deploy to Test (push) Successful in 9s
CI / Lint, typecheck, test (push) Failing after 4m16s
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 / Smoke tests against Test (push) Successful in 1m19s
CD / Promote to Int (push) Successful in 11s

The slug-based machine surfaces now see and shape the hierarchy:

- REST: page list/detail carry parent (the parent page's slug, nulled
  when the token's user may not read it — same no-leak rule as the
  internal list); create accepts parent; PATCH accepts parent
  (slug nests, null moves to the top level, appended at the end of the
  new sibling group via the new PagesService.moveToEnd). Cycle/depth
  refusals keep their regular error codes. OpenAPI updated.
- MCP: list_pages returns parent, create_page takes an optional parent
  slug, update_page moves with parent (slug|null); tool errors carry
  the api code (page_cycle covered in the e2e pack).
- ZIP export deliberately stays flat — noted in features.md; the
  hierarchy is organizational only.

e2e: REST pack covers nested create, list shape, move/root-move, 409
page_cycle, 404 unknown parent; MCP pack covers nested create, list
parent, and the cycle tool error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Claude Fable 5 2026-07-14 10:49:52 +02:00
parent 0308bc712d
commit ffcc337ed0
12 changed files with 212 additions and 31 deletions

View File

@ -25,6 +25,7 @@ export const MCP_TOOL_INPUTS = {
pond,
title: z.string().min(1).max(200),
markdown: z.string().default(''),
parent: page.optional(),
}),
update_page: z
.object({
@ -32,10 +33,13 @@ export const MCP_TOOL_INPUTS = {
page,
title: z.string().min(1).max(200).optional(),
markdown: z.string().optional(),
parent: page.nullable().optional(),
})
.refine((input) => input.title !== undefined || input.markdown !== undefined, {
message: 'title or markdown required',
}),
.refine(
(input) =>
input.title !== undefined || input.markdown !== undefined || input.parent !== undefined,
{ message: 'title, markdown, or parent required' },
),
add_comment: z.object({ pond, page, text: z.string().min(1) }),
list_labels: z.object({ pond }),
set_page_labels: z.object({ pond, page, labelIds: z.array(z.string()) }),
@ -62,7 +66,9 @@ export const MCP_TOOL_DEFINITIONS: {
},
{
name: 'list_pages',
description: 'List the readable pages of a pond: slug, title, labels, timestamps.',
description:
'List the readable pages of a pond: slug, title, parent (the page tree), labels, ' +
'timestamps.',
inputSchema: { type: 'object', properties: { pond: pondProp }, required: ['pond'] },
},
{
@ -100,6 +106,10 @@ export const MCP_TOOL_DEFINITIONS: {
pond: pondProp,
title: { type: 'string', maxLength: 200 },
markdown: { type: 'string', description: 'Initial content as Markdown' },
parent: {
type: 'string',
description: 'Optional parent page slug — nests the new page under it',
},
},
required: ['pond', 'title'],
},
@ -118,6 +128,11 @@ export const MCP_TOOL_DEFINITIONS: {
page: pageProp,
title: { type: 'string', maxLength: 200 },
markdown: { type: 'string', description: 'Replacement content as Markdown' },
parent: {
type: ['string', 'null'],
description:
'Move the page in the tree: a page slug nests it, null moves it to the top level',
},
},
required: ['pond', 'page'],
},

View File

@ -269,6 +269,30 @@ describe.skipIf(!hasTestDb)('mcp endpoint (e2e, issue #105)', () => {
});
expect(JSON.parse(textOf(link)).url).toContain(`/api/public/v1/ponds/${pondSlug}/export`);
// Page-tree parity (issue #110): create nested, list carries the parent
// slug, moving into the own subtree is a tool error with the api code.
const nested = await client.callTool({
name: 'create_page',
arguments: { pond: pondSlug, title: `MCP Child ${suffix}`, parent: page.slug },
});
expect(nested.isError).toBeFalsy();
const childPage = JSON.parse(textOf(nested)) as { slug: string; parent: string | null };
expect(childPage.parent).toBe(page.slug);
const pages = await client.callTool({
name: 'list_pages',
arguments: { pond: pondSlug },
});
const items = JSON.parse(textOf(pages)) as { slug: string; parent: string | null }[];
expect(items.find((p) => p.slug === childPage.slug)?.parent).toBe(page.slug);
const cyclic = await client.callTool({
name: 'update_page',
arguments: { pond: pondSlug, page: page.slug, parent: childPage.slug },
});
expect(cyclic.isError).toBe(true);
expect(textOf(cyclic)).toContain('page_cycle');
await client.close();
});

View File

@ -89,7 +89,10 @@ export class McpService {
name: Name,
args: z.infer<(typeof MCP_TOOL_INPUTS)[Name]>,
): Promise<ToolResult> {
const input = args as Record<string, string> & { labelIds?: string[] };
const input = args as Record<string, string> & {
labelIds?: string[];
parent?: string | null;
};
switch (name) {
case 'list_ponds':
return asJson(await this.publicApi.listPonds(user, token, 'mcp'));
@ -101,7 +104,7 @@ export class McpService {
case 'read_page':
await this.assertPondExposed(input.pond!, token);
await this.requirePage(user, input.pond!, input.page!, 'read');
return asJson(await this.publicApi.getPage(input.pond!, input.page!));
return asJson(await this.publicApi.getPage(user, input.pond!, input.page!));
case 'search': {
if (input.pond) await this.assertPondExposed(input.pond, token);
@ -123,6 +126,7 @@ export class McpService {
await this.publicApi.createPage(user, token, input.pond!, {
title: input.title!,
markdown: input.markdown ?? '',
parent: input.parent ?? undefined,
}),
);
@ -134,6 +138,7 @@ export class McpService {
await this.publicApi.updatePage(user, token, input.pond!, input.page!, {
title: input.title,
markdown: input.markdown,
parent: input.parent,
}),
);

View File

@ -224,8 +224,24 @@ export class PagesService {
pondId: string,
title: string,
state: Uint8Array<ArrayBuffer>,
parentId: string | null = null,
): Promise<Page> {
return this.insertPage(user, pondId, title, state);
return this.insertPage(user, pondId, title, state, parentId);
}
/**
* Move a page to a new parent, appended at the end of the pond order
* (issue #110): the placement clients cannot express when they only speak
* slugs (public API, MCP). Same validation path as {@link reposition}.
*/
async moveToEnd(user: User, id: string, parentId: string | null): Promise<PageView> {
const page = await this.findLivePage(id);
const last = await this.prisma.page.findFirst({
where: { pondId: page.pondId, deletedAt: null, id: { not: id } },
orderBy: { sortKey: 'desc' },
select: { id: true },
});
return this.reposition(user, id, { afterId: last?.id ?? null, beforeId: null, parentId });
}
private async insertPage(

View File

@ -101,6 +101,12 @@ export function buildOpenApiDocument(): object {
properties: {
slug: { type: 'string' },
title: { type: 'string' },
parent: {
type: ['string', 'null'],
description:
'Parent page slug in the page tree; null at the root or when the parent ' +
'is not readable for this token.',
},
labels: { type: 'array', items: { type: 'string' } },
createdAt: { type: 'string', format: 'date-time' },
updatedAt: { type: 'string', format: 'date-time' },
@ -112,6 +118,10 @@ export function buildOpenApiDocument(): object {
slug: { type: 'string' },
title: { type: 'string' },
pondSlug: { type: 'string' },
parent: {
type: ['string', 'null'],
description: 'Parent page slug; see PageListItem.parent.',
},
markdown: { type: 'string' },
html: { type: 'string', description: 'Server-rendered, sanitized HTML.' },
labels: { type: 'array', items: { type: 'string' } },
@ -125,6 +135,10 @@ export function buildOpenApiDocument(): object {
properties: {
title: { type: 'string', maxLength: 200 },
markdown: { type: 'string', description: 'Initial content; empty allowed.' },
parent: {
type: 'string',
description: 'Parent page slug — nests the new page under it (max depth 6).',
},
},
},
UpdatePage: {
@ -138,6 +152,13 @@ export function buildOpenApiDocument(): object {
'Replaces the whole content. Applied through the collaborative document, ' +
'so open editors converge; the response may briefly lag the change.',
},
parent: {
type: ['string', 'null'],
description:
'Move the page in the tree: a page slug nests it (appended to the new ' +
'sibling group), null moves it to the top level. Moving a page into its ' +
'own subtree or past the depth limit is rejected (409).',
},
},
},
SearchResult: {

View File

@ -109,8 +109,9 @@ export class PublicApiController {
getPage(
@Param('pondSlug') pondSlug: string,
@Param('pageSlug') pageSlug: string,
@Req() request: PublicApiRequest,
): Promise<PublicPageView> {
return this.publicApi.getPage(pondSlug, pageSlug);
return this.publicApi.getPage(request.user!, pondSlug, pageSlug);
}
@Patch('ponds/:pondSlug/pages/:pageSlug')

View File

@ -5,6 +5,7 @@ import {
type ApiTokenCreatedView,
type ApiTokenView,
type LabelView,
type PublicPageListItemView,
type PublicPageView,
type PublicPondView,
type PublicSearchResultView,
@ -405,6 +406,60 @@ describe.skipIf(!hasTestDb)('public api v1 (e2e, issue #104)', () => {
expect(ops).toEqual(expect.arrayContaining(['page_created', 'page_updated', 'page_trashed']));
});
it('exposes and shapes the page tree through parent slugs (issue #110)', async () => {
const parent = await pub()
.post(`/api/public/v1/ponds/${pondSlug}/pages`)
.set('Authorization', bearer('editor'))
.send({ title: `Tree Parent ${suffix}` })
.expect(201);
const parentSlug = (parent.body as PublicPageView).slug;
// Create nested; the response and the list carry the parent slug.
const child = await pub()
.post(`/api/public/v1/ponds/${pondSlug}/pages`)
.set('Authorization', bearer('editor'))
.send({ title: `Tree Child ${suffix}`, parent: parentSlug })
.expect(201);
const childView = child.body as PublicPageView;
expect(childView.parent).toBe(parentSlug);
const listed = await pub()
.get(`/api/public/v1/ponds/${pondSlug}/pages`)
.set('Authorization', bearer('reader'))
.expect(200);
const listedChild = listed.body.find(
(p: { slug: string }) => p.slug === childView.slug,
) as PublicPageListItemView;
expect(listedChild.parent).toBe(parentSlug);
// Move to the root and back via PATCH parent.
const rooted = await pub()
.patch(`/api/public/v1/ponds/${pondSlug}/pages/${childView.slug}`)
.set('Authorization', bearer('editor'))
.send({ parent: null })
.expect(200);
expect((rooted.body as PublicPageView).parent).toBeNull();
await pub()
.patch(`/api/public/v1/ponds/${pondSlug}/pages/${childView.slug}`)
.set('Authorization', bearer('editor'))
.send({ parent: parentSlug })
.expect(200);
// A cycle is refused with the regular error code.
const cycle = await pub()
.patch(`/api/public/v1/ponds/${pondSlug}/pages/${parentSlug}`)
.set('Authorization', bearer('editor'))
.send({ parent: childView.slug })
.expect(409);
expect(cycle.body.code).toBe('page_cycle');
// An unknown parent slug reads as 404 on create.
await pub()
.post(`/api/public/v1/ponds/${pondSlug}/pages`)
.set('Authorization', bearer('editor'))
.send({ title: 'Orphan', parent: `missing-${suffix}` })
.expect(404);
});
it('manages labels with pond-admin rights and assigns them to pages', async () => {
const label = await pub()
.post(`/api/public/v1/ponds/${pondSlug}/labels`)

View File

@ -33,6 +33,7 @@ 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';
@ -59,6 +60,7 @@ export class PublicApiService {
private readonly search: SearchProvider,
private readonly tokens: ApiTokensService,
private readonly audit: AuditService,
private readonly permissions: PermissionService,
) {}
async me(user: User, token: ApiToken): Promise<PublicMeView> {
@ -97,26 +99,32 @@ export class PublicApiService {
this.pages.list(user, pond.id),
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,
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(pondSlug: string, pageSlug: string): Promise<PublicPageView> {
async getPage(user: User, pondSlug: string, pageSlug: string): Promise<PublicPageView> {
const page = await this.requirePage(pondSlug, pageSlug);
const [cache, pageLabels, labelNames] = await Promise.all([
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,
parent,
markdown: cache?.markdown ?? '',
html: cache?.html ?? '',
labels: pageLabels.map((row) => labelNames.get(row.labelId) ?? row.labelId).sort(),
@ -132,10 +140,11 @@ export class PublicApiService {
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);
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(pondSlug, page.slug);
return this.getPage(user, pondSlug, page.slug);
}
/**
@ -159,8 +168,15 @@ export class PublicApiService {
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(pondSlug, page.slug);
return this.getPage(user, pondSlug, page.slug);
}
async deletePage(user: User, token: ApiToken, pondSlug: string, pageSlug: string): Promise<void> {
@ -359,6 +375,18 @@ export class PublicApiService {
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 } },

View File

@ -39,6 +39,9 @@ projects.
## Organize the way you think
- **Page tree**: nest pages under pages (up to 6 levels) — slugs and links
stay flat, so moving a page never breaks anything; the ZIP export also
stays flat (the hierarchy is organizational only)
- **Labels**, hierarchical if you like, to slice a pond any way you want
— and to scope access rules (see below).
- **Fast full-text search** across everything you may read — accent- and

View File

@ -46,7 +46,7 @@ and any pond restriction.
# The ponds this token can reach
curl -H "$AUTH" https://wiki.example.com/api/public/v1/ponds
# Pages of a pond: slug, title, labels, timestamps
# Pages of a pond: slug, title, parent (page-tree slug), labels, timestamps
curl -H "$AUTH" https://wiki.example.com/api/public/v1/ponds/team/pages
# One page — Markdown source AND rendered, sanitized HTML
@ -66,12 +66,13 @@ slugs for follow-up calls.
## Writing (requires the `write` scope)
```sh
# Create a page from Markdown
# Create a page from Markdown (optional "parent": a page slug nests it)
curl -H "$AUTH" -H 'Content-Type: application/json' \
-d '{"title": "Meeting notes", "markdown": "# Agenda\n\n- Ducks\n"}' \
https://wiki.example.com/api/public/v1/ponds/team/pages
# Rename and/or REPLACE the content
# Rename, REPLACE the content, and/or move in the page tree
# ("parent": <slug> nests the page, "parent": null moves it to the top level)
curl -X PATCH -H "$AUTH" -H 'Content-Type: application/json' \
-d '{"markdown": "New content."}' \
https://wiki.example.com/api/public/v1/ponds/team/pages/meeting-notes

View File

@ -61,18 +61,18 @@ clients bridge with `mcp-remote`:
## What the assistant can do
| Tool | Does |
| -------------------------------------------- | ------------------------------------------- |
| `list_ponds` | the ponds this token can reach |
| `list_pages(pond)` | pages with slug, title, labels, timestamps |
| `read_page(pond, page)` | a page as Markdown plus metadata |
| `search(query, pond?, label?)` | full-text search with snippets |
| `create_page(pond, title, markdown)` | new page from Markdown _(write)_ |
| `update_page(pond, page, markdown?, title?)` | rename and/or replace content _(write)_ |
| `add_comment(pond, page, text)` | comment on a page _(write)_ |
| `list_labels(pond)` | the pond's label tree |
| `set_page_labels(pond, page, labelIds)` | replace a page's labels _(write)_ |
| `export_pond(pond)` | a download link for the Markdown-ZIP export |
| Tool | Does |
| ----------------------------------------------------- | ------------------------------------------- |
| `list_ponds` | the ponds this token can reach |
| `list_pages(pond)` | pages with slug, title, parent, labels |
| `read_page(pond, page)` | a page as Markdown plus metadata |
| `search(query, pond?, label?)` | full-text search with snippets |
| `create_page(pond, title, markdown, parent?)` | new page from Markdown _(write)_ |
| `update_page(pond, page, markdown?, title?, parent?)` | rename, replace content, move _(write)_ |
| `add_comment(pond, page, text)` | comment on a page _(write)_ |
| `list_labels(pond)` | the pond's label tree |
| `set_page_labels(pond, page, labelIds)` | replace a page's labels _(write)_ |
| `export_pond(pond)` | a download link for the Markdown-ZIP export |
Content updates travel the same collaborative path as human edits: open
editors converge live, and the previous state stays in the version

View File

@ -29,6 +29,9 @@ export interface PublicPondView {
export interface PublicPageListItemView {
slug: string;
title: string;
/** Parent page slug in the tree (issue #110), or null at the root nulled
* as well when the token's user may not read the parent (no existence leak). */
parent: string | null;
labels: string[];
createdAt: string;
updatedAt: string;
@ -38,6 +41,8 @@ export interface PublicPageView {
slug: string;
title: string;
pondSlug: string;
/** Parent page slug (issue #110); see {@link PublicPageListItemView.parent}. */
parent: string | null;
markdown: string;
html: string;
labels: string[];
@ -52,6 +57,8 @@ 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(''),
/** Parent page slug (issue #110) — nests the new page under it. */
parent: z.string().min(1).optional(),
});
export type PublicCreatePageInput = z.infer<typeof publicCreatePageInputSchema>;
@ -60,11 +67,16 @@ export const publicUpdatePageInputSchema = z
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),
/** Move in the tree (issue #110): a page slug nests, `null` moves to the
* root; the page lands at the end of its new sibling group. */
parent: z.string().min(1).nullable(),
})
.partial()
.refine((input) => input.title !== undefined || input.markdown !== undefined, {
message: 'validation.required',
});
.refine(
(input) =>
input.title !== undefined || input.markdown !== undefined || input.parent !== undefined,
{ message: 'validation.required' },
);
export type PublicUpdatePageInput = z.infer<typeof publicUpdatePageInputSchema>;
/**