AstroBaaS

Build on it

Plugin development

Generated from PLUGIN_DEVELOPMENT.md in the AstroBaaS repository. The repository is the source of truth; this page is a copy of it.

AstroBaaS has two plugin tiers. Pick by what you need to do:

Code plugins (bundled)Declarative plugins (manifest)
What it isA TypeScript moduleA JSON manifest
Installed byAdding an import + rebuildingUploading / registry, at runtime
Can run logic✅ any filter/action❌ none — it is data
Can addanythingmeta/link tags, CSS, content types, webhooks
Privilegesfull application privilegesnone — nothing is executed
Best fordevelopers, AI agentsoperators who can’t redeploy

Both tiers share one registry, one activation model, and one admin screen.

Code plugins are small, trusted, in-process TypeScript modules bundled with the app. They extend behaviour through filters (transform a value) and actions (fire-and-forget side effects). Activation state is persisted in the database, so a server restart preserves what the operator turned on.

Code plugins run with full application privileges — there is no sandbox. Only install ones you trust. This fits AstroBaaS’s single-node, self-hosted model. Declarative plugins carry no such warning precisely because they cannot execute.

Jump to: Declarative plugins.

Anatomy of a plugin

Create a folder under src/plugins/<your-id>/ with an index.ts that default-exports a plugin via definePlugin(). Import everything from the stable astrobaas/core barrel — never from internal src/lib/* paths:

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_CONTENT]: (html: string, post?: Post) => html + '<p>Hi</p>',
  },
  actions: {
    [PLUGIN_HOOKS.AFTER_POST_SAVE]: (post: Post) => console.log('saved', post.id),
  },
  activate() {/* optional one-time setup */},
  deactivate() {/* optional teardown */},
});

Then register it in src/plugins/index.ts:

import myPlugin from './my-plugin';
export const BUNDLED_PLUGINS = [readingTime, draftWatermark, myPlugin];

definePlugin() is an identity helper (like Astro’s defineConfig) that gives you full hook type-checking. astrobaas/core is the only import surface covered by the project’s stability guarantees — see STABILITY.md.

That’s it — it appears under Admin → Plugins, where it can be activated. The choice is saved to the plugins table and replayed on the next boot by pluginManager.bootstrap().

Hook catalog

Hook names live in PLUGIN_HOOKS (exported from astrobaas/core) — always use the constants, never raw strings, so they can’t drift.

ConstantKindSignatureNotes
API_POSTS_GETfilter(posts: Post[]) => Post[]The full post list, before visibility filtering.
POST_CONTENTfilter(html: string, post: Post) => stringPost body HTML. Re-sanitized after filters before render, so you cannot inject script.
POST_TITLEfilter(title: string, post: Post) => stringPost title (rendered as text).
BEFORE_POST_SAVEfilter(post, ctx: { isNew: boolean }) => postMutate/validate post fields just before create or update. Content is sanitized after this hook; author_id is always overridden by the session.
AFTER_POST_SAVEaction(post: Post) => voidFires after a post is created or updated.
AFTER_POST_DELETEaction(post: Post) => voidFires after a post is deleted.
HEAD_TAGSfilter(html: string, ctx: { pathname: string }) => stringAppend markup to <head> on public pages. Sanitized to meta/link only — no <script>, and no <style> (use PLUGIN_STYLES).
PLUGIN_STYLESfilter(css: string, ctx: { pathname: string }) => stringAppend CSS for public pages. All active plugins’ CSS is concatenated and served from /plugins.css.

Filters run in activation order; each receives the previous filter’s output. A filter that throws is skipped (its input passes through) and the error is logged.

Commerce hooks

The same table, for shops. Everything money-shaped is integer cents.

