Skip to content

Plugins

Theodor Dimitriou edited this page Sep 24, 2026 · 2 revisions

How to extend AstroBaaS with plugins: the code and declarative tiers, hooks, API routes, admin screens, storage, content types, dependencies, external packages and payment providers. Written for developers building plugins and for operators who install them.

Two tiers, one registry

AstroBaaS has two kinds of plugin. Both share one registry, one activation model and one admin screen (Admin → Plugins).

Code plugin Declarative plugin
What it is A TypeScript/JavaScript module A JSON manifest
How it arrives Compiled into the build (src/plugins/), or loaded from an npm package named in ASTROBAAS_PLUGINS Pasted into the admin, installed from the curated registry, or posted to POST /api/plugins/install. No rebuild.
Runs logic Yes: filters, actions, routes, admin screens, migrations No. It is data.
Can add Anything <meta>/<link> tags, CSS, content types, webhooks, editor sections
Privileges Full application privileges, no sandbox None; nothing is executed
Best for Developers and AI agents Operators who cannot redeploy

Code plugins run in the same process as the application with full privileges. Install only code you trust. Declarative plugins can be installed from the browser precisely because they cannot execute anything.

No runtime code upload, no licence checks

There is no way to upload executable plugin code through the admin or the API. Code reaches a server in one of two ways, both chosen by whoever runs the server:

  • compiled into the build from src/plugins/, or
  • installed as a package on the server and named in the ASTROBAAS_PLUGINS environment variable.

This removes the arbitrary-code-upload surface that is the most exploited vector in classic CMS ecosystems, keeps type checking end to end for bundled code, and keeps a strict hash-based CSP possible.

AstroBaaS contains no licence checks. Nothing in the repository phones home, validates a key, or refuses to run. Paid vertical modules are sold separately and are not in this repository; they install through the same public plugin seams described on this page, like any other external plugin. See LICENSING.md.

Bundled plugins

Eight code plugins ship in src/plugins/. All are inactive until an operator activates them.

Id What it does Reference for
reading-time Prepends an estimated reading time to post content. A simple POST_CONTENT filter
draft-watermark Banners posts that are not published. A per-post conditional filter
product-catalog Registers a product custom content type. registerContentType()
print-styles Adds a print stylesheet so articles print without site chrome. PLUGIN_STYLES
smtp2go Sends mail through the SMTP2GO HTTP API. A connector that replaces a core service; activate/deactivate
consent-banner Granular prior-consent cookie control. CSS via PLUGIN_STYLES plus a core-served script
ai-assistant A chat bubble on public pages, backed by the operator's own AI provider (configured under Settings). Same shape as consent-banner
popups One opt-in overlay on the operator's terms. Same shape as consent-banner

The last three show a pattern worth copying. A plugin cannot ship executable script to the browser under the CSP, so each contributes only CSS, and the browser behaviour lives in a file core serves (/consent.js, /assistant.js, /popup.js). Deactivating the plugin stops the file being served.

Code plugin anatomy

A code plugin is a folder under src/plugins/<id>/ with an index.ts that default-exports definePlugin({...}). Import only from astrobaas/core, the one import surface covered by the stability policy (STABILITY.md); never import from src/lib/*.

import { definePlugin, PLUGIN_HOOKS } from 'astrobaas/core';
import type { Post } from 'astrobaas/core';

export default definePlugin({
  id: 'my-plugin',          // stable, unique, kebab-case
  name: 'My Plugin',
  version: '1.0.0',
  description: 'What it does.',
  author: 'You',
  filters: {
    [PLUGIN_HOOKS.POST_TITLE]: (title: string, post?: Post) => title,
  },
  actions: {
    [PLUGIN_HOOKS.AFTER_POST_SAVE]: (post: Post) => console.log('saved', post.id),
  },
  activate() { /* optional one-time setup */ },
  deactivate() { /* optional teardown */ },
});

Plugin fields:

Field Required Notes
id, name, version, description, author yes id is stable and kebab-case.
requiresCore no Semver range of the host plugin API. Checked for external plugins.
filters no { [hook]: (value, ...args) => newValue }
actions no { [hook]: (...args) => void }
routes no API endpoints; see API routes.
adminPages no Admin screens; see Admin screens.
migrations no Data migrations for the plugin's own store.
activate, deactivate no Lifecycle callbacks.

