diff --git a/apps/api/src/pages/pages.module.ts b/apps/api/src/pages/pages.module.ts index a954701..371d8bb 100644 --- a/apps/api/src/pages/pages.module.ts +++ b/apps/api/src/pages/pages.module.ts @@ -5,10 +5,11 @@ import { SearchModule } from '../search/search.module'; import { PagesController } from './pages.controller'; import { PagesService } from './pages.service'; +import { PluginApiController } from './plugin-api.controller'; @Module({ imports: [PondsModule, SearchModule], - controllers: [PagesController], + controllers: [PagesController, PluginApiController], providers: [PagesService], exports: [PagesService], }) diff --git a/apps/api/src/pages/pages.service.ts b/apps/api/src/pages/pages.service.ts index 4a0832f..1af8998 100644 --- a/apps/api/src/pages/pages.service.ts +++ b/apps/api/src/pages/pages.service.ts @@ -2,6 +2,7 @@ import { ConflictException, Injectable, NotFoundException } from '@nestjs/common import { CollabTokenResponse, CreatePageInput, + OutlineEntry, PageListItemView, PageStateView, PageView, @@ -352,6 +353,20 @@ export class PagesService { 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 { + 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 { const page = await this.findLivePage(id); await this.prisma.page.update({ diff --git a/apps/api/src/pages/plugin-api.controller.ts b/apps/api/src/pages/plugin-api.controller.ts new file mode 100644 index 0000000..908dc4e --- /dev/null +++ b/apps/api/src/pages/plugin-api.controller.ts @@ -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 { + 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 { + 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 { + 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 { + return this.pages.meta(pageId); + } +} diff --git a/apps/api/src/pages/plugin-api.e2e.db.test.ts b/apps/api/src/pages/plugin-api.e2e.db.test.ts new file mode 100644 index 0000000..25d90fb --- /dev/null +++ b/apps/api/src/pages/plugin-api.e2e.db.test.ts @@ -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 = {}; + const cookies: Record = {}; + let pondId: string; + let openPageId: string; + let secretPageId: string; + + const api = () => request(app.getHttpServer()); + + async function makeUser(handle: string): Promise { + 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); + }); +}); diff --git a/apps/web/src/plugins/PluginFrame.tsx b/apps/web/src/plugins/PluginFrame.tsx index 99015e1..5fc6dda 100644 --- a/apps/web/src/plugins/PluginFrame.tsx +++ b/apps/web/src/plugins/PluginFrame.tsx @@ -3,12 +3,15 @@ import { useTranslation } from 'react-i18next'; import type { HostCapabilityHandlers } from '@dorfteich/plugin-sdk'; +import { type PluginHostContext } from './host-capabilities'; import { createPluginSandbox, type SandboxPluginRef, type SandboxState } from './sandbox-host'; export interface PluginFrameProps { plugin: SandboxPluginRef; 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; /** Block data for block surfaces (#76). */ data?: unknown; @@ -23,6 +26,7 @@ export interface PluginFrameProps { export function PluginFrame({ plugin, extensionPointId, + context, capabilities, data, }: PluginFrameProps): React.JSX.Element { @@ -39,14 +43,15 @@ export function PluginFrame({ extensionPointId, locale: i18n.language, container, + context, capabilities, data, onStateChange: setState, }); return () => sandbox.destroy(); - // `capabilities`/`data` identity is owned by the parent surface; remounting - // on plugin/extension change is what resets the sandbox. - }, [plugin.id, plugin.version, extensionPointId, i18n.language, capabilities, data]); + // `context`/`capabilities`/`data` identity is owned by the parent surface; + // remounting on plugin/extension change is what resets the sandbox. + }, [plugin.id, plugin.version, extensionPointId, i18n.language, context, capabilities, data]); return (
diff --git a/apps/web/src/plugins/host-capabilities.test.ts b/apps/web/src/plugins/host-capabilities.test.ts new file mode 100644 index 0000000..f37dd52 --- /dev/null +++ b/apps/web/src/plugins/host-capabilities.test.ts @@ -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'); + }); +}); diff --git a/apps/web/src/plugins/host-capabilities.ts b/apps/web/src/plugins/host-capabilities.ts new file mode 100644 index 0000000..c6158b9 --- /dev/null +++ b/apps/web/src/plugins/host-capabilities.ts @@ -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(`/plugin/pages/${requireCurrentPage()}/outline`), + getContent: async () => + (await apiGet(`/plugin/pages/${requireCurrentPage()}/content`)).markdown, + getMeta: async () => apiGet(`/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(`/plugin/ponds/${requirePond()}/pages`), + getPageOutline: async (params) => + apiGet(`/plugin/pages/${asPageId(params)}/outline`), + getPageContent: async (params) => + (await apiGet(`/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); +} diff --git a/apps/web/src/plugins/sandbox-host.ts b/apps/web/src/plugins/sandbox-host.ts index dd8539c..dc9d878 100644 --- a/apps/web/src/plugins/sandbox-host.ts +++ b/apps/web/src/plugins/sandbox-host.ts @@ -18,6 +18,7 @@ import { } from '@dorfteich/plugin-sdk'; import { frameTransport } from './frame-transport'; +import { buildHostCapabilities, type PluginHostContext } from './host-capabilities'; /** Deadline for the frame to load and answer its first `render` call. */ export const RENDER_TIMEOUT_MS = 5000; @@ -45,9 +46,13 @@ export interface CreateSandboxOptions { locale: string; /** Element the iframe is appended to. */ container: HTMLElement; - /** Host implementations of capability methods (grows with #74). The `ui` - * method `resize` has a built-in default; pass your own to override. */ + /** Extra/override host capability handlers. The read capabilities and `ui` + * 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; + /** 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). */ data?: unknown; renderTimeoutMs?: number; @@ -86,6 +91,8 @@ export function createPluginSandbox(options: CreateSandboxOptions): PluginSandbo }; 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 // plugin cannot blow up the layout. Still gated: only manifests declaring // 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)); iframe.style.height = `${clamped}px`; }, + // Surface-specific overrides (e.g. #76 blockData) win over the defaults. ...options.capabilities, }; diff --git a/packages/shared/src/plugins.ts b/packages/shared/src/plugins.ts index eaaa307..39a90bd 100644 --- a/packages/shared/src/plugins.ts +++ b/packages/shared/src/plugins.ts @@ -83,3 +83,26 @@ export const pondPluginToggleInputSchema = z.object({ enabled: z.boolean(), }); export type PondPluginToggleInput = z.infer; + +/** + * 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; +}