Add capability-scoped plugin API endpoints (#74, read surface)
All checks were successful
CI / Import/export fidelity gate (push) Successful in 58s
CI / Build container images (push) Has been skipped
CI / Lint, typecheck, test (push) Successful in 3m2s
CD / Build and push images (push) Successful in 3m16s
CI / Auth e2e pack (push) Successful in 4m13s
CD / Smoke tests against Test (push) Successful in 1m6s
CD / Deploy to Test (push) Successful in 9s
CD / Promote to Int (push) Successful in 9s
CI / Warm action cache (push) Successful in 10s

Viewer-scoped data API behind /api/v1/plugin/, backing the SDK's
readCurrentPage and readPond capabilities:
- api: PluginApiController with GET plugin/ponds/:id/pages (listPages),
  plugin/pages/:id/{outline,content,meta}. Each reuses the existing
  @RequiresPondRole/@RequiresPagePermission guards and PagesService — no
  parallel permission logic — so a plugin sees exactly what its viewer
  could. New PagesService.outline/meta read the content cache.
- web: host-capabilities builds the host implementations from a per-
  surface context (the host holds the current page/pond ids; a plugin can
  only ask about "the current page" or "this pond"). Wired into the
  sandbox host and PluginFrame; ui.openPage/toast route to host callbacks.
- shared: PluginPageSummary/Meta/Content response types.
- tests: api db test proves a label-restricted reader gets a filtered
  listPages and 404s on the hidden page, non-members are hidden (404),
  anonymous is rejected (401); web unit test pins the endpoint mapping,
  id-encoding, and missing-context rejection.

readBlock/blockData land with the plugin_block node in #76 (block
addressing does not exist in the schema yet).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
This commit is contained in:
Claude Fable 5 2026-07-11 09:52:11 +02:00
parent d1a7d37fc8
commit 48798d4247
9 changed files with 447 additions and 7 deletions

View File

@ -5,10 +5,11 @@ import { SearchModule } from '../search/search.module';
import { PagesController } from './pages.controller'; import { PagesController } from './pages.controller';
import { PagesService } from './pages.service'; import { PagesService } from './pages.service';
import { PluginApiController } from './plugin-api.controller';
@Module({ @Module({
imports: [PondsModule, SearchModule], imports: [PondsModule, SearchModule],
controllers: [PagesController], controllers: [PagesController, PluginApiController],
providers: [PagesService], providers: [PagesService],
exports: [PagesService], exports: [PagesService],
}) })

View File