Register a bundled plugin in src/plugins/index.ts with one import and one array entry. Add to BUNDLED_PLUGINS; do not replace it, or you uninstall the other eight.

import myPlugin from './my-plugin';
export const BUNDLED_PLUGINS: Plugin[] = [
  readingTime, draftWatermark, productCatalog, printStyles, smtp2go,
  consentBanner, aiAssistant, popups,
  myPlugin,
];

Rebuild, and the plugin appears in Admin → Plugins. Activation state is stored in the plugins table and replayed on every boot.

Hooks

Hook names are constants in PLUGIN_HOOKS, exported from astrobaas/core. Always use the constants rather than raw strings.

  • Filters transform a value. They run in activation order, each receiving the previous output. A filter that throws is skipped (its input passes through) and the error is logged.
  • Actions are fire-and-forget side effects. A throwing action is logged and does not break the request.

Plugins share the process, so avoid blocking work and unbounded memory.

Content and site hooks

Constant Kind Signature Notes
API_POSTS_GET filter (posts: Post[]) => Post[] One page of GET /api/posts, already filtered, paginated and visibility-checked. Not a permission hook.
POST_CONTENT filter (html, post) => string Post body HTML. Re-sanitized after all filters, so it cannot inject script.
POST_TITLE filter (title, post) => string Rendered as text.
BEFORE_POST_SAVE filter (post, { isNew }) => post Just before create or update. Content is sanitized after this hook; author_id always comes from the session.
AFTER_POST_SAVE action (post) => void After create or update.
AFTER_POST_DELETE action (post) => void After delete.
ROBOTS_GROUPS filter (groups, { origin }) => groups Per-crawler groups for robots.txt. Add to what you receive; it holds the operator's choices.
CRAWLER_POLICY filter (verdict, { userAgent, path }) => verdict Called on public page requests. Return { block: true, reason } to answer 403. Errors are swallowed.
HEAD_TAGS filter (html, { pathname }) => string Extra <head> markup on public pages, sanitized to meta and link only.
PLUGIN_STYLES filter (css, { pathname }) => string CSS for public pages, served from /plugins.css.

Commerce hooks

Money values are integer cents. See Commerce for context.

Constant Kind Signature Notes
API_PRODUCTS_GET filter (products) => products Full list, before filtering and pagination.
SEARCH_EXPAND filter (expander, { products, query, settings }) => expander Extra spellings for a search term, consulted only when the literal term matched nothing. The initial value carries the operator's synonyms: compose with it, do not return null.
BEFORE_PRODUCT_SAVE filter (product, { isNew }) => product Description is sanitized after this hook.
AFTER_PRODUCT_SAVE action (product) => void
AFTER_PRODUCT_DELETE action (product) => void
PRODUCT_PRICE filter (priceCents, product, { qty, variant }) => number Final unit price. Must return integer cents or checkout fails with a 500.
BEFORE_ORDER_SAVE filter (order, { isNew }) => order Validation runs before; totals are re-checked after.
AFTER_ORDER_CREATE action (order) => void Checkout completed.
AFTER_ORDER_STATUS_CHANGE action (order, previousStatus) => void
ORDER_LINE_EXTRAS filter (result, { line, product, name }) => result Accept a line with { ok: true, patch } or refuse the order with { ok: false, message, code?, params? }. Pass an incoming refusal through unchanged.
COMMERCE_SCHEMA filter (schema, { name, variant? }) => schema Publish a schema for a storefront form. Return the input unchanged for names you do not own.
PAYMENT_PROVIDERS filter (providers) => providers Contribute payment gateways.
MANUAL_METHODS filter (methods) => methods Contribute offline payment methods.

The full contracts are in PLUGIN_DEVELOPMENT.md and src/lib/plugin-system.ts.

CSS and the CSP

Production pages use a hash-based CSP with no 'unsafe-inline'. Plugin markup is produced per request and has no build-time hash, so an inline <style> or <script> from a plugin is silently dropped by the browser. That is why:

  • CSS goes through PLUGIN_STYLES and is served same-origin from /plugins.css,
  • HEAD_TAGS accepts only meta and link,
  • admin screen scripts are served from /plugin-admin.js, never inline.

