dorfteich/packages/shared/src/plugins.ts
Claude Fable 5 9b7acab294
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 6m21s
CI / Build container images (pull_request) Successful in 3m59s
CI / Auth e2e pack (pull_request) Successful in 8m35s
CI / Import/export fidelity gate (pull_request) Successful in 1m1s
CD / Build and push images (push) Successful in 17s
CD / Deploy to Test (push) Successful in 14s
CD / Smoke tests against Test (push) Successful in 1m17s
CD / Promote to Int (push) Successful in 12s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 8m27s
CI / Import/export fidelity gate (push) Successful in 58s
CI / Lint, typecheck, test (push) Successful in 6m30s
#232: plugin allowlist with SHA-256 hash pinning
The install path records the SHA-256 of the delivered bundle ZIP
(plugins.bundle_hash; pre-#232 installs show it as unknown until
reinstalled). plugins.allowlist in instance_settings names permitted
ids with their pinned hashes: empty (default) = not enforced, existing
instances unchanged; non-empty = installs of unlisted or deviating
bundles are rejected (plugin_not_pinned / plugin_hash_mismatch, 403),
and an installed plugin outside the list or with a deviating hash does
not load — absent from pond mount lists, frame/assets 404. Every
rejection is audited (plugin.rejected, catalogue v1.5). A version bump
changes the hash and therefore requires an explicit re-pin — the
intended friction (ADR 0025). Admin UI shows observed vs pinned hash
per plugin with pin/re-pin/unpin. Scope stated honestly in
plugin-architecture.md: the pin answers "is this the reviewed bundle";
post-install disk tampering is platform integrity (ADR 0019), sandbox
containment stays the sandbox's job. Hardening guide row + catalog
advisory triage; residual risk R-03 resolved. e2e: empty-allowlist
compatibility, pinned load, unpinned and tampered installs rejected and
audited, pin drift blocks loading while the admin still sees the
mismatch, version bump needs re-pin. Full api suite 101 files / 561
green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8
2026-07-31 21:26:36 +02:00

133 lines
4.4 KiB
TypeScript

import { z } from 'zod';
/**
* Plugin administration types shared between api and web (ADR 0008, issue #71).
* The manifest itself lives in `@dorfteich/plugin-sdk`; these types describe an
* *installed* plugin as the instance stores and surfaces it.
*/
/**
* Instance-level activation a Site Admin sets per plugin (ADR 0008 lifecycle):
* `disabled` — installed but inert; `optional` — available, Pond Admins toggle
* it per pond; `required` — always on everywhere and cannot be uninstalled.
*/
export const PLUGIN_INSTANCE_MODES = ['disabled', 'optional', 'required'] as const;
export type PluginInstanceMode = (typeof PLUGIN_INSTANCE_MODES)[number];
/** One extension point as surfaced to admins (mirrors the manifest entry). */
export interface PluginExtensionPointView {
type: string;
id: string;
title: Record<string, string>;
}
/** An installed plugin as returned by the admin API. */
export interface PluginView {
id: string;
name: string;
version: string;
apiVersion: string;
kind: string;
mode: PluginInstanceMode;
/** Capabilities the manifest declared, shown to the Site Admin at install. */
permissions: string[];
extensionPoints: PluginExtensionPointView[];
/** Base path the sandbox loads assets from: `/plugins/<id>/<version>/`. */
assetBasePath: string;
license: string;
homepage?: string;
/** SHA-256 (hex) of the installed bundle ZIP (#232); null = installed
* before hash recording existed — reinstall to record it. */
bundleSha256: string | null;
/** Verdict against `plugins.allowlist` (#232): `not_enforced` while the
* allowlist is empty; otherwise pinned / unpinned / mismatch. */
pinning: 'not_enforced' | 'pinned' | 'unpinned' | 'mismatch';
installedAt: string;
updatedAt: string;
}
/**
* Machine-readable rejection codes for an install/uninstall attempt. Each is
* also an `errors.<code>` i18n key. `validateManifest` field issues travel in
* the ApiErrorBody `details`.
*/
export const PLUGIN_ERROR_CODES = [
'plugin_invalid_zip',
'plugin_bad_structure',
'plugin_invalid_manifest',
'plugin_api_incompatible',
'plugin_too_large',
'plugin_missing_bundle',
'plugin_missing_styles',
'plugin_css_unsafe',
'plugin_version_not_higher',
'plugin_required_cannot_uninstall',
'plugin_not_found',
'plugin_not_optional',
'plugin_not_pinned',
'plugin_hash_mismatch',
] as const;
export type PluginErrorCode = (typeof PLUGIN_ERROR_CODES)[number];
/**
* An optional plugin as shown in a pond's plugin settings (issue #72): the
* installed plugin plus whether this pond has activated it. Only `optional`
* plugins appear here — `required` ones are always on and `disabled` ones are
* never available, so neither is a per-pond choice.
*/
export interface PondPluginSetting {
plugin: PluginView;
enabled: boolean;
}
/** Payload to switch a plugin's instance mode (Site Admin, issue #72). */
export const pluginModeInputSchema = z.object({
mode: z.enum(PLUGIN_INSTANCE_MODES),
});
export type PluginModeInput = z.infer<typeof pluginModeInputSchema>;
/** Payload to toggle an optional plugin for one pond (Pond Admin, issue #72). */
export const pondPluginToggleInputSchema = z.object({
enabled: z.boolean(),
});
export type PondPluginToggleInput = z.infer<typeof pondPluginToggleInputSchema>;
/**
* What a `plugin_block` of an inactive plugin renders instead of its sandbox
* (issue #76): the manifest `fallback`, resolved server-side from the stored
* manifest snapshot — which survives uninstall as a tombstone, so blocks in
* documents always have something to show. An image fallback is resolved to
* its served URL while the files exist and degrades to `null` (neutral
* placeholder) once they are gone.
*/
export interface PluginFallbackView {
pluginId: string;
name: string;
fallback: { type: 'text'; value: string } | { type: 'image'; url: string } | null;
}
/**
* 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;
/** Label names, for pageTool filtering (issue #77). */
labels: string[];
}
export interface PluginPageMeta {
id: string;
title: string;
pondId: string;
slug: string;
}
export interface PluginPageContent {
markdown: string;
}