ConstantKindSignatureNotes
API_PRODUCTS_GETfilter(products: Product[]) => Product[]Full product list, before filtering and pagination.
SEARCH_EXPANDfilter(expander: TermExpander | null, ctx: { products, query, settings }) => TermExpander | nullContribute alternative spellings for a search term — Greeklish, typos, synonyms. The core keeps ownership of relevance (lib/search/rank.ts); this only says what a term may also mean, and is consulted only when the literal term matched nothing, memoised once per query. Returning null means “no expansion” and is the default. Compose with the expander you are handed rather than discarding it — do not assume you are the only plugin registered. ctx.settings holds every search_* setting, read by the call site and passed in, because applyFilters is synchronous and settings reads are not.
BEFORE_PRODUCT_SAVEfilter(product, ctx: { isNew }) => productJust before persist. Description HTML is sanitized after this filter.
AFTER_PRODUCT_SAVEaction(product: Product) => voidAfter create or update.
AFTER_PRODUCT_DELETEaction(product: Product) => voidAfter delete.
PRODUCT_PRICEfilter(priceCents, product, ctx: { qty }) => numberFinal unit price at checkout — sales rules, volume discounts, member pricing. Must return integer cents.
BEFORE_ORDER_SAVEfilter(order, ctx: { isNew }) => orderBefore an order persists (checkout or admin edit). Validation runs before this hook; totals are re-checked after it.
AFTER_ORDER_CREATEaction(order: Order) => voidCheckout completed. The hook the new-sale notifier uses.
AFTER_ORDER_STATUS_CHANGEaction(order, previousStatus) => voidAfter a status change, with the status it left.
ORDER_LINE_EXTRASfilter(result, ctx: { line, product, name }) => resultThe vertical’s extension point. Return { ok: true, patch } to accept (patch is frozen onto the stored line) or { ok: false, message } to refuse the whole order with a 400. If the incoming value is already ok: false, return it unchanged — a refusal must survive later plugins. With no plugin registered the initial value passes through, so an install without your module accepts the order and stores the raw data instead of 500ing.
COMMERCE_SCHEMAfilter(schema, ctx: { name, variant? }) => schemaPublish a machine-readable schema for a storefront form (this is how the optical module serves /api/commerce/prescription-schema). Initial value is null; return it unchanged for names you don’t recognise. Core answers 404 while it stays null — which is also what an install without the module should look like.
PAYMENT_PROVIDERSfilter(providers: PaymentProvider[]) => PaymentProvider[]Contribute payment gateways. Collected once at bootstrap from active plugins only. See “Payment providers from a plugin” below.
MANUAL_METHODSfilter(methods: ManualMethodDef[]) => ManualMethodDef[]Contribute manual (offline) payment methods. A manual id that shadows a gateway id is refused — otherwise a shop could accept as unpaid what it believes was charged.

Payment providers from a plugin

The contracts are exported from astrobaas/core:

import type { PaymentProvider, ManualMethodDef } from 'astrobaas/core';
import { WebhookVerificationError } from 'astrobaas/core';

Three rules the platform enforces, so build to them:

  1. A provider is only offered when its requiredEnv is present. Name the variables; never read secrets from settings. The admin’s payment report shows operators which names are missing — names, never values.
  2. A forged webhook must answer 401, not 500. Throw an error whose class name ends in VerificationError from verifyWebhook — the platform matches on the name, so your own class works from any package: class MyPspVerificationError extends Error { name = 'MyPspVerificationError' }.
  3. Never trust an amount from the wire. Verify the webhook, then compare the paid amount against the ORDER’s total; mismatches are ignored, not paid.

Why plugin CSS is a hook and not a <style> tag

AstroBaaS ships a hash-based CSP with no 'unsafe-inline': the build hashes every script and style it bundles, and the browser rejects anything else. Plugin markup is produced per request, so it has no build-time hash — an inline <style> would be silently dropped (the tag appears in the DOM, but styleTag.sheet is null and the CSS simply never applies, with no console error). Astro’s CSP runtime API can’t rescue it either, because the Node adapter streams: the CSP header is finalized before your layout renders.

So CSS goes through PLUGIN_STYLES and is served same-origin from /plugins.css, which satisfies style-src 'self'. The <link> is only emitted when an active plugin actually contributes CSS. See src/plugins/print-styles/ for the reference implementation.

api_posts_get receives a PAGE

GET /api/posts filters and paginates in the storage layer, so this hook is handed the rows for the requested page — not every post in the database. That is unavoidable once the database does the paging, and it means a filter here cannot see or reorder rows outside the current page. Filtering rows OUT of the page will also make meta.total disagree with what was returned, because the count comes from the query.

If you need to influence which posts are returned, do it with the query parameters (status, kind, category, locale) rather than by removing rows after the fact.

Safety notes

  • POST_CONTENT output is re-run through the server sanitizer at the render boundary (src/lib/sanitize.ts), so a content filter cannot reintroduce <script> or javascript: URLs.
  • Actions are isolated: a throwing action is logged and does not break the request.
  • Plugins share the process — avoid blocking work and unbounded memory use.