Content filters are also subject to the content sanitizer. Stored and filtered HTML may carry only allow-listed classes: the core section classes, and classes in the plugin namespace ab-x-<pluginId>-<name>. Use that namespace for any class your filter adds to post content.

API routes

A code plugin can serve its own endpoints. They are dispatched by the catch-all src/pages/api/[...pluginRoute].ts, collected from active plugins only, and replaced on reload, so deactivating a plugin stops its routes immediately.

routes: [
  {
    method: 'GET',                         // GET, POST, PUT, PATCH or DELETE
    path: '/api/plugin/shop/orders/:id',   // :params and one trailing * allowed
    access: 'staff',                       // 'staff' (default) | 'admin' | 'public'
    scope: 'orders:read',                  // optional API-key scope
    handler: async ({ params, store, settings }) => {
      const order = await store.get('orders', params.id);
      return Response.json({ success: true, data: order ?? null }, { status: order ? 200 : 404 });
    },
  },
],

The handler receives { request, url, params, user, locals, store, settings } and returns a Response. store and settings are already bound to your plugin. An uncaught error is logged with the plugin's name and answered 500 with no detail.

Access and safety rules

access Who can call it
staff (default) Signed-in admin, editor, author or manager, but not viewer; or an API key with the declared scope
admin Administrators only
public Anyone, unauthenticated. Writes are still CSRF-checked and rate-limited.
  • Core routes always win. Astro sorts static route files before the catch-all, so a plugin cannot shadow /api/orders or any other real route file.
  • Widening a gate needs the /api/plugin/ namespace. access: 'public', csrf: 'exempt' and scope are refused on paths outside /api/plugin/.
  • Reserved prefixes are refused at registration: /api/auth/, /api/2fa/, /api/keys, /api/users/, /api/backup/, /api/plugins, /api/settings/ and /api/webhooks.
  • The segment after /api must be literal. /api/:x/... is refused.
  • Two plugins claiming one route is refused, not resolved by load order.
  • csrf: 'exempt' is only for machine callers that authenticate another way, such as a signed provider webhook.
  • A route without scope is unreachable by scoped API keys. Declare one to let a headless frontend call it.

The recommended path shape is /api/plugin/<plugin-id>/..., which cannot collide with anything.

Admin screens

adminPages: [
  {
    path: 'orders',                    // served at /admin/plugin/shop/orders
    title: 'Orders',
    nav: { label: 'Orders', order: 10 },
    roles: ['admin', 'manager'],       // omit for admin only
    render: async ({ user, csrf, url }) => `<table>…</table>`,
    script: `document.querySelector('#refresh')?.addEventListener('click', () => location.reload());`,
  },
],
  • Screens live under /admin/plugin/<plugin-id>/ and are rendered by src/pages/admin/plugin/[...pluginPage].astro. They cannot take arbitrary admin paths, because admin authorisation is longest-prefix-wins and a plugin path could otherwise decide who may open core screens.
  • Omitting roles means admin only.
  • render() output is inserted verbatim. Escape every value you interpolate; an unescaped customer name is stored XSS in your own admin.
  • script is served from /plugin-admin.js, same-origin, under the same access rule as the page.

Storage and migrations

Every code plugin gets a namespaced key-value store. One plugin cannot read another's records.

import { createPluginStore, LocalDB } from 'astrobaas/core';

// Inside a route handler you already have `store`; elsewhere, build it:
const store = createPluginStore('shop', LocalDB);

await store.put('orders', 'ord_1', { total_cents: 8900 });
await store.get('orders', 'ord_1');
await store.list('orders');   // capped at 5,000 records
await store.delete('orders', 'ord_1');
await store.clear('orders');
  • No query language, joins or secondary indexes. list() returns at most 5,000 records and logs a warning past that, with no error to the caller.
  • Collection names starting with _ are reserved.
  • Records are not exposed by any core route and do not appear in the content change feed.
  • Uninstalling a plugin deletes its data.

Migrations run at bootstrap in version order, and the version is stamped after each one:

migrations: [
  { version: 1, name: 'add-status', up: async (store) => { /* ... */ } },
],

