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 { PagesService } from './pages.service';
|
||||
import { PluginApiController } from './plugin-api.controller';
|
||||
|
||||
@Module({
|
||||
imports: [PondsModule, SearchModule],
|
||||
controllers: [PagesController],
|
||||
controllers: [PagesController, PluginApiController],
|
||||
providers: [PagesService],
|
||||
exports: [PagesService],
|
||||
})
|
||||
|
||||
@ -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<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> {
|
||||
const page = await this.findLivePage(id);
|
||||
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 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 (
|
||||
<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';
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
|
||||
@ -83,3 +83,26 @@ export const pondPluginToggleInputSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
});
|
||||
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