Repository navigation
Plugins
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.
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.
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_PLUGINSenvironment 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.
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.
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.
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.
| 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. |
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.
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_STYLESand is served same-origin from/plugins.css, -
HEAD_TAGSaccepts onlymetaandlink, - 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.
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 |
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/ordersor any other real route file. -
Widening a gate needs the
/api/plugin/namespace.access: 'public',csrf: 'exempt'andscopeare 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
/apimust 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
scopeis 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.
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 bysrc/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
rolesmeans admin only. -
render()output is inserted verbatim. Escape every value you interpolate; an unescaped customer name is stored XSS in your own admin. -
scriptis served from/plugin-admin.js, same-origin, under the same access rule as the page.
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.
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.getPluginSettingtreatsnullas absent. -
Keep credentials in environment variables, not settings. Settings live in the database; the bundled
smtp2goconnector readsSMTP2GO_API_KEYfrom 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.
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.
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.
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.
- 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,@containerand@layerwrappers are allowed;body {},* {}and@font-faceare refused. -
modifiersproduceab-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.
npx astrobaas plugin validate faq-and-seo.manifest.jsonThis 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/installwith{ "manifest": { ... } };DELETE /api/plugins/installwith{ "id": "faq-and-seo" }. -
Activate or deactivate (admin session):
POST /api/plugins/togglewith{ "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.
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.
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.
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 devThe 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.
| 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 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>' }],
}),
];
}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.
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:
-
A provider is offered only when it is listed in
PAYMENTS_ENABLED, everyrequiredEnvvariable is set, andvalidateEnv()reports no problems. -
A forged webhook must answer 401, not 500. Throw an error whose class name ends in
VerificationError(for example, extendWebhookVerificationError). -
Never trust an amount from the wire. Compare the paid amount against the order total; mismatches are
ignored, notpaid. -
captureApprovedis 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. - 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.
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-noteThen 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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/notereturns 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).
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 suiteFocused 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.
Declarative plugins cannot add request handlers, admin screens or business logic. When you reach that limit:
- Write a code plugin and redeploy, or ship it as an external package.
-
Rebuild on the server: add the module to
src/plugins/, register it, and runnpm run buildin 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.
- Themes: the same two-tier model for the site's look
- Custom content types
- Commerce and Payments
- Email: mail transports, including the SMTP2GO connector
- Webhooks
- Headless and REST API: API keys and scopes
-
SDK, CLI and MCP: the
astrobaas plugincommands - Security: CSP, sanitizer and trust model
-
Configuration:
ASTROBAAS_PLUGINS,ASTROBAAS_PLUGINS_ACTIVATE, registry variables - Contributing and licensing
- PLUGIN_DEVELOPMENT.md
AstroBaaS is free software under GPL-3.0-or-later. This wiki describes the latest release; the repository and its CHANGELOG are the final word. Found a mistake here? Open an issue.
Getting started
Using the CMS
Building with it
Extending
Commerce and email
Operating
Project