A failure stops the run at the last success. Migrations are serialized within one process but not across replicas, so up() must be idempotent.

Plugin settings

Each plugin record carries a settings object. Read it scoped to your plugin:

// In a route handler
const { apiKey, retries = 3 } = await ctx.settings();
// Anywhere else
import { getPluginSettings, getPluginSetting } from 'astrobaas/core';

const all = await getPluginSettings('acme-stamps');
const retries = await getPluginSetting('acme-stamps', 'retries', 3);
  • Read settings where you use them, not in activate(). Activation runs once per process and a cached value goes stale when the operator changes it.
  • Both functions return {} (or your fallback) rather than throwing when there is no record or storage is unreachable. getPluginSetting treats null as absent.
  • Keep credentials in environment variables, not settings. Settings live in the database; the bundled smtp2go connector reads SMTP2GO_API_KEY from the environment and disables itself loudly when it is missing.

Core does not generate a settings form for code plugins. If your plugin needs operator-editable configuration, provide an admin screen for it.

Content types from plugins

A plugin can register a new collection with registerContentType(), usually from activate():

import { definePlugin, registerContentType } from 'astrobaas/core';

export default definePlugin({
  id: 'events', name: 'Events', version: '1.0.0',
  description: 'Adds an event content type.', author: 'You',
  activate() {
    registerContentType({
      name: 'event',                 // served at /api/content/event
      label: 'Event',
      visibility: 'public',          // omit and the type is staff-only
      fields: [
        { name: 'title', rule: { type: 'string', min: 1, max: 200 } },
        { name: 'starts', rule: { type: 'string' } },
        { name: 'seats', rule: { type: 'number', min: 0, optional: true } },
      ],
    });
  },
});
Option Values Default
visibility 'public' (anyone can read) or 'staff' (signed-in users) 'staff'
writable 'staff' or 'public' (anyone can POST: a form) 'staff'
moderated true stores public submissions as pending off
notifyOnSubmission true emails the site admin on each public submission off

A private type answers 404 to anonymous readers, the same as a type that does not exist. writable: 'public' opens an anonymous endpoint protected by a per-IP rate limit, a honeypot and a proof-of-work challenge, then schema validation. A contact form is writable: 'public' with visibility: 'staff'.

Generic CRUD is exposed at /api/content/<type> (GET, POST) and /api/content/<type>/<id> (PUT, DELETE). A name a plugin registers stays the plugin's: an admin-defined type with the same name is refused at bootstrap. See Custom content types for the admin builder and field rules.

Declarative plugins (manifests)

A declarative plugin is a JSON manifest. It is validated, stored and interpreted by existing subsystems. Nothing is eval'd or dynamically imported.

{
  "id": "faq-and-seo",
  "name": "FAQ + SEO Meta",
  "version": "1.0.0",
  "description": "An FAQ collection and a robots meta tag.",
  "author": "You",
  "homepage": "https://example.com",
  "astrobaasApi": "^1.0.0",
  "capabilities": {
    "headTags": [
      { "tag": "meta", "attrs": { "name": "robots", "content": "index, follow" } }
    ],
    "css": ".faq-question { font-weight: 600 }",
    "contentTypes": [
      {
        "name": "faq", "label": "FAQ", "labelPlural": "FAQs",
        "visibility": "public",
        "fields": [
          { "name": "question", "rule": { "type": "string", "min": 3, "max": 300 } },
          { "name": "answer", "rule": { "type": "string", "max": 5000 } }
        ]
      }
    ],
    "webhooks": [
      { "event": "post.published", "url": "https://hooks.example/notify" }
    ],
    "sections": [
      {
        "name": "promo",
        "label": "Promo band",
        "description": "A branded strip with a call to action.",
        "template": "<div class=\"ab-x-faq-and-seo-promo\"><h3>Promo</h3><p>Copy.</p></div>",
        "css": ".ab-x-faq-and-seo-promo { padding: 2rem; background: var(--surface-color); }",
        "modifiers": { "tone": ["quiet", "loud"] }
      }
    ]
  }
}

A working copy is at examples/plugins/faq-and-seo.manifest.json.

Capabilities

The capabilities object is the complete list of what a manifest can do. The admin shows each installed manifest's capabilities.

