From ffcc337ed0dd3e005accf371cfc69f2747a024ac Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Tue, 14 Jul 2026 10:49:52 +0200 Subject: [PATCH] Page-tree parity for the public REST API and MCP (#110) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/api/src/mcp/mcp-tools.ts | 23 ++++++-- apps/api/src/mcp/mcp.e2e.db.test.ts | 24 ++++++++ apps/api/src/mcp/mcp.service.ts | 9 ++- apps/api/src/pages/pages.service.ts | 18 +++++- apps/api/src/public-api/openapi.ts | 21 +++++++ .../src/public-api/public-api.controller.ts | 3 +- .../src/public-api/public-api.e2e.db.test.ts | 55 +++++++++++++++++++ apps/api/src/public-api/public-api.service.ts | 38 +++++++++++-- docs/features.md | 3 + docs/manual/api-guide.md | 7 ++- docs/manual/mcp-guide.md | 24 ++++---- packages/shared/src/public-api.ts | 18 +++++- 12 files changed, 212 insertions(+), 31 deletions(-) diff --git a/apps/api/src/mcp/mcp-tools.ts b/apps/api/src/mcp/mcp-tools.ts index a03844e..f2182fc 100644 --- a/apps/api/src/mcp/mcp-tools.ts +++ b/apps/api/src/mcp/mcp-tools.ts @@ -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'], }, diff --git a/apps/api/src/mcp/mcp.e2e.db.test.ts b/apps/api/src/mcp/mcp.e2e.db.test.ts index 1fd0676..ba91368 100644 --- a/apps/api/src/mcp/mcp.e2e.db.test.ts +++ b/apps/api/src/mcp/mcp.e2e.db.test.ts @@ -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(); }); diff --git a/apps/api/src/mcp/mcp.service.ts b/apps/api/src/mcp/mcp.service.ts index f0894e5..91b861b 100644 --- a/apps/api/src/mcp/mcp.service.ts +++ b/apps/api/src/mcp/mcp.service.ts @@ -89,7 +89,10 @@ export class McpService { name: Name, args: z.infer<(typeof MCP_TOOL_INPUTS)[Name]>, ): Promise { - const input = args as Record & { labelIds?: string[] }; + const input = args as Record & { + 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, }), ); diff --git a/apps/api/src/pages/pages.service.ts b/apps/api/src/pages/pages.service.ts index 3508751..295f7d8 100644 --- a/apps/api/src/pages/pages.service.ts +++ b/apps/api/src/pages/pages.service.ts @@ -224,8 +224,24 @@ export class PagesService { pondId: string, title: string, state: Uint8Array, + parentId: string | null = null, ): Promise { - 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 { + 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( diff --git a/apps/api/src/public-api/openapi.ts b/apps/api/src/public-api/openapi.ts index 01b616d..24b142c 100644 --- a/apps/api/src/public-api/openapi.ts +++ b/apps/api/src/public-api/openapi.ts @@ -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: { diff --git a/apps/api/src/public-api/public-api.controller.ts b/apps/api/src/public-api/public-api.controller.ts index 4813f7b..9ac57f1 100644 --- a/apps/api/src/public-api/public-api.controller.ts +++ b/apps/api/src/public-api/public-api.controller.ts @@ -109,8 +109,9 @@ export class PublicApiController { getPage( @Param('pondSlug') pondSlug: string, @Param('pageSlug') pageSlug: string, + @Req() request: PublicApiRequest, ): Promise { - return this.publicApi.getPage(pondSlug, pageSlug); + return this.publicApi.getPage(request.user!, pondSlug, pageSlug); } @Patch('ponds/:pondSlug/pages/:pageSlug') diff --git a/apps/api/src/public-api/public-api.e2e.db.test.ts b/apps/api/src/public-api/public-api.e2e.db.test.ts index a151f5f..b8b91f6 100644 --- a/apps/api/src/public-api/public-api.e2e.db.test.ts +++ b/apps/api/src/public-api/public-api.e2e.db.test.ts @@ -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`) diff --git a/apps/api/src/public-api/public-api.service.ts b/apps/api/src/public-api/public-api.service.ts index dd4b893..8dc0382 100644 --- a/apps/api/src/public-api/public-api.service.ts +++ b/apps/api/src/public-api/public-api.service.ts @@ -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 { @@ -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 { + async getPage(user: User, pondSlug: string, pageSlug: string): Promise { 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 { 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 { @@ -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 { + 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 { const label = await this.prisma.label.findFirst({ where: { id: labelId, pond: { slug: pondSlug, deletedAt: null } }, diff --git a/docs/features.md b/docs/features.md index 4a9444f..976e82d 100644 --- a/docs/features.md +++ b/docs/features.md @@ -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 diff --git a/docs/manual/api-guide.md b/docs/manual/api-guide.md index acbe31e..180166e 100644 --- a/docs/manual/api-guide.md +++ b/docs/manual/api-guide.md @@ -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": 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 diff --git a/docs/manual/mcp-guide.md b/docs/manual/mcp-guide.md index 2a85718..d6654f5 100644 --- a/docs/manual/mcp-guide.md +++ b/docs/manual/mcp-guide.md @@ -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 diff --git a/packages/shared/src/public-api.ts b/packages/shared/src/public-api.ts index 405bf89..482a735 100644 --- a/packages/shared/src/public-api.ts +++ b/packages/shared/src/public-api.ts @@ -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; @@ -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; /**