dorfteich/packages/plugin-sdk/src/manifest.ts
Claude Opus 4.8 ec6ca80c4d
All checks were successful
CD / Build and push images (push) Successful in 3m14s
CI / Lint, typecheck, test (push) Successful in 3m18s
CI / Auth e2e pack (push) Successful in 4m3s
CI / Import/export fidelity gate (push) Successful in 54s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m9s
CD / Promote to Int (push) Has been skipped
Add plugin SDK: manifest schema, capabilities, and RPC protocol (#70)
The SDK is the contract every other M7 story builds on (ADR 0008,
plugin-architecture.md). New package `@dorfteich/plugin-sdk`, standalone
(only depends on zod) so a plugin author needs nothing else.

- Zod manifest schema (`validateManifest`/`parseManifest`) with actionable
  `{ path, message }` issues and cross-field rules (extension-point/kind
  match, unique ids, section_style declares no permissions). Fixtures:
  3 valid + 14 invalid variants, asserted individually.
- `checkApiVersion` compatibility helper against the host's supported range.
- Capability names + method→capability map as the single source of truth
  for the permission gate.
- Transport-agnostic postMessage RPC engine (`createRpcEndpoint`) with
  request/response ids, per-request timeouts, unknown-method and
  endpoint-disposed handling, plus a `windowTransport` adapter.
- Host side (`createHostBridge`): routes plugin capability calls through
  the manifest permission gate; drives plugin lifecycle (render/edit/destroy).
- Plugin side (`createPlugin`): answers lifecycle calls, exposes a typed
  `host` proxy. RPC roundtrip verified in a jsdom MessageChannel test
  (roundtrip, args, timeout, unknown method, undeclared capability, dispose).
- README documents the protocol with a mermaid sequence diagram.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-10 16:12:00 +02:00

175 lines
6.2 KiB
TypeScript

import { z } from 'zod';
import { CAPABILITIES } from './capabilities';
/**
* Plugin manifest schema (ADR 0008, plugin-architecture.md §"manifest.json",
* which is normative). A `manifest.json` is validated at install time; invalid
* packages are rejected with the precise messages Zod produces here.
*/
/** Plugin classes by trust needs (ADR 0008). */
export const PLUGIN_KINDS = ['code', 'section_style'] as const;
export type PluginKind = (typeof PLUGIN_KINDS)[number];
/** Extension point types a manifest may declare. */
export const EXTENSION_POINT_TYPES = ['sectionStyle', 'block', 'pageTool'] as const;
export type ExtensionPointType = (typeof EXTENSION_POINT_TYPES)[number];
/** Extension point types that belong to each plugin kind. A `section_style`
* plugin exposes only `sectionStyle` points; a `code` plugin only `block` /
* `pageTool`. */
export const EXTENSION_POINTS_BY_KIND = {
section_style: ['sectionStyle'],
code: ['block', 'pageTool'],
} as const satisfies Record<PluginKind, readonly ExtensionPointType[]>;
/** A plugin `id`: a stable slug used in URLs, CSS scopes, and node attributes.
* Lowercase to keep CSS class scoping (`.dt-style-<id>-<styleId>`) predictable. */
const pluginIdSchema = z
.string()
.min(1)
.max(64)
.regex(
/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/,
'must be a lowercase slug (letters, digits, single hyphens), e.g. "page-index"',
);
/** Semantic version, e.g. `1.2.0`. Kept strict so update comparisons (#71) are
* well-defined. */
const semverSchema = z.string().regex(/^\d+\.\d+\.\d+$/, 'must be a semantic version like "1.2.0"');
/** The host checks this against its supported range at install time
* (see api-version.ts). A single major, e.g. `"1"`. */
const apiVersionSchema = z
.string()
.regex(/^\d+$/, 'must be a whole-number major version string like "1"');
/** A human-readable label available in at least German and English (ADR 0012).
* Extra locales are allowed. */
const localizedTextSchema = z
.object({
de: z.string().min(1),
en: z.string().min(1),
})
.catchall(z.string().min(1));
const extensionPointSchema = z.object({
type: z.enum(EXTENSION_POINT_TYPES),
/** Unique within the manifest; used as the surface id (e.g. the `pageTool`
* key, or the section-style key in the CSS scope). */
id: pluginIdSchema,
title: localizedTextSchema,
});
export type ExtensionPointManifest = z.infer<typeof extensionPointSchema>;
/** Static representation rendered in Word/PDF exports and when a plugin is
* disabled but its blocks still exist in documents (plugin-architecture.md). */
const fallbackSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('text'), value: z.string().min(1) }),
z.object({
type: z.literal('image'),
/** Path to a bundled asset, relative to the package root. */
value: z.string().min(1),
}),
]);
export type ManifestFallback = z.infer<typeof fallbackSchema>;
/** Optional references to bundled UI-string files, keyed by locale. */
const i18nRefsSchema = z.record(
z.string().regex(/^[a-z]{2}(-[A-Z]{2})?$/, 'must be a locale code like "de" or "en-GB"'),
z.string().min(1),
);
export const manifestSchema = z
.object({
id: pluginIdSchema,
name: z.string().min(1).max(120),
version: semverSchema,
apiVersion: apiVersionSchema,
kind: z.enum(PLUGIN_KINDS),
extensionPoints: z.array(extensionPointSchema).min(1),
permissions: z.array(z.enum(CAPABILITIES)).default([]),
fallback: fallbackSchema.optional(),
/** SPDX identifier or free-form license string. */
license: z.string().min(1),
homepage: z.string().url().optional(),
i18n: i18nRefsSchema.optional(),
})
.strict()
.superRefine((manifest, ctx) => {
const allowed: readonly string[] = EXTENSION_POINTS_BY_KIND[manifest.kind];
// Extension point types must match the plugin kind, and ids must be unique.
const seen = new Set<string>();
manifest.extensionPoints.forEach((point, index) => {
if (!allowed.includes(point.type)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['extensionPoints', index, 'type'],
message: `kind "${manifest.kind}" allows only ${allowed
.map((t) => `"${t}"`)
.join(', ')} extension points, not "${point.type}"`,
});
}
if (seen.has(point.id)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['extensionPoints', index, 'id'],
message: `duplicate extension point id "${point.id}"`,
});
}
seen.add(point.id);
});
// Declarative section-style plugins run no JavaScript, so they cannot use
// capabilities — those only exist for the sandboxed code path.
if (manifest.kind === 'section_style' && manifest.permissions.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['permissions'],
message: 'section_style plugins run no code and must not declare permissions',
});
}
});
export type PluginManifest = z.infer<typeof manifestSchema>;
/** A single actionable validation problem: a dotted path and a message. */
export interface ManifestIssue {
path: string;
message: string;
}
export interface ManifestValidationResult {
success: boolean;
manifest?: PluginManifest;
issues: ManifestIssue[];
}
function toIssues(error: z.ZodError): ManifestIssue[] {
return error.issues.map((issue) => ({
path: issue.path.length > 0 ? issue.path.join('.') : '(root)',
message: issue.message,
}));
}
/**
* Validates an unknown value against the manifest schema, returning a flat list
* of actionable issues instead of throwing — suited to the install path where
* every problem is reported to the Site Admin.
*/
export function validateManifest(input: unknown): ManifestValidationResult {
const result = manifestSchema.safeParse(input);
if (result.success) {
return { success: true, manifest: result.data, issues: [] };
}
return { success: false, issues: toIssues(result.error) };
}
/** Parses a manifest, throwing a `ZodError` on failure. Convenience for call
* sites that treat an invalid manifest as an exceptional condition. */
export function parseManifest(input: unknown): PluginManifest {
return manifestSchema.parse(input);
}