Capability What it adds Limit
headTags Structured { tag, attrs } objects, meta or link only, attribute allow-list 20 tags
css CSS appended to /plugins.css 20,000 characters
contentTypes Collections, same shape as registerContentType() 10 types, 40 fields each
webhooks Outbound webhooks; https only and SSRF-guarded at install 10
sections Editor blocks with scoped CSS 12 sections; 4,000-character template, 8,000-character CSS each

Top-level manifest fields: id (kebab-case, may not reuse a bundled id), name, version (semver), description, author, homepage (https only), astrobaasApi (a mismatched major version is refused), dependencies and capabilities.

Section rules

  • The class is derived as ab-x-<pluginId>-<name>; you do not choose it.
  • Every ab- class in the template must be yours.
  • Every CSS selector must start with .ab-x-<pluginId>-. Descendants and @media, @supports, @container and @layer wrappers are allowed; body {}, * {} and @font-face are refused.
  • modifiers produce ab-x-<pluginId>-<group>-<value> classes: up to 6 groups, 1 to 12 strict kebab-case values each.
  • The template must survive the content sanitizer byte-for-byte, or install fails with a diff.

When a plugin is removed, its sections leave the palette and its CSS stops being served, but existing markup stays in content as plain readable HTML. Setting SANITIZE_STRICT_CLASSES=1 strips plugin section classes on save; it is off by default because it destroys that markup.

Validate, install, uninstall

npx astrobaas plugin validate faq-and-seo.manifest.json

This runs the same validator as the install endpoint and exits non-zero listing every problem.

  • Admin: Admin → Plugins → Install a plugin, then browse the registry or paste a manifest. Activate it like any other plugin.
  • API (admin session): POST /api/plugins/install with { "manifest": { ... } }; DELETE /api/plugins/install with { "id": "faq-and-seo" }.
  • Activate or deactivate (admin session): POST /api/plugins/toggle with { "id": "faq-and-seo", "active": true }.

Installing takes effect in the running process at once. Other processes sharing the database notice within about 15 seconds of their next request and rebuild their registry.

The curated registry

Registry browsing is on by default and points at examples/registry/index.json in this repository.

Variable Effect
PLUGIN_REGISTRY_URL Point browsing at your own index.
PLUGIN_REGISTRY_DISABLED=1 Turn registry browsing off. Pasting manifests still works.

Each index entry carries a SHA-256 of the manifest bytes, verified before parsing. This is checksum pinning against an index the operator chose, not author signing. Entries of kind bundled point at code plugins already in the build and cannot be installed from the registry.

Dependencies and version ranges

A declarative manifest can require other plugins, declarative or bundled:

{
  "id": "faq-pro",
  "version": "1.0.0",
  "dependencies": { "faq-and-seo": "^1.0.0" },
  "capabilities": {}
}

Supported range forms: ^1.2.3, ~1.2.3, >=1.2.3, >1.2.3, <=1.2.3, <1.2.3, =1.2.3, 1.2.3 and *. Anything else (|| unions, hyphen ranges, 1.x) is a validation error. A pre-release such as 2.0.0-beta.1 only satisfies a range that names a pre-release of the same version. Up to 12 dependencies.

Action Behaviour
Install with a dependency missing Allowed; the response lists what is still needed.
Activate with a dependency missing, inactive or out of range Refused, naming which and why.
Deactivate or uninstall a plugin that an active plugin depends on Refused, naming the dependents.
Same, when the dependent is inactive Allowed.

Code plugins do not declare dependencies, but they can be depended on: every plugin has an id and a version.

External plugins and suites

An external plugin is an npm package, or a single built file, that the operator names in ASTROBAAS_PLUGINS. This is how plugins that are not part of this repository, including paid modules and per-client integrations, are installed.

# from your AstroBaaS checkout
npx astrobaas plugin package acme-stamps   # scaffold a standalone package
cd acme-stamps && npm install && npm run build
cd ..
ASTROBAAS_PLUGINS="$PWD/acme-stamps/dist/index.mjs" \
ASTROBAAS_PLUGINS_ACTIVATE=acme-stamps npm run dev

The scaffolded package has only a build script; npm run dev belongs to the AstroBaaS checkout. astrobaas plugin package prints the exact commands with absolute paths filled in.