Admin-defined content types (no plugin required)

The same registry, through a screen: Admin → Content types lets an admin define a collection — name, labels, read policy, fields with validation — and it is real on the next request: REST at /api/content/<name>, an entries screen generated from the schema, and the same deny-by-default visibility rule plugin types get. No plugin, no deploy, no restart.

The precedence rule matters to plugin authors: a name your plugin registered stays yours. An admin definition with the same name is refused loudly at bootstrap rather than shadowing you — a paid module’s collection must not be hijackable from a settings field.

Definitions are validated exactly as hard as a hostile manifest (the stored value outlives the screen that wrote it), and writes are audited: a collection appearing, vanishing or going public is an API-surface change with a trail.

Custom content types

Beyond filters/actions, a plugin can register an entirely new content collection — products, events, docs, anything — with registerContentType() (call it from the plugin’s activate()):

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

export default definePlugin({
  id: 'product-catalog', name: 'Product Catalog', version: '1.0.0',
  description: 'Adds a product content type.', author: 'You',
  activate() {
    registerContentType({
      name: 'product',                 // -> /api/content/product
      label: 'Product',
      visibility: 'public',            // REQUIRED to be readable anonymously
      fields: [
        { name: 'name',  rule: { type: 'string', min: 1, max: 200 } },
        { name: 'price', rule: { type: 'number', min: 0 } },
        { name: 'sku',   rule: { type: 'string', max: 64, optional: true } },
      ],
    });
  },
});

visibility — read it before you ship a type

A content type is PRIVATE unless it declares visibility: 'public'. Omit the field and anonymous reads get 404; only a signed-in user (or an API key scoped to content) can list it.

ValueWho can read
'public'anyone, no credentials — site content a frontend renders
'staff'any signed-in user (the default when the field is absent)

Both registration paths take the same key. A code plugin passes it to registerContentType(); a declarative manifest puts it in the contentTypes entry (see the manifest example below). They are the same field with the same default, and the manifest path is covered end-to-end by the smoke suite — it previously accepted visibility: "public", validated it, and then registered the collection private, which is a silent failure with no error for the author to find.

This is deliberately the annoying way round. Types used to be world-readable with no way to opt out, which is fine for event or doc and a data breach for job-application or enquiry — and the leak fell on the author who never thought about visibility, i.e. the one most likely to get it wrong. Making you type the word public costs one line and moves the decision to the person who knows the answer.

A private type answers 404, not 403, and it is the same 404 an unregistered type gets. Whether a collection exists is itself information: job-application returning 403 while nonsense returns 404 tells a prober which plugins you have installed.

Writes are unaffected — they were always admin/editor.

AstroBaaS then exposes generic, schema-validated CRUD:

MethodPathAuth
GET/api/content/<type>public only if visibility: 'public'; otherwise any session
POST/api/content/<type>admin/editor
PUT/api/content/<type>/<id>admin/editor
DELETE/api/content/<type>/<id>admin/editor

Every write is validated against the fields schema (same validate() the core uses). Records are stored generically; reads are public so any Astro page can render them. name must be kebab-case and not collide with a built-in collection.

Bundled examples

  • reading-time — a POST_CONTENT filter that prepends an estimated read time. Shows a simple value transform.
  • draft-watermark — a POST_CONTENT filter that banners non-published posts. Shows a per-post conditional transform using the post argument.
  • product-catalog — registers a product custom content type. Shows the registerContentType() primitive end-to-end.
  • print-styles — contributes CSS via PLUGIN_STYLES, served from /plugins.css. The reference for adding styling.
  • smtp2go — swaps the email transport. The reference for a connector: a plugin that replaces a core service rather than filtering content, and the reference for the activate/deactivate lifecycle. See below.

SMTP2GO connector

Sends AstroBaaS mail — password resets, contact forms, order notifications — through SMTP2GO’s HTTP API. No SMTP socket handling, and it works from platforms that block outbound port 587.

SMTP2GO_API_KEY=api-XXXXXXXXXXXX
SMTP2GO_SENDER="My Shop <no-reply@example.com>"

Then activate SMTP2GO under Admin → Plugins.

