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
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:
parent
d1a7d37fc8
commit
48798d4247
@ -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],
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
58
apps/api/src/pages/plugin-api.controller.ts
Normal file
58
apps/api/src/pages/plugin-api.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
202
apps/api/src/pages/plugin-api.e2e.db.test.ts
Normal file
202
apps/api/src/pages/plugin-api.e2e.db.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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}>
|
||||||
|
|||||||
54
apps/web/src/plugins/host-capabilities.test.ts
Normal file
54
apps/web/src/plugins/host-capabilities.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
74
apps/web/src/plugins/host-capabilities.ts
Normal file
74
apps/web/src/plugins/host-capabilities.ts
Normal 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);
|
||||||
|
}
|
||||||
@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user