Environment variables

Variable Meaning
ASTROBAAS_PLUGINS Comma-separated module specifiers. A value starting with . or / is a path, resolved against the server process's working directory; anything else is a package name resolved from node_modules.
ASTROBAAS_PLUGINS_ACTIVATE Comma-separated plugin ids to switch on at boot. An id that is not installed is logged and reported; nothing else is activated in its place.

Activation is deliberately an operator decision. A plugin cannot activate itself on install.

The module contract

The default export may be:

  • a plugin object,
  • an array of plugin objects (a suite: several plugins in one package, so adding a module never requires another environment change),
  • a factory (host) => plugin | plugin[].

Prefer the factory and import nothing from the host at runtime. An import from astrobaas/core in a shipped bundle would resolve against whatever version the server has, or fail. Use astrobaas as a devDependency for types; the scaffolded build (esbuild, fully bundled) produces a file with no imports.

The factory receives:

Key What it is
definePlugin The host's identity helper.
PLUGIN_HOOKS The host's hook constants.
text { fold, transliterate }, the core's text utilities, so a module folds search terms exactly as the core does.
getSettings () => Promise<Record<string, unknown>>: every site setting as a map. Async, so usable in routes and admin render, not in synchronous filters.
getPluginSettings (pluginId) => Promise<Record<string, unknown>>: a plugin's own settings.
// A suite: two plugins from one package
export default function create({ definePlugin, PLUGIN_HOOKS }) {
  return [
    definePlugin({
      id: 'acme-stamps', name: 'Acme Stamps', version: '1.0.0',
      description: 'Stamps posts.', author: 'Acme', requiresCore: '^1.0.0',
      filters: { [PLUGIN_HOOKS.POST_TITLE]: (title) => title },
    }),
    definePlugin({
      id: 'acme-reports', name: 'Acme Reports', version: '1.0.0',
      description: 'A report screen.', author: 'Acme', requiresCore: '^1.0.0',
      adminPages: [{ path: 'overview', title: 'Reports', render: async () => '<h1>Reports</h1>' }],
    }),
  ];
}

Compatibility and load failures

Declare requiresCore with the plugin API range you built against. The host version is MANIFEST_API_VERSION (currently 1.0.0), the same version declarative manifests check. An incompatible plugin is refused at load with a message naming both versions.

Loading never takes boot down. A module that fails to import, exports something that is not a plugin, or fails the compatibility check is logged at every boot and reported by GET /api/health/deep under plugins.failed_to_load, so a deploy script can check that what was requested actually loaded. A plugin the database marks active with no implementation loaded is also logged. See Deployment.

Payment-provider plugins

A plugin contributes gateways through the PAYMENT_PROVIDERS filter and offline methods through MANUAL_METHODS. Both are collected once at bootstrap, from active plugins only. The types are exported from astrobaas/core:

import type { PaymentProvider, ManualMethodDef } from 'astrobaas/core';
import { WebhookVerificationError } from 'astrobaas/core';
Member Required Purpose
id yes Stable identifier stored on orders. Renaming it is a migration.
label yes Shown in the admin and storefront.
requiredEnv yes Environment variable names that must be set and non-empty.
createSession(order, ctx) yes Open a hosted checkout; return { reference, redirectUrl, expiresAt? }.
verifyWebhook(rawBody, headers, ctx) yes Verify and normalise an inbound webhook to a VerifiedEvent; throw on any verification failure.
validateEnv(env) no Deeper credential checks; return a list of problems (names, never values).
refund(order, amountCents, idempotencyKey, ctx) no Without it the admin says refunds are unavailable.
captureApproved(event, ctx) if you can return approved Turn a buyer approval into a capture.

Rules the platform enforces:

  1. A provider is offered only when it is listed in PAYMENTS_ENABLED, every requiredEnv variable is set, and validateEnv() reports no problems.
  2. A forged webhook must answer 401, not 500. Throw an error whose class name ends in VerificationError (for example, extend WebhookVerificationError).
  3. Never trust an amount from the wire. Compare the paid amount against the order total; mismatches are ignored, not paid.
  4. captureApproved is called by core only once it has judged the order shippable. Return the event the capture produced, throw on transient failures so the webhook is retried, and be idempotent per approval.
  5. A manual method id that shadows a gateway id is refused.