Three decisions in it are worth copying into your own connector:

  1. The credential comes from the environment, not from plugin settings. Settings live in the database next to a public read path. A connector that stashed its API key there would repeat a bug this codebase already fixed.
  2. A missing credential disables the connector loudly and leaves the previous transport alone. It does not fall back to swallowing mail — a half-configured mail connector that silently drops password resets is worse than no connector.
  3. HTTP 200 is not success. SMTP2GO answers 200 with succeeded/failed counts, so the transport reads the body and throws when nothing was accepted. A transport that only checks res.ok drops undeliverable mail with no error anywhere — the user simply never receives it.

deactivate() passes null to setEmailTransport(), which restores the env-configured default rather than pinning the console transport. Turning the plugin off must not quietly disable a configured fallback.


External / shippable plugins

The third kind, and the one a business ships: an npm package (or a single built file) that a customer names in an environment variable. This is how the optical and IRIS modules install on a customer’s server without that server ever holding their source.

npx astrobaas plugin package acme-stamps    # scaffold the whole shape
cd acme-stamps && npm install && npm run build
ASTROBAAS_PLUGINS=./acme-stamps/dist/index.mjs \
ASTROBAAS_PLUGINS_ACTIVATE=acme-stamps npm run dev

The contract

ASTROBAAS_PLUGINS is a comma-separated list of specifiers. A path (starting with . or /) is imported from disk; anything else resolves as a package name from node_modules. The module’s default export may be:

  • a plugin object,
  • an array of plugin objects — one package can carry every module a customer licensed, so a fourth purchase never means another env change,
  • or a factory ({ definePlugin, PLUGIN_HOOKS }) => plugin | plugin[].

Prefer the factory, and import nothing from the host at runtime. Your file is loaded INTO a running AstroBaaS: an import ... from 'astrobaas/core' in a shipped bundle would resolve against the CUSTOMER’S node_modules — a version you were never built with — or fail outright. The factory hands you the host’s own definePlugin and PLUGIN_HOOKS, which is the same thing with none of the risk. Use astrobaas as a devDependency for types while developing; npm run build (esbuild, bundled, no externals) produces a file with zero imports.

Compatibility

Declare the plugin-API range you were built against:

requiresCore: '^1.0.0',

An incompatible module is refused at load, with a sentence naming both versions, instead of loading and failing somewhere deep at request time. The host’s version is the same MANIFEST_API_VERSION declarative manifests check, so “what am I compatible with” has one answer across every plugin kind.

What loading failure looks like

loadExternalPlugins never takes the boot down: every module that fails to import, exports something that is not a plugin, or fails the compatibility check is collected with its reason and reported — on the console at every boot, and by GET /api/health/deep under plugins.failed_to_load, so a deploy script can assert that what was requested actually loaded.

Storage, from a route

Route handlers receive ctx.store, already namespaced to your plugin — you import nothing (see “Storage” below). Outside handlers, the factory pattern still applies: do the work inside hooks and routes, which is where the host hands you what you need.

Declarative plugins (runtime-installable)

A declarative plugin is data, not code: a JSON manifest an operator installs from the admin UI with no rebuild and no redeploy. It is validated, stored, and then interpreted by subsystems that already exist. Nothing is eval’d, vm’d, or dynamically imported — which is why this tier can be exposed to operators while code plugins stay build-time only.

Manifest format

{
  "id": "faq-and-seo",              // kebab-case, unique, may not shadow a bundled id
  "name": "FAQ + SEO Meta",
  "version": "1.0.0",               // semver
  "description": "…",
  "author": "You",
  "homepage": "https://…",          // https only
  "astrobaasApi": "^1.0.0",         // manifest API range; mismatched major is refused
  "capabilities": {
    "headTags": [                    // <meta>/<link> only, attribute-allow-listed
      { "tag": "meta", "attrs": { "name": "robots", "content": "index, follow" } }
    ],
    "css": ".faq-question { font-weight: 600 }",   // appended to /plugins.css
    "contentTypes": [                                // same shape registerContentType() takes
      {
        "name": "faq", "label": "FAQ", "labelPlural": "FAQs",
        "visibility": "public",                      // omit -> private (staff only)
        "fields": [
          { "name": "question", "rule": { "type": "string", "min": 3, "max": 300 } },
          { "name": "answer",   "rule": { "type": "string", "max": 5000 } }
        ]
      }
    ],
    "webhooks": [                                    // https + SSRF-guarded
      { "event": "post.published", "url": "https://hooks.example/notify" }
    ],
    "sections": [                                    // editor blocks, namespaced to you
      {
        "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); }"
      }
    ]
  }
}

