> 日本語版: [08-plugin-architecture.ja.md](./08-plugin-architecture.ja.md) > ## 8. Plugin Architecture > **Writing a plugin?** This page is the design spec. The hands-on > walkthrough lives in [`packages/ampless/docs/plugin-author-guide.md`](../../packages/ampless/docs/plugin-author-guide.md) > — the same file ships inside the `ampless` npm tarball and is > copied into every scaffolded project at `docs/plugin-author-guide.md`. ### Trust model (v1 scope) ampless is a customization-based CMS for engineers ([Positioning](./14-roadmap.md#positioning-2026-06-07)). Plugins are npm dependencies that the site engineer imports + configures directly (Astro integration / Next.js plugin pattern); the engineer audits each dep before installing. The trust framework described in this section (the `trust_level` union, the IAM-scoped trusted / untrusted Lambdas, the capability declaration list, the `secretSettings` `trusted`-only check) is implemented in v1 as **first-party plugin organization**. It decides: - Which trust tier's Lambda each event hook runs in (event-dispatcher → SQS-trusted or SQS-untrusted) - Which IAM permissions each tier's Lambda holds (trusted reads posts + writes own S3 path; untrusted has none) - Which features the runtime hard-gates by trust tier (e.g. `settings.secret`-declaring plugins throw at `definePlugin()` time unless `trust_level: 'trusted'`, because secret read requires the trusted Lambda's IAM permission to the `PluginSecret` table) - Which capability declarations the runtime / admin uses for warnings, UI labels, and future allow-lists (capability mismatch is a soft warning today; admin UI labels read from capabilities; future `cms.config.ts` allow-lists may act on capabilities) - Which output paths the runtime defensively sanitizes (e.g. `publicHtmlForPost` strict allowlist for visible HTML — same sanitize across trust tiers, defense-in-depth not trust-tier gating) It is **not** designed as a marketplace-grade automatic sandbox that safely runs arbitrary third-party untrusted plugins the engineer has not audited. Plugins that the engineer adds to `cms.config.ts` are trusted because the engineer chose to install them, not because the framework guarantees their safety. A marketplace + runtime sandbox is a v2.0+ exploration item, not a v1.0 deliverable (see [roadmap §v2.0+ exploration](./14-roadmap.md#v20-exploration--not-committed)). The **admin preview iframe** is also inside this trust boundary (v1 explicit design decision). The preview iframe runs with `sandbox="allow-scripts allow-same-origin"`, which gives preview scripts the same origin as the admin — necessary for 3rd-party embed widgets, and consistent with treating preview content / plugin scripts as trusted (same rationale: engineer-audited plugins, editor-authored body). See §Server-render fetch admin preview below for the full security trade-off and the v2.0+ alternative. ### Design Philosophy ampless plugins run inside the same Lambda that processes events for their `trust_level` — the sandbox is **the Lambda's IAM execution role**, not a V8 isolate or `vm.Script` wrapper. There is no in-process JS sandbox: untrusted code runs in a Lambda whose IAM role has been pruned to nothing, and trusted code runs in a Lambda whose IAM role lists exactly what trusted plugins are allowed to touch. This IAM segregation is sized for first-party / engineer-audited plugins (forward-compat for an eventual marketplace, see §Trust model (v1 scope)). It does not by itself prevent a maliciously written first-party `'untrusted'` plugin from misbehaving in the ways its empty IAM role permits (e.g., consuming CPU, throwing in hooks); the engineer's audit is the last line of defense, not the framework. This trades the fine-grained capability surface of a V8-isolate sandbox for AWS-native isolation: simpler to reason about, no native-binary dependency, no `--no-node-snapshot` flag, no custom container image. ### Plugin Contract Plugins are plain TypeScript modules that export the result of `definePlugin()` ([`packages/ampless/src/plugin.ts`](../../packages/ampless/src/plugin.ts)). The target shape: ```typescript export interface AmplessPlugin { name: string apiVersion: 1 trust_level: 'untrusted' | 'trusted' | 'privileged' // Per-install namespace. Defaults to `name`. Distinguishes multiple // instances of the same plugin (e.g. two GTM containers). instanceId?: string // Human-readable label for admin UI. displayName?: LocalizedString // Declared capability list. Runtime warns on declaration-vs-implementation // mismatch. `cms.config.ts` `allowCapabilities` is a reserved future // allow-list surface for v2.0+ marketplace exploration (admin pages / // server routes / secrets / etc.); not enforced at runtime today. // See the `allowCapabilities` section later in this document. capabilities?: readonly PluginCapability[] // Event hooks — run in the trust_level-matched Lambda from SQS. // Return value is reserved: `Promise`. The // runtime ignores it today (plugins returning `Promise` keep // working); `PluginHookResult` is a forward-compat reservation. hooks?: { [K in EventType]?: (event, ctx) => Promise } // Per-post and site-level metadata — pure functions, called at request time. metadata?(post: Post, site): PluginMetadata siteMetadata?(site): PluginMetadata // Declarative head/body injection — descriptor arrays, not ReactNode. // Validated and rendered by the runtime at request time, in the public // Next.js process. Phase 1 (implemented; contract documented below). publicHead?(ctx): readonly PublicHeadDescriptor[] publicBodyEnd?(ctx): readonly PublicBodyDescriptor[] // Per-post body injection — descriptor arrays rendered by the theme's post // page template. Only `inlineScript` with `scriptType: 'application/ld+json'` // is accepted; other scriptType values are dropped with a warning. // Phase 4 (implemented). Capability: `schema`. publicBodyForPost?(post: Post, ctx): readonly PublicPostBodyDescriptor[] // Per-post visible HTML — descriptor arrays sanitized and rendered by the // theme's post page template at the beforeContent / afterContent slots. // Phase 6d (implemented). Capability: `publicHtmlForPost`. publicHtmlForPost?(post: Post, ctx): readonly PublicPostHtmlDescriptor[] // Dynamic OG image — rendered at request time via Next.js ImageResponse. ogImage?: OgImageConfig } ``` `capabilities` / `instanceId` / `displayName` / `publicHead` / `publicBodyEnd` are the **Phase 1 extension** to the contract. `publicBodyForPost` is the **Phase 4 extension** — per-post body injection, primarily for JSON-LD structured data. `publicHtmlForPost` is the **Phase 6d extension** — per-post visible HTML for things like reading-time badges, breadcrumbs, share links. Existing first-party plugins (`seo`, `rss`, `og-image`, `webhook`) continue to work without declaring these fields. A plugin combines any of these surfaces. Activation is a single line in the project's `cms.config.ts`: ```typescript plugins: [ seoPlugin({ /* ... */ }), rssPlugin({ /* ... */ }), ] ``` ### Capability Model `capabilities` lists what the plugin wants to do. The runtime and admin use the list for: capability/feature-mismatch warnings, admin UI labels, and as a future allow-list surface (the runtime does not hard-gate features on `capabilities` declarations today, with limited exceptions — most notably `settings.secret` declared with `trust_level !== 'trusted'` throws at `definePlugin()` time, see the [`secretSettings`](#capability-model) row in the capability table below). This declaration surface is sized for first-party / engineer-audited plugins (see [Trust model (v1 scope)](#trust-model-v1-scope) above). Active capabilities (implemented): | capability | meaning | default-allowed trust_level | |---|---|---| | `publicHead` | `` descriptor injection (Phase 1, implemented) | `untrusted` and up | | `publicBody` | ``-end descriptor injection (Phase 1, implemented) | `untrusted` and up | | `metadata` | existing `metadata()` / `siteMetadata()` surfaces | `untrusted` and up | | `eventHooks` | existing async event hooks (`hooks`) | `untrusted` and up (e.g. trusted hooks are how `@ampless/plugin-webhook` delivers signed outbound HTTP today) | | `writePublicAsset` | trusted hook context writes a validated, namespaced public asset (Phase 3, implemented) | `trusted` and up | Phase 2 additions: | capability | meaning | default-allowed trust_level | |---|---|---| | `adminSettings` | declares one or more `settings.public` fields editable from `/admin/plugins` (Phase 2, implemented) | `untrusted` and up | Phase 4 additions: | capability | meaning | default-allowed trust_level | |---|---|---| | `schema` | `publicBodyForPost()` — per-post body injection, primarily JSON-LD structured data; rendered by the theme's post page template (Phase 4, implemented) | `untrusted` and up | Phase 6d additions: | capability | meaning | default-allowed trust_level | |---|---|---| | `publicHtmlForPost` | `publicHtmlForPost()` — per-post **visible HTML** at the `beforeContent` / `afterContent` slots of the theme's post page (Phase 6d, implemented). Body is sanitized by the runtime under a strict `sanitize-html` allowlist; same sanitize is applied to every trust level. | `untrusted` and up | Phase 6a additions: | capability | meaning | default-allowed trust_level | |---|---|---| | `secretSettings` | Declares one or more `settings.secret` fields — admin-editable values stored encrypted in the `PluginSecret` DDB table (IAM-only access; no Cognito group can read directly). Admin writes via `setPluginSecret` / `clearPluginSecret` AppSync mutations backed by the plugin-secret-handler Lambda, which encrypts with AES-256-GCM before writing. Trusted hooks read via `ctx.secret(key)`. There are four observable behaviours at `definePlugin()` time ([plugin.ts:1004-1019](../../packages/ampless/src/plugin.ts#L1004-L1019)): (1) **`settings.secret` non-empty + `trust_level !== 'trusted'`** → `definePlugin()` throws — secret read requires the trusted Lambda's IAM permission to the `PluginSecret` table; untrusted and privileged Lambdas have no IAM read access to that table. (2) **`settings.secret` non-empty + `capabilities` declared + `'secretSettings'` missing from `capabilities`** → soft mismatch warning — matches the existing capability-mismatch pattern for `'schema'` / `'publicHtmlForPost'`. (3) **`settings.secret` non-empty + `capabilities` undefined** (legacy plugin without a `capabilities` array) → no warning — the mismatch check is skipped when `capabilities` is `undefined`, for backward compatibility. (4) **`capabilities: ['secretSettings']` declared with no `settings.secret` field** → no-op — neither warning nor throw. | `trusted` only (hard gate at `definePlugin()` time when `settings.secret` is non-empty; see the four cases above). | Phase 7 additions: | capability | meaning | default-allowed trust_level | |---|---|---| | `contentFields` | `contentFields` array — plugin renderers for tiptap node types (`kind: 'tiptap'`) and single-line markdown URL patterns (`kind: 'markdown-url'`). Threaded into `ampless.renderBody(post)` which now returns `Promise` (pre-1.0 breaking; see migration notes). Duplicate `nodeType` / `pattern.source` registration across plugins throws eagerly at `createPluginHead` time. | `untrusted` and up | | `publicPostScript` | `publicPostScript(post, ctx)` — page-level `