Provider webhooks arrive at /api/payments/webhook/<provider-id>. Built-in providers (Stripe, PayPal, Klarna) are the reference implementations in src/lib/payments/. See Payments and PAYMENTS.md.

Worked example: a minimal code plugin

This plugin appends a note to every post, styles it, exposes a public read-only endpoint and adds an admin screen. Scaffold the file first (the CLI refuses to overwrite):

npx astrobaas plugin new footer-note

Then replace src/plugins/footer-note/index.ts with:

import { definePlugin, PLUGIN_HOOKS, getPluginSetting } from 'astrobaas/core';

const ID = 'footer-note';
const DEFAULT_NOTE = 'Thanks for reading.';

// Classes in content must be allow-listed; the ab-x-<pluginId>- namespace is.
const CLASS = 'ab-x-footer-note-box';

const escapeHtml = (s: string) =>
  s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]!));

export default definePlugin({
  id: ID,
  name: 'Footer Note',
  version: '1.0.0',
  description: 'Appends a short note to every post.',
  author: 'You',

  filters: {
    // Filters are synchronous, so the note text here is fixed.
    [PLUGIN_HOOKS.POST_CONTENT]: (html: string) =>
      typeof html === 'string' && html.trim()
        ? `${html}<p class="${CLASS}">${escapeHtml(DEFAULT_NOTE)}</p>`
        : html,

    // Served from /plugins.css, never inline.
    [PLUGIN_HOOKS.PLUGIN_STYLES]: (css: string) =>
      `${css}\n.${CLASS} { margin-top: var(--space-6); color: var(--muted-color); font-style: italic; }`,
  },

  routes: [
    {
      method: 'GET',
      path: `/api/plugin/${ID}/note`,
      access: 'public',
      handler: async ({ settings }) => {
        const { note } = await settings();
        return Response.json({ success: true, data: { note: typeof note === 'string' ? note : DEFAULT_NOTE } });
      },
    },
  ],

  adminPages: [
    {
      path: 'overview',                      // /admin/plugin/footer-note/overview
      title: 'Footer Note',
      nav: { label: 'Footer Note' },
      render: async () => {
        const note = await getPluginSetting(ID, 'note', DEFAULT_NOTE);
        return `<h1>Footer Note</h1><p>Current note: ${escapeHtml(String(note))}</p>`;
      },
    },
  ],
});

Register it in src/plugins/index.ts (add the import and append footerNote to BUNDLED_PLUGINS), run npm run build, then activate Footer Note in Admin → Plugins. Check:

  • a published post ends with the note, styled from /plugins.css,
  • GET /api/plugin/footer-note/note returns JSON without signing in,
  • the Footer Note screen appears in the admin navigation for administrators.

To ship the same plugin outside the repository, move it into a package made with npx astrobaas plugin package, wrap it in a factory, and replace the astrobaas/core imports with what the factory receives (use getPluginSettings from the host in place of getPluginSetting).

Testing plugins

npm run build                      # astro check: hook signatures and types
npm run dev                        # activate the plugin and exercise it
npm test                           # unit tests plus the smoke suite

Focused suites:

Command Covers
npm run test:plugins Plugin manager: filters, actions, activation
npm run test:plugin-platform Routes, admin screens, storage, migrations
npm run test:plugin-deps Dependency rules
npm run test:semver Version range parsing
npm run test:plugin-sections Declarative section validation
node tests/manifest.test.mjs Declarative manifest validation
npm run test:cli astrobaas plugin commands, including scaffolding

For a declarative plugin, npx astrobaas plugin validate <file> is the pre-flight check. For an external plugin, start the server with ASTROBAAS_PLUGINS set and confirm GET /api/health/deep shows an empty plugins.failed_to_load.

When a manifest is not enough

Declarative plugins cannot add request handlers, admin screens or business logic. When you reach that limit:

  1. Write a code plugin and redeploy, or ship it as an external package.
  2. Rebuild on the server: add the module to src/plugins/, register it, and run npm run build in place. This keeps type safety and CSP hashing but needs the build toolchain in production and does not suit immutable containers. It is a documented escape hatch, not a supported product feature.

See also

Clone this wiki locally