A working example lives at examples/plugins/faq-and-seo.manifest.json.

Dependencies

A plugin can require another. This is what makes verticals possible: a generic commerce plugin sold to everyone, and an optical pack — prescriptions, dioptres, lens configurators — sold on top of it.

{
  "id": "optical",
  "version": "1.0.0",
  "dependencies": { "commerce": "^2.0.0" },
  "capabilities": { /* … */ }
}

Ranges use a deliberate subset — ^1.2.3, ~1.2.3, >=1.2.3, 1.2.3, * — and anything outside it is a validation error rather than a dependency that silently never matches. || unions, hyphen ranges and 1.x wildcards are not supported. A pre-release (2.0.0-beta.1) only satisfies a range that names a pre-release at the same version, so ^2.0.0 will not quietly accept an unfinished build.

Where each rule bites:

ActionBehaviour
Install with a dependency missingAllowed. You cannot be made to install in topological order. The response lists what is still needed and says it cannot be activated yet.
Activate with any dependency missing, inactive or out of rangeRefused, naming which and why. This is when a plugin starts contributing content types, sections and webhooks, so it is when its assumptions must hold.
Deactivate / uninstall a plugin an ACTIVE plugin depends onRefused, naming the dependents. Otherwise the dependent keeps running against something that is gone and fails somewhere unrelated.
Same, but the dependent is INACTIVEAllowed. It simply cannot activate until the dependency returns.

A plugin cannot depend on itself, and a dependency on a bundled plugin works the same way — both tiers carry an id and a version.

Sections

A section is a block an author can insert from the editor’s palette. Yours are namespaced: the host derives the class as ab-x-<pluginId>-<name> and you do not get to choose it. That is what stops two plugins colliding and stops either shadowing a core section.

Three rules, all enforced at install rather than discovered later:

  1. Every ab- class in your template must be yours. Reusing ab-hero would inherit core styling you do not control and break the moment that section changes.
  2. Every CSS selector must start with .ab-x-<pluginId>-. Descendants are fine (.ab-x-you-promo h3), and so are @media / @supports / @container wrappers around scoped rules. body {}, * {} and @font-face are refused — this is what bounds your CSS to the markup you contributed instead of the whole document.
  3. Your template must survive the content sanitizer byte-for-byte. If it does not, the install fails and the response shows your markup next to what the sanitizer returned, so you can see exactly what was removed. A section that renders in the editor and loses part of itself on save is the worst failure this system can have, so it is refused up front rather than warned about.

Validate before uploading:

npx astrobaas plugin validate your-plugin.manifest.json

What happens when your plugin is removed. Its sections disappear from the palette and its styles stop being served, but the markup stays in content and degrades to plain, readable HTML. Re-installing brings the styling back. The content sanitizer recognises the ab-x-* namespace by shape rather than by looking up what is installed, precisely so that saving a page while your plugin is disabled cannot strip it out permanently.

Operators who want a closed class allow-list with no namespace escape hatch can set SANITIZE_STRICT_CLASSES=1, which strips plugin section classes on save. It is off by default because turning it on is data loss for anyone using a plugin that ships sections.

Validate before you ship

npx astrobaas plugin validate examples/plugins/faq-and-seo.manifest.json

This runs the same validator the install endpoint uses, so “valid here” means “installable there”. It exits non-zero and lists every problem.

Installing

  • Admin UI — Plugins → Install a plugin: browse the curated registry, or paste a manifest. Then activate it like any other plugin.
  • APIPOST /api/plugins/install { manifest } (admin + CSRF), DELETE /api/plugins/install { id } to uninstall.

Installing takes effect in the running process. Note that this is per process: other replicas keep their old plugin set until they restart.

What the sandboxless guarantee rests on

Declarative plugins are safe to install because of what they cannot express:

  • No code. There is no capability that carries JavaScript.
  • No arbitrary markup. headTags takes structured {tag, attrs} objects restricted to meta/link with an attribute allow-list; values are escaped and then re-sanitized. A manifest cannot emit a <script>.
  • No inline styles. css is served from /plugins.css, so the strict hash-based CSP applies unchanged.
  • No internal network access. Webhook targets are re-checked against the SSRF guard at install time, so a manifest cannot point the server at 169.254.169.254 or an RFC-1918 host.
  • No shadowing. A manifest may not take the id of a bundled plugin.
  • Bounded. Size caps on CSS, tag counts, content types, and fields.