@ -2,6 +2,7 @@ import { ConflictException, Injectable, NotFoundException } from '@nestjs/common
import { import {
CollabTokenResponse, CollabTokenResponse,
CreatePageInput, CreatePageInput,
OutlineEntry,
PageListItemView, PageListItemView,
PageStateView, PageStateView,
PageView, PageView,
@ -352,6 +353,20 @@ export class PagesService {
return { slug: page.slug, markdown: cache?.markdown ?? '' }; return { slug: page.slug, markdown: cache?.markdown ?? '' };
} }
/** The page's heading outline from the content cache (plugin API #74).
* Permission is enforced by the caller's page-read guard. */
async outline(id: string): Promise<OutlineEntry[]> {
const page = await this.findLivePage(id);
const cache = await this.prisma.pageContentCache.findUnique({ where: { pageId: page.id } });
return (cache?.outline as unknown as OutlineEntry[] | undefined) ?? [];
}
/** Minimal page metadata for the plugin API (#74): id, title, pond, slug. */
async meta(id: string): Promise<{ id: string; title: string; pondId: string; slug: string }> {
const page = await this.findLivePage(id);
return { id: page.id, title: page.title, pondId: page.pondId, slug: page.slug };
}
async softDelete(user: User, id: string): Promise<void> { async softDelete(user: User, id: string): Promise<void> {
const page = await this.findLivePage(id); const page = await this.findLivePage(id);
await this.prisma.page.update({ await this.prisma.page.update({

View File

@ -0,0 +1,58 @@
import { Controller, Get, Param, Req } from '@nestjs/common';
import type { OutlineEntry } from '@dorfteich/shared';
import type { PluginPageContent, PluginPageMeta, PluginPageSummary } from '@dorfteich/shared';
import { AuthedRequest } from '../auth/auth.guard';
import { RequiresPagePermission, RequiresPondRole } from '../permissions/permission.decorators';
import { PagesService } from './pages.service';
/**
* Viewer-scoped plugin data API (ADR 0008, issue #74). These endpoints back
* the plugin SDK's `readCurrentPage` and `readPond` capabilities: the host
* bridge calls them with the viewing user's session, and each reuses the same
* page/pond permission guards as the rest of the app a plugin can never read
* more than the person looking at it (no parallel permission logic). The
* `readBlock`/`blockData` capabilities land with the `plugin_block` node in
* issue #76, where block addressing exists.
*/
@Controller('plugin')
export class PluginApiController {
constructor(private readonly pages: PagesService) {}
/** `readPond.listPages` — the pages of a pond the viewer may read. */
@Get('ponds/:pondId/pages')
@RequiresPondRole('reader', { idParam: 'pondId' }) // the service filters per page
async listPages(
@Param('pondId') pondId: string,
@Req() request: AuthedRequest,
): Promise<PluginPageSummary[]> {
const pages = await this.pages.list(request.user!, pondId);
return pages.map((page) => ({ id: page.id, title: page.title, slug: page.slug }));
}
/** `readCurrentPage.getOutline` / `readPond.getPageOutline`. */
@Get('pages/:pageId/outline')
@RequiresPagePermission('read', { idParam: 'pageId' })
outline(@Param('pageId') pageId: string): Promise<OutlineEntry[]> {
return this.pages.outline(pageId);
}
/** `readCurrentPage.getContent` / `readPond.getPageContent` (Markdown). */
@Get('pages/:pageId/content')
@RequiresPagePermission('read', { idParam: 'pageId' })
async content(
@Param('pageId') pageId: string,
@Req() request: AuthedRequest,
): Promise<PluginPageContent> {
const { markdown } = await this.pages.exportMarkdown(request.user!, pageId);
return { markdown };
}
/** `readCurrentPage.getMeta`. */
@Get('pages/:pageId/meta')
@RequiresPagePermission('read', { idParam: 'pageId' })
meta(@Param('pageId') pageId: string): Promise<PluginPageMeta> {
return this.pages.meta(pageId);
}
}

View File

@ -0,0 +1,202 @@
import { INestApplication } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { PondsService } from '../ponds/ponds.service';
import { createTestApp, sessionCookieOf } from '../testing/test-app';
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service';
/**
* Viewer-scoped plugin API (issue #74). The `/plugin/*` endpoints reuse the
* same page/pond guards as the rest of the app, so a plugin sees exactly what
* its viewer could: a label-restricted reader gets a filtered `listPages` and
* a 404 on the page they cannot read; a non-member is refused outright.
*/
describe.skipIf(!hasTestDb)('plugin API (e2e, issue #74)', () => {
let app: INestApplication;
let prisma: PrismaClient;
const suffix = uniqueSuffix();
const password = 'plugins lesen nur was der nutzer darf 1';
const userIds: Record<string, string> = {};
const cookies: Record<string, string> = {};
let pondId: string;
let openPageId: string;
let secretPageId: string;
const api = () => request(app.getHttpServer());
async function makeUser(handle: string): Promise<void> {
const users = app.get(UsersService);
const username = `plugapi-${handle}-${suffix}`;
const user = await users.createUser({
username,
email: `${username}@example.org`,
displayName: `PlugApi ${handle}`,
password,
locale: 'en',
});
userIds[handle] = user.id;
await users.markEmailVerified(user.id);
const res = await api()
.post('/api/v1/auth/login')
.send({ usernameOrEmail: username, password })
.expect(200);
cookies[handle] = sessionCookieOf(res);
}
beforeAll(async () => {
prisma = createTestPrisma();
await prisma.rateLimit.deleteMany({});
app = await createTestApp();
for (const handle of ['owner', 'reader', 'foreign']) await makeUser(handle);
await app
.get(PondsService)
.ensurePersonalPond(await prisma.user.findUniqueOrThrow({ where: { id: userIds.owner! } }));
await prisma.quotaOverride.create({
data: {
subjectType: 'USER',
subjectId: userIds.owner!,
quotaKey: 'additional_ponds',
value: 10,
},
});
const pond = await api()
.post('/api/v1/ponds')
.set('Cookie', cookies.owner!)
.send({ name: `PlugApi Pond ${suffix}` })
.expect(201);
pondId = pond.body.id;
const open = await api()
.post(`/api/v1/ponds/${pondId}/pages`)
.set('Cookie', cookies.owner!)
.send({ title: 'Open Page' })
.expect(201);
openPageId = open.body.id;
const secret = await api()
.post(`/api/v1/ponds/${pondId}/pages`)
.set('Cookie', cookies.owner!)
.send({ title: 'Secret Page' })
.expect(201);
secretPageId = secret.body.id;
// The reader may see the pond, but a label-deny hides the secret page.
await api()
.post(`/api/v1/ponds/${pondId}/grants`)
.set('Cookie', cookies.owner!)
.send({
subjectType: 'user',
subjectId: userIds.reader!,
role: 'reader',
scopeType: 'pond',
effect: 'allow',
})
.expect(201);
const label = await api()
.post(`/api/v1/ponds/${pondId}/labels`)
.set('Cookie', cookies.owner!)
.send({ name: `secret-${suffix}` })
.expect(201);
await api()
.post(`/api/v1/pages/${secretPageId}/labels`)
.set('Cookie', cookies.owner!)
.send({ labelId: label.body.id })
.expect(201);
await api()
.post(`/api/v1/ponds/${pondId}/grants`)
.set('Cookie', cookies.owner!)
.send({
subjectType: 'user',
subjectId: userIds.reader!,
role: 'reader',
scopeType: 'label',
scopeId: label.body.id,
effect: 'deny',
})
.expect(201);
});
afterAll(async () => {
await prisma.roleGrant.deleteMany({ where: { pondId } });
await prisma.pageLabel.deleteMany({ where: { page: { pondId } } });
await prisma.label.deleteMany({ where: { pondId } });
await prisma.pageContentCache.deleteMany({ where: { page: { pondId } } });
await prisma.pageUpdate.deleteMany({ where: { page: { pondId } } });
await prisma.page.deleteMany({ where: { pondId } });
await prisma.pond.deleteMany({ where: { id: pondId } });
const ids = Object.values(userIds);
await prisma.roleGrant.deleteMany({ where: { pond: { ownerId: { in: ids } } } });
await prisma.pond.deleteMany({ where: { ownerId: { in: ids } } });
await prisma.quotaOverride.deleteMany({ where: { subjectId: { in: ids } } });
await prisma.user.deleteMany({ where: { id: { in: ids } } });
await prisma.$disconnect();
await app.close();
});
it('lists all pages for a full reader (the owner)', async () => {
const res = await api()
.get(`/api/v1/plugin/ponds/${pondId}/pages`)
.set('Cookie', cookies.owner!)
.expect(200);
expect(res.body.map((p: { id: string }) => p.id).sort()).toEqual(
[openPageId, secretPageId].sort(),
);
expect(res.body[0]).toMatchObject({ title: expect.any(String), slug: expect.any(String) });
});
it('filters listPages for a label-restricted reader and 404s the hidden page', async () => {
const list = await api()
.get(`/api/v1/plugin/ponds/${pondId}/pages`)
.set('Cookie', cookies.reader!)
.expect(200);
const ids = list.body.map((p: { id: string }) => p.id);
expect(ids).toContain(openPageId);
expect(ids).not.toContain(secretPageId);
// The readable page's content/outline/meta are served…
await api()
.get(`/api/v1/plugin/pages/${openPageId}/content`)
.set('Cookie', cookies.reader!)
.expect(200);
await api()
.get(`/api/v1/plugin/pages/${openPageId}/outline`)
.set('Cookie', cookies.reader!)
.expect(200);
const meta = await api()
.get(`/api/v1/plugin/pages/${openPageId}/meta`)
.set('Cookie', cookies.reader!)
.expect(200);
expect(meta.body).toMatchObject({ id: openPageId, pondId });
// …but the denied page reads as nonexistent through the plugin API too.
await api()
.get(`/api/v1/plugin/pages/${secretPageId}/content`)
.set('Cookie', cookies.reader!)
.expect(404);
await api()
.get(`/api/v1/plugin/pages/${secretPageId}/outline`)
.set('Cookie', cookies.reader!)
.expect(404);
});
it('hides everything from a non-member (404) and rejects anonymous callers (401)', async () => {
// An authenticated non-member sees 404 (existence hidden) — the same read
// policy the rest of the app enforces, inherited via the shared guards.
await api()
.get(`/api/v1/plugin/ponds/${pondId}/pages`)
.set('Cookie', cookies.foreign!)
.expect(404);
await api()
.get(`/api/v1/plugin/pages/${openPageId}/content`)
.set('Cookie', cookies.foreign!)
.expect(404);
// The plugin API runs with the viewer's session; there is no anonymous
// viewer (public read is the separate #56 path), so auth rejects first.
await api().get(`/api/v1/plugin/pages/${openPageId}/meta`).expect(401);
});
});

View File

@ -3,12 +3,15 @@ import { useTranslation } from 'react-i18next';
import type { HostCapabilityHandlers } from '@dorfteich/plugin-sdk'; import type { HostCapabilityHandlers } from '@dorfteich/plugin-sdk';
import { type PluginHostContext } from './host-capabilities';
import { createPluginSandbox, type SandboxPluginRef, type SandboxState } from './sandbox-host'; import { createPluginSandbox, type SandboxPluginRef, type SandboxState } from './sandbox-host';
export interface PluginFrameProps { export interface PluginFrameProps {
plugin: SandboxPluginRef; plugin: SandboxPluginRef;
extensionPointId: string; extensionPointId: string;
/** Host capability implementations for this surface (grows with #74). */ /** The page/pond the surface views + UI actions (#74 read + ui capabilities). */
context?: PluginHostContext;
/** Surface-specific host capability overrides (e.g. #76 blockData). */
capabilities?: HostCapabilityHandlers; capabilities?: HostCapabilityHandlers;
/** Block data for block surfaces (#76). */ /** Block data for block surfaces (#76). */
data?: unknown; data?: unknown;
@ -23,6 +26,7 @@ export interface PluginFrameProps {
export function PluginFrame({ export function PluginFrame({
plugin, plugin,
extensionPointId, extensionPointId,
context,
capabilities, capabilities,
data, data,
}: PluginFrameProps): React.JSX.Element { }: PluginFrameProps): React.JSX.Element {
@ -39,14 +43,15 @@ export function PluginFrame({
extensionPointId, extensionPointId,
locale: i18n.language, locale: i18n.language,
container, container,
context,
capabilities, capabilities,
data, data,
onStateChange: setState, onStateChange: setState,
}); });
return () => sandbox.destroy(); return () => sandbox.destroy();
// `capabilities`/`data` identity is owned by the parent surface; remounting // `context`/`capabilities`/`data` identity is owned by the parent surface;
// on plugin/extension change is what resets the sandbox. // remounting on plugin/extension change is what resets the sandbox.
}, [plugin.id, plugin.version, extensionPointId, i18n.language, capabilities, data]); }, [plugin.id, plugin.version, extensionPointId, i18n.language, context, capabilities, data]);
return ( return (
<div className="plugin-frame-host" data-plugin-id={plugin.id} data-state={state}> <div className="plugin-frame-host" data-plugin-id={plugin.id} data-state={state}>

View File

@ -0,0 +1,54 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { apiGet } from '../lib/api';
import { buildHostCapabilities } from './host-capabilities';
vi.mock('../lib/api', () => ({ apiGet: vi.fn() }));
const mockedGet = vi.mocked(apiGet);
describe('buildHostCapabilities', () => {
beforeEach(() => mockedGet.mockReset());
it('maps the read capabilities to the viewer-scoped plugin endpoints', async () => {
mockedGet.mockResolvedValueOnce([{ id: 'h1', level: 1, text: 'Hi' }]);
mockedGet.mockResolvedValueOnce({ markdown: '# Hi' });
mockedGet.mockResolvedValueOnce([{ id: 'p2', title: 'Two', slug: 'two' }]);
const caps = buildHostCapabilities({ pageId: 'p1', pondId: 'pond1' });
await caps.getOutline!(undefined);
expect(mockedGet).toHaveBeenLastCalledWith('/plugin/pages/p1/outline');
await expect(caps.getContent!(undefined)).resolves.toBe('# Hi');
expect(mockedGet).toHaveBeenLastCalledWith('/plugin/pages/p1/content');
await caps.listPages!(undefined);
expect(mockedGet).toHaveBeenLastCalledWith('/plugin/ponds/pond1/pages');
});
it('encodes the page id argument for getPageContent/getPageOutline', async () => {
mockedGet.mockResolvedValue([]);
const caps = buildHostCapabilities({ pageId: 'p1', pondId: 'pond1' });
await caps.getPageOutline!('weird/id');
expect(mockedGet).toHaveBeenLastCalledWith('/plugin/pages/weird%2Fid/outline');
});
it('rejects read calls that need context the surface does not have', async () => {
const caps = buildHostCapabilities({}); // no page, no pond
await expect(caps.getOutline!(undefined)).rejects.toThrow(/current page/);
await expect(caps.listPages!(undefined)).rejects.toThrow(/pond/);
expect(mockedGet).not.toHaveBeenCalled();
});
it('routes ui.openPage / ui.toast to the host callbacks', () => {
const openPage = vi.fn();
const toast = vi.fn();
const caps = buildHostCapabilities({ pageId: 'p1', openPage, toast });
caps.openPage!('target-page');
caps.toast!('saved');
expect(openPage).toHaveBeenCalledWith('target-page');
expect(toast).toHaveBeenCalledWith('saved');
});
});

View File

@ -0,0 +1,74 @@
import type { HostCapabilityHandlers } from '@dorfteich/plugin-sdk';
import type {
OutlineEntry,
PluginPageContent,
PluginPageMeta,
PluginPageSummary,
} from '@dorfteich/shared';
import { apiGet } from '../lib/api';
/**
* The surface a plugin runs against (issue #74): which page/pond it is viewing
* and how the host performs UI actions on its behalf. The host, not the plugin,
* holds the current ids a plugin can only ask about "the current page" or
* pages of "this pond", never arbitrary ones.
*/
export interface PluginHostContext {
pageId?: string;
pondId?: string;
/** Navigate to a page (the `ui.openPage` capability). */
openPage?: (pageId: string) => void;
/** Show a localized toast (the `ui.toast` capability). */
toast?: (messageKey: string) => void;
}
/**
* Builds the host implementations of the plugin API methods for one surface.
* Every data method calls the viewer-scoped `/plugin/*` endpoints (#74), so
* permission enforcement stays server-side. Only methods whose capability the
* plugin declared are ever reached the SDK host bridge gates them.
*/
export function buildHostCapabilities(context: PluginHostContext): HostCapabilityHandlers {
const requireCurrentPage = (): string => {
if (!context.pageId) throw new Error('no current page for this plugin surface');
return context.pageId;
};
const requirePond = (): string => {
if (!context.pondId) throw new Error('no pond for this plugin surface');
return context.pondId;
};
return {
// readCurrentPage — bound to the host-held current page id. Async so a
// missing-context error surfaces as a rejection, not a synchronous throw.
getOutline: async () => apiGet<OutlineEntry[]>(`/plugin/pages/${requireCurrentPage()}/outline`),
getContent: async () =>
(await apiGet<PluginPageContent>(`/plugin/pages/${requireCurrentPage()}/content`)).markdown,
getMeta: async () => apiGet<PluginPageMeta>(`/plugin/pages/${requireCurrentPage()}/meta`),
// readPond — scoped to the surface's pond; page ids are validated by the
// server's read guard, so an out-of-pond id simply 404s for the viewer.
listPages: async () => apiGet<PluginPageSummary[]>(`/plugin/ponds/${requirePond()}/pages`),
getPageOutline: async (params) =>
apiGet<OutlineEntry[]>(`/plugin/pages/${asPageId(params)}/outline`),
getPageContent: async (params) =>
(await apiGet<PluginPageContent>(`/plugin/pages/${asPageId(params)}/content`)).markdown,
// ui — host-side effects; resize is added by the sandbox itself.
openPage: (params) => {
context.openPage?.(asPageId(params));
},
toast: (params) => {
if (typeof params === 'string') context.toast?.(params);
},
};
}
/** A single-string argument arrives as `params` (SDK `toParams`). */
function asPageId(params: unknown): string {
if (typeof params !== 'string' || params.length === 0) {
throw new Error('a page id is required');
}
return encodeURIComponent(params);
}

View File

@ -18,6 +18,7 @@ import {
} from '@dorfteich/plugin-sdk'; } from '@dorfteich/plugin-sdk';
import { frameTransport } from './frame-transport'; import { frameTransport } from './frame-transport';
import { buildHostCapabilities, type PluginHostContext } from './host-capabilities';
/** Deadline for the frame to load and answer its first `render` call. */ /** Deadline for the frame to load and answer its first `render` call. */
export const RENDER_TIMEOUT_MS = 5000; export const RENDER_TIMEOUT_MS = 5000;
@ -45,9 +46,13 @@ export interface CreateSandboxOptions {
locale: string; locale: string;
/** Element the iframe is appended to. */ /** Element the iframe is appended to. */
container: HTMLElement; container: HTMLElement;
/** Host implementations of capability methods (grows with #74). The `ui` /** Extra/override host capability handlers. The read capabilities and `ui`
* method `resize` has a built-in default; pass your own to override. */ * are derived from `context`; anything here is merged on top (e.g. #76's
* blockData). The built-in `ui.resize` is always applied. */
capabilities?: HostCapabilityHandlers; capabilities?: HostCapabilityHandlers;
/** The page/pond the surface views + UI actions, backing the read and `ui`
* capabilities (#74). */
context?: PluginHostContext;
/** Block data passed to `render`/`edit` (block surfaces, #76). */ /** Block data passed to `render`/`edit` (block surfaces, #76). */
data?: unknown; data?: unknown;
renderTimeoutMs?: number; renderTimeoutMs?: number;
@ -86,6 +91,8 @@ export function createPluginSandbox(options: CreateSandboxOptions): PluginSandbo
}; };
const capabilities: HostCapabilityHandlers = { const capabilities: HostCapabilityHandlers = {
// Viewer-scoped read + ui handlers derived from the surface context (#74).
...(options.context ? buildHostCapabilities(options.context) : {}),
// Built-in `ui.resize`: plugins size their own frame, clamped so a hostile // Built-in `ui.resize`: plugins size their own frame, clamped so a hostile
// plugin cannot blow up the layout. Still gated: only manifests declaring // plugin cannot blow up the layout. Still gated: only manifests declaring
// the `ui` capability ever reach this handler. // the `ui` capability ever reach this handler.
@ -95,6 +102,7 @@ export function createPluginSandbox(options: CreateSandboxOptions): PluginSandbo
const clamped = Math.min(MAX_FRAME_HEIGHT_PX, Math.max(MIN_FRAME_HEIGHT_PX, height)); const clamped = Math.min(MAX_FRAME_HEIGHT_PX, Math.max(MIN_FRAME_HEIGHT_PX, height));
iframe.style.height = `${clamped}px`; iframe.style.height = `${clamped}px`;
}, },
// Surface-specific overrides (e.g. #76 blockData) win over the defaults.
...options.capabilities, ...options.capabilities,
}; };

View File

@ -83,3 +83,26 @@ export const pondPluginToggleInputSchema = z.object({
enabled: z.boolean(), enabled: z.boolean(),
}); });
export type PondPluginToggleInput = z.infer<typeof pondPluginToggleInputSchema>; export type PondPluginToggleInput = z.infer<typeof pondPluginToggleInputSchema>;
/**
* Responses of the viewer-scoped plugin API (issue #74, `/api/v1/plugin/…`).
* Every call runs with the requesting user's session behind the standard
* permission guards, so a plugin never sees more than its viewer could. The
* heading outline reuses the editor's `OutlineEntry` (see editor-schema).
*/
export interface PluginPageSummary {
id: string;
title: string;
slug: string;
}
export interface PluginPageMeta {
id: string;
title: string;
pondId: string;
slug: string;
}
export interface PluginPageContent {
markdown: string;
}