The curated registry

PLUGIN_REGISTRY_URL points at a JSON index of available plugins (see examples/registry/index.json). Each entry carries a SHA-256 of the manifest bytes, verified before parsing — so a tampered copy is refused even when served over valid TLS. Set PLUGIN_REGISTRY_DISABLED=1 to turn registry browsing off entirely; uploads still work.

This is checksum-pinning against an operator-chosen index, not author signing — there is no public-key trust chain yet. Treat the registry as “content reviewed by whoever runs that index”.

Listing kinds

An index entry declares a kind:

  • declarative (the default) — a manifest the operator installs in one click. Requires manifestUrl + sha256; no checksum, no install.
  • bundled — a code plugin that already ships inside AstroBaaS. Some things a manifest genuinely cannot express: a connector that swaps the email transport is an implementation, not data. Listing them keeps the registry an honest directory of everything available rather than only the installable subset — someone browsing for “how do I send email” should find the answer, and it happens to be one they already have.

A bundled entry carries no URL or checksum, and the install path refuses it outright with a message pointing at Plugins. It is a pointer to code already in the build; the registry can never deliver code, and a listing must not imply otherwise.

Capability ceiling (and the escape hatch)

Declarative (manifest) plugins deliberately cannot add request handlers, admin screens, or business logic. Code plugins can — see The plugin platform. When you hit that ceiling you have two options:

  1. Write a code plugin and redeploy — the normal path for developers and AI agents, with full types and no restrictions.
  2. Rebuild-on-install — for self-hosters who want operator-installable code plugins, drop the module into src/plugins/, add the import, and rebuild (npm run build) on the server. This keeps type safety and CSP hashing because the plugin genuinely becomes build-time code. It needs the build toolchain in production and does not suit immutable containers, so it is a documented escape hatch rather than a supported product feature.

The plugin platform: routes, screens, and storage

A code plugin can own three things core used to own alone: a URL, a screen, and a table. Together they are what makes a plugin a feature rather than a filter on values core already had.

export default definePlugin({
  id: 'shop',
  name: 'Shop',
  version: '1.0.0',
  description: 'Sells things.',
  author: 'You',

  routes: [ /* API endpoints */ ],
  adminPages: [ /* admin screens */ ],
  migrations: [ /* data transforms */ ],
});

All three are read from active plugins at bootstrap and replaced wholesale on reload, so deactivating a plugin genuinely stops serving its routes rather than leaving them until a restart.

Routes

routes: [
  {
    method: 'GET',
    path: '/api/plugin/shop/orders/:id',
    access: 'staff',            // 'staff' (default) | 'admin' | 'public'
    scope: 'orders:read',       // optional: which API-key scope governs it
    handler: async ({ params, user, request, url }) =>
      Response.json({ id: params.id }),
  },
]

A plugin cannot shadow a core route. Astro sorts routes once at build time and a static path segment always sorts before a spread one (astro/dist/core/routing/priority.js), so the catch-all that dispatches plugin routes sits below every real route file. /api/orders reaches src/pages/api/orders/index.ts no matter what any plugin claims. That is structural, not a check that could be forgotten — and it is the same property src/pages/[...slug].astro already relies on.

It also means claiming a path core currently owns is silently ineffective. That is deliberate: it is what will let a module take over a surface core used to serve, on the day core stops serving it, without breaking the storefronts pointing at that URL in the meantime.

Widening a gate requires the /api/plugin/ namespace

access: 'public', csrf: 'exempt' and scope are refused outside /api/plugin/, and the reason is a real hole that this closed.

The middleware decides authentication and CSRF before routing, so it asks the route registry about the raw request path. A plugin declaring { path: '/api/media/upload', csrf: 'exempt' } never serves that path — core’s file wins — but the middleware consulted the declaration anyway and stopped checking CSRF on core’s handler. A cross-site POST then uploaded a file with nothing but a session cookie. (Reproduced, then fixed; tests/smoke.mjs keeps it closed, and the fixture still declares it so the assertion has something to catch.)

A staff or admin route may still claim any path, because it widens nothing: an unknown /api path is already staff-gated, so the declaration changes no gate.

For the same reason, the segment right after /api must be literal. A parameter there (/api/:x/me) matches every namespace at once, including the reserved ones the string check protects.

Paths under /api/auth/, /api/keys, /api/2fa/, /api/users/, /api/backup/, /api/plugins and /api/settings/ are refused at registration. Everything there authenticates somebody, hands out a credential, or changes who can log in.

Two plugins claiming one URL is refused rather than resolved — otherwise database row order would decide which plugin answers a live endpoint.

Access is the security boundary

accessWho reaches it
'staff' (default)admin, editor, author or managernot viewer
'admin'administrators only
'public'anyone, unauthenticated

'public' means an unauthenticated endpoint — exactly what a checkout or a provider webhook needs, and exactly what must never happen by accident. It is declared per route, per method.

Public does not mean unprotected. Writes are still CSRF-checked and rate-limited. csrf: 'exempt' exists for machine callers that authenticate themselves another way — a signed provider webhook — and for nothing else. A cookie-authenticated endpoint with CSRF off is a cross-site request away from being called by any page on the internet.

A route that declares no scope is unreachable by a scoped API key, because core’s scope map cannot know about a path added at runtime and an unknown path denies. Declare one to let a headless storefront call it.

What a handler gets

{ request, url, params, user, locals }. Return a Response. Anything a handler throws is logged with the plugin’s name and answered 500 with no detail — a plugin’s raw error can carry credentials, queries and paths.

Admin screens

adminPages: [
  {
    path: 'orders',                    // → /admin/plugin/shop/orders
    title: 'Orders',
    nav: { label: 'Orders', order: 10 },
    roles: ['admin', 'manager'],       // omitted → ADMIN ONLY
    render: async ({ user, csrf, url }) => `<table>…</table>`,
    script: `document.querySelector('…').addEventListener(…)`,
  },
]

Screens are namespaced under /admin/plugin/<plugin-id>/. Unlike routes they are not free-form, and the reason is specific: admin authorisation is longest-prefix-wins over one shared table, so a plugin able to register /admin/products/bulk would not merely add a screen — it would out-specify the /admin/products rule and decide who may open everything beneath it.

Omitting roles means admin only, inherited from the rule that already denies any unknown /admin path to everyone else. Forgetting is safe.

render() returns HTML that is inserted verbatim. A plugin is code already running in this process, so sanitising its own UI would buy nothing and break every real form — but you must escape any data you interpolate. A customer name rendered unescaped is stored XSS aimed at your own admin.

script is served from /plugin-admin.js, same-origin, authorised with the same rule as the page. It cannot be inline: the app ships a hash-based CSP with no unsafe-inline, so an inline <script> is dropped by the browser silently.

Storage

Every plugin gets a namespaced store. One plugin cannot read another’s records — that is a property of the API, not a rule to remember.

Inside a route handler, the store arrives on the context, already bound to your plugin — you import nothing:

routes: [{
  method: 'GET', path: '/api/plugin/shop/orders/:id', access: 'staff',
  handler: async ({ params, store }) => {
    const order = await store.get('orders', params.id);
    return new Response(JSON.stringify({ success: true, data: order ?? null }), {
      status: order ? 200 : 404, headers: { 'Content-Type': 'application/json' },
    });
  },
}]

Outside a handler — in a filter, an action, or setup code — build it yourself from the stable barrel:

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

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; see below
await store.delete('orders', 'ord_1');
await store.clear('orders');

Migrations receive the same store as their argument (see below), so all three paths read and write the same namespaced records.

Deliberately small: no query language, no joins, no indexes beyond the namespace. list() filters in-process and is capped at 5,000 records — a plugin past that has outgrown this store, and it says so rather than silently returning a prefix.

Collection names may not begin with _; those are reserved for the platform.

Records are not fed to the content change feed and are exposed by no core route. (Reusing custom_entities would have been less code and would have published every plugin record at /api/content/changes.)

Uninstalling a plugin now deletes its data. It previously deleted one row from the plugins table and orphaned everything the plugin had written — invisible, unreadable, and silently inherited by a reinstall along with its schema version.

Migrations

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

Run at bootstrap, in version order, stamped after each one so a crash halfway does not re-apply what already succeeded. A failure stops the run and leaves the version at the last success — carrying on would apply v3 to data v2 never transformed.

Versioned per plugin, independently of core’s schema version, so a plugin’s data model is its own.

up() must be idempotent. Migrations are serialised within a process but not across replicas, which is the same guarantee core’s own migration runner gives.