AstroBaaS

Run it

Overview

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

A TypeScript-native, self-hostable backend — auth, data, REST API, and storage — with a content admin built in. Secure by default.

Extensible by developers at build time, not by operators at runtime. Plugins and themes are typed TypeScript modules you import and deploy — not uploads you install from a dashboard. That is a deliberate trade: you get type safety, a strict CSP, and no arbitrary-code-upload surface, and you give up click-to-install. See Extensibility model.

Use it two ways with the same install:

  • As a classic CMS. One process, one db.json, boots in under a second. The public site is Astro-fast (server-rendered HTML, almost no client JS); the admin panel is a normal HTML form at /admin. No PHP, no plugin marketplace.
  • As a headless BaaS. Point any frontend (Astro/React/Vue/an AI-vibe-coded app) at the REST API with a bearer API key and configurable CORS. There’s a typed SDK (astrobaas/client), a CLI (astrobaas init), an MCP server so AI agents can drive it, signed webhooks, and an agent-readable contract at /llms.txt + /openapi.json. Persist to a JSON file for local dev or to SQLite/libSQL (Turso) for durable, multi-host deploys.

Status: pre-alpha — see CHANGELOG.md. Auth + API-key/bearer auth, post/category/user/media CRUD, custom content types, a theme system, a plugin system, pluggable storage (lowdb + libSQL), the client SDK / CLI / MCP server, outbound webhooks, sitemap, RSS, search, image optimization, and backup/restore all work. See “What’s not done yet” below for the honest gaps.

Not on npm yet. astrobaas is not published to the npm registry, so npm install astrobaas and a bare npx astrobaas outside this repository will 404. What that does and does not affect:

  • Running AstroBaaS: unaffected. Clone the repo — that is the documented install below, and it is the only one this project supports today.
  • npx astrobaas … inside the clone: works. npx resolves the bin entries from this package’s own package.json. Every CLI example here assumes you are in the project directory.
  • Importing astrobaas/client from a SEPARATE frontend project: does not work yet. Until the package is published, call the REST API directly — it is plain HTTP and JSON, documented in INTEGRATION.md and at /openapi.json — or npm pack a tarball from a local build.
  • npx astrobaas-mcp in an MCP client config: use an absolute path to bin/astrobaas-mcp.mjs in your clone. See the example below.

Publishing is tracked as part of the pre-1.0 checklist in TRUTH_PLAN.md.

Login page

Why AstroBaaS

You want…AstroBaaS gives you
A blog you can self-host on a $5/mo VPSOne Node process. db.json + public/uploads/ are the entire data layer.
Static-site speed without the rebuild-on-publish workflowAstro SSR — no build step to ship a post; the database is read from an in-process cache.
Markdown-friendly content you can grepImporters for WordPress WXR and raw markdown. Back up your whole site as one JSON file.
Pre-alpha software that doesn’t lie about being betaRoadmap and changelog say exactly what works and what doesn’t.

It is not a replacement for WordPress at scale, a headless CMS for an e-commerce front-end, or a multi-tenant platform. Those are bigger problems that need bigger tools.

Try it in 30 seconds

Requires Node 22.12+ (see .nvmrc).

git clone <repository-url>
cd AstroCMS_v1
cp .env.example .env       # optional for dev; AUTH_SECRET is required in production
npm install
npm run dev                # http://localhost:4321

Sign in at http://localhost:4321/login with admin@local / admin. Change the password immediately — the dashboard banner reminds you.

One-click deploy

  • Coolify: Point it at the repo and use the bundled Dockerfile.

Set AUTH_SECRET (generate with openssl rand -hex 32) and mount a volume at /app/data so db.json + uploads persist across redeploys.

Screenshots

Posts listEditor
Posts listEditor

Scripts

CommandWhat it does
npm run devAstro dev server on :4321
npm run buildType-check + production build
npm run startRun the production build with NODE_ENV=production (after npm run build; requires AUTH_SECRET)
npm run previewPreview the built site locally
npm run typecheckRun astro check only
npm run setupInteractive: create or replace the admin user
npm run reset-passwordCLI to reset any user’s password by email
npm run import:md <dir>Import a directory of markdown posts
npm run import:wp <wxr>Import a WordPress export (WXR XML)
npm run smokeSpawn dev, hit core routes, exit non-zero on fail
npm run smoke:libsqlSame smoke suite against the libSQL driver
npm run test:unitUnit tests (validate, auth, sanitizer, plugins, SDK, CLI, MCP)
npm testUnit tests + smoke test
npm run e2eBuild + Playwright browser tests (needs npx playwright install chromium)

The package ships two CLI bins, astrobaas and astrobaas-mcp (the MCP server). Run these from the project directory — npx resolves them from this package’s own bin entries, and the package is not on npm yet. Beyond init / secret / setup, the CLI scaffolds extensions:

npx astrobaas plugin new my-plugin        # code plugin  -> src/plugins/my-plugin/
npx astrobaas theme new my-theme          # theme        -> src/themes/my-theme/
npx astrobaas plugin manifest my-thing    # declarative manifest (runtime-installable)
npx astrobaas plugin validate my-thing.manifest.json

validate runs the same validator as the install endpoint, so “valid here” means “installable there”.

What works today

  • Auth. Signed-cookie sessions, PBKDF2 password hashing, double-submit CSRF, per-IP rate limit (in-process or shared/libSQL for multi-node), security headers, and a hash-based CSP (script-src is 'self' + per-build hashes, no 'unsafe-inline'), X-Frame-Options, Referrer-Policy, Permissions-Policy.

  • Two-factor (TOTP). Optional per-account 2FA with any authenticator app (RFC 6238, dependency-free), one-time backup codes, and a two-step login that never re-asks for the password. Enable it in your profile.

  • Content. Posts, standalone pages, categories, users, media; status workflow (draft / review / scheduled / published / trashed); slug auto-generation with uniqueness; server-side HTML allow-list sanitization. A page is a post with kind: "page" — it lands at /{slug} instead of /blog/{slug} and is excluded from the archive, the feed and the default GET /api/posts response. Posts can be pinned or given a manual position; unset sorts by date, so an existing site’s order does not change until you pin something. Headings with text get stable slug anchors (#how-it-works), body images get the file’s own width/height, and an image with no description gets one from the media library — an explicitly empty alt="" is never overwritten, because that is how correct markup marks a decorative image. An optional table of contents renders above long articles (Reading → Table of contents; 0 turns it off). The body a post API route returns in content_rendered is the body this CMS renders itself, byte for byte — same filters, same sanitizer, same anchors, same dimensions, same lazy-loading hints — because all four renderers call one function. The rendered contents list is a theme slot rather than an API field, but toc_min_headings is a public setting and the anchors are in the body, so a headless storefront can build the same list from the same ids.

  • Content analysis in the editor. A green dot with the checks behind it, recomputed as you type — no endpoint, no round trip, and it works on a draft that has never been saved. It deliberately shows no reading-ease score outside English: the formula counts English syllables, and running it over Greek would print a number nobody can trace to a fact.

  • Search you can teach. Ranked, accent- and capital-folding, weighted by field, with a synonym table you maintain — σκελετός, μοντούρα, frame makes all three find each other. Typo tolerance and Greeklish need an index of your own catalogue and ship in the paid module.

  • Sections and patterns. A palette of pre-designed blocks (hero, columns, card, CTA, note, media, gallery, spacer) plus whole-page patterns, inserted into the editor as allow-listed CSS classes rather than a parallel block tree — so content stays the HTML string every downstream consumer already reads, and themes restyle sections through design tokens. A hover toolbar moves, duplicates and deletes a section. Any page can be set as the site home page from Settings.

  • Media that a decoupled storefront can actually use. Every upload records its width and height and generates a fixed set of WebP derivatives (400, 800, 1600 — never upscaled, largest capped so a 4000px camera original is not reproduced at 4000px), so a storefront builds a srcset instead of running its own image optimizer. The untouched original is kept and downloadable. The API publishes url_absolute on each record and media_base on the response envelope, so a storefront on a different host resolves images against the CMS rather than against itself — while url, thumb_url and images[].src stay exactly as they were for existing consumers. npm run media:backfill gives the same treatment to a library uploaded before this existed.

  • A deep health check. GET /api/health/deep (admin session or HEALTH_TOKEN) exercises rather than introspects: it encodes a test image with sharp, writes and removes a probe file in the uploads directory, counts on the real rate-limit store, and writes to the database. It also reports whether the CMS knows its own public address and which plugins were requested versus actually loaded. 503 when anything essential is broken, so a deploy script can assert it with curl -fsS.

  • A reference deployment. deploy/ ships an nginx vhost and a systemd unit with limits sized against measured traffic, plus a post-deploy checklist of the things that fail silently.

  • Roles. admin, editor, author, manager (shop staff: full catalogue, read-only orders and customers, own posts) and viewer. The admin navigation filters itself by role, so nobody sees a menu item that bounces them back.

  • Admin. Dashboard with live counts and recent-activity feed, posts list with pagination + search + filters, post editor (rich text + media picker), categories CRUD, users CRUD, media library with disk-backed uploads, profile + password change, backup/restore tools page.

  • Public site. Home (stock welcome page, or any CMS page you designate), blog index with category filter + pagination, /blog/[slug], CMS pages at /[slug], about, contact (working contact form + newsletter signup), 404, 500, /sitemap.xml, /rss.xml, /robots.txt, OG image generator at /og/[slug].png. Site title/tagline/social come from Settings.

  • Commerce — part of the core, not an add-on. Products, brands, product categories, orders, customers — with server-side pricing (integer cents, never floats) and atomic stock reservation, so concurrent checkouts cannot oversell the last unit. Public, anonymous checkout is capped by operator-adjustable order limits.

  • Payments. Stripe, PayPal, and Klarna, plus bank transfer and cash on delivery. Hosted checkout only — card data never touches your server, so a self-hosted install stays outside PCI scope. Credentials come from the environment, webhooks are signature- or fetch-back-verified with no bypass, and a verified event still has to match the order’s amount before it can mark anything paid. See PAYMENTS.md.

  • Email. Console and webhook transports out of the box, plus a bundled SMTP2GO connector — activate it and set two environment variables.

  • Agent-ready. /llms.txt, a drift-tested /openapi.json, a typed client, and a 33-tool MCP server (npx astrobaas-mcp) covering content, commerce, media, and settings. Repo conventions for AI contributors live in AGENTS.md.

  • i18n, all the way to the visible page. Set SITE_LOCALES=en,de,fr and posts carry a locale, the admin gains a language picker + filter, /de/blog serves German content from the same templates, and the API takes ?locale=. Unset = single-language, with identical URLs and behaviour to before. Translations are grouped with translation_of.

    Every link in the nav, footer, post cards and breadcrumbs carries the locale, and a crawlable language switcher appears in the header — pointing at the real translation where one exists, and at that language’s home page where it does not, marked so the reader can tell which. The stock marketing home page’s own buttons are not converted yet.

    For content records — posts and Pages — the canonical URL follows the record’s language rather than the URL the reader used, so canonical, hreflang, sitemap and RSS name the same address by construction. Static routes such as /about canonicalise to the URL they were served at.

  • Themes with template overrides. Switch the active theme and customize colors, typography, and arbitrary CSS in the admin (served from /theme.css, so it applies on first paint under the strict CSP). A theme can also replace templatesHeader, Footer, PostCard, PostArticle, Sidebar — and inherits the built-in default for every slot it doesn’t override, so themes stay forward-compatible. Bundled editorial theme demonstrates it; see THEME_DEVELOPMENT.md.

  • Plugins. Filter/action hook system (8 hooks) with persisted activation, error isolation, and a typed API from astrobaas/core; bundled examples in src/plugins/. Plugins are imported at build time — see below and PLUGIN_DEVELOPMENT.md.

  • Animated/3D frontends. Effects (parallax, scroll-float, WebGL) live in the Astro frontend, fed by CMS content — @astrojs/react is wired for React Three Fiber islands and the CSP is env-configurable for CDN/asset loading. Working reference at /showcase; see FRONTEND_EFFECTS.md.

  • Headless / BaaS. Bearer API-key auth (CSRF-exempt) + configurable CORS for cross-origin frontends; a typed client SDK (astrobaas/client); the astrobaas CLI (init/secret/setup); an MCP server (astrobaas-mcp) exposing content tools to AI agents; signed outbound webhooks; and an agent-readable contract at /llms.txt + /openapi.json.

  • Pluggable storage. lowdb JSON for zero-config local dev; SQLite/libSQL (Turso) for durable multi-host deploys — selected by DATABASE_URL. See STORAGE.md.

  • Ops. /healthz for orchestrators; structured(-ish) logging; one-shot Docker container; CI runs typecheck + build + unit + smoke on every PR.

Extensibility model

Two tiers. Data installs at runtime; code is compiled in.

The dividing line is not plugins-versus-themes — both come in both tiers. It is whether an extension needs to run or merely describe.

Declarative (manifest)Compiled-in (code)
InstallPaste JSON in the admin, or POST /api/plugins/install / /api/themes/install. No rebuild.Add a folder + one import, redeploy.
A plugin canRegister content types, contribute <meta>/<link> tags, subscribe webhooks, add editor sections with scoped CSS.Anything: hooks, filters, server logic, its own API routes.
A theme canShip design tokens, a stylesheet, and section patterns.All of that, plus replace the Header/Footer/Sidebar/PostCard/PostArticle templates.
Runs code?Never.Yes — it is your code.

Astro compiles the server at build time, so anything that must execute on the server, or render markup as a component, has to be present at build time. That is a property of the runtime, not a policy choice. What is a choice is that everything expressible as data was made installable without a rebuild.

Sections are why the declarative theme tier exists at all: an editor block is an allow-listed CSS class rather than a component, so a theme’s contribution — tokens, a stylesheet, ready-made layouts — is data all the way down.

What you get for the split:

  • No arbitrary-code-upload surface. There is no “upload a zip and run it as root” path, the single most-exploited vector in classic CMS ecosystems. A manifest cannot execute anything; compiled-in code is code you reviewed.
  • Type safety end to end for the compiled tier: a bad hook signature fails the build, not production.
  • A strict CSP stays possible. Scripts are hashed at build time with no 'unsafe-inline' — achievable because executable extensions are known at build time. Plugin and theme CSS are served from /plugins.css and /theme.css for the same reason.
  • Manifests are validated, not trusted. A plugin’s CSS must be scoped to its own ab-x-<pluginId>- namespace; a theme’s tokens must be values the vocabulary defines, so a stored value can select CSS but never be CSS; and any section or pattern whose markup the sanitizer would rewrite is refused at install, with the sanitized output shown beside what was submitted.

What you still give up: a plugin that needs server logic, or a theme that needs to change page structure, is a code extension and needs a deploy.

Where the paid boundary sits

Commerce is core and stays core. It is not a plugin and is not sold separately — it is woven through the storage contract every driver implements (25 references in the Storage interface alone), and splitting it would buy an abstraction nobody needs.

The paid boundary is one level up, at the vertical: the optometry / eyewear module — prescription capture and validation, dioptre and PD handling, lens configuration. That is genuinely separable, worth more to fewer people, and is the shape of something worth licensing. It is decided but not built; see OPTICAL_MODULE.md, which also records the constraints any licensing design must not violate.

Plugin dependencies already exist to support this (dependencies: { "commerce": "^2.0.0" } in a manifest, with activation refused until every dependency is installed, active and in range — and a dependency that cannot be deactivated, uninstalled or upgraded out of range while a dependent is running). See PLUGIN_DEVELOPMENT.md.

One thing to be clear about. A declarative theme cannot run code, read your data, or make network requests — but it does decide how every page looks. Install themes from sources you would accept a stylesheet from.

Use it as a backend (headless)

Full walkthrough: INTEGRATION.md (keys, CORS, SDK, webhooks, MCP, CLI).

Mint an API key in the admin (or POST /api/keys as admin), then from any origin:

import { createClient } from 'astrobaas/client';

const baas = createClient('https://cms.example.com', { apiKey: process.env.ASTROBAAS_KEY });

const posts = await baas.posts.list({ status: 'published', limit: 10 });
const post  = await baas.posts.get('hello-world');
await baas.posts.create({ title: 'From my frontend', status: 'draft' }); // needs author+ key
await baas.content('product').create({ name: 'Widget', price: 9 });       // custom types
  • Bearer auth + CORS. Send Authorization: Bearer <key>; bearer requests are CSRF-exempt. Start the server with CORS_ORIGINS listing your frontend’s origin.
  • Scaffold securely. From inside the clone, npx astrobaas init writes a .env with a CSPRNG AUTH_SECRET (the #1 production foot-gun, removed). npx astrobaas secret prints one for piping.
  • AI agents. Run the MCP server so an agent operates the backend as tools:
    // Claude Desktop config. An absolute path, not `npx astrobaas-mcp`: this
    // config is read from your home directory, not from the clone, and the
    // package is not on npm yet — so npx would 404 there.
    { "mcpServers": { "astrobaas": {
        "command": "node", "args": ["/absolute/path/to/AstroCMS_v1/bin/astrobaas-mcp.mjs"],
        "env": { "ASTROBAAS_URL": "https://cms.example.com", "ASTROBAAS_KEY": "abk_..." } } } }
    Agents can also just read /llms.txt (plain-text brief) or /openapi.json.
  • Webhooks. POST /api/webhooks { url, events } to get notified on post.* / content.* events. Each delivery is signed: X-AstroBaaS-Signature: sha256=HMAC-SHA256(secret, rawBody).

What’s not done yet

  • Runtime installation is data-only. Plugins and themes both install from a JSON manifest at runtime — no rebuild — but a manifest carries data, not code: settings, content types, webhooks, design tokens, stylesheets, editor sections and patterns. Anything that needs to run JavaScript on the server, or replace a page template, is still a compiled-in extension and needs a rebuild. See Extensibility model.
  • No comments or custom taxonomies. Post revisions and autosave landed, but editorial depth is still thinner than WordPress.
  • No embeds. iframe is not on the sanitizer’s allow-list, so a pasted YouTube or Maps embed is removed on save. That is what keeps the strict CSP intact; supporting embeds means shipping a consent-gated facade, which is not built yet.
  • No PDF export, no ESP sync, no custom roles. See docs/WP-PARITY-ROADMAP.md for what is measured and what is left.
  • Payment providers are not sandbox-verified. The protocol logic and every security control are implemented and adversarially tested offline, and the full webhook → capture chain runs live in the smoke suite; what has not been done is a round trip against Stripe/PayPal/Klarna’s live sandboxes, which needs real credentials. Run yours there before taking real money.
  • No independent security audit. The code has been through an adversarial self-review with regression tests (see SECURITY.md), but no external pentest, fuzzing, or bug bounty. Treat it as alpha.
  • No fuzz/load tests, no a11y CI. Coverage is unit + an HTTP smoke suite over every screen/API on all three storage drivers + a Playwright browser suite (npm test, npm run e2e).
  • Multi-host needs libSQL. With a libsql:// (Turso) DATABASE_URL the data survives replicas; the lowdb default does not (ephemeral FS / write races). For real multi-writer traffic use the relational driver (DATABASE_DRIVER=relational); the default doc-blob mode is last-write-wins. Rate limiting shares counters across replicas only when you set RATE_LIMIT_STORE=libsql. See STORAGE.md.

Configuration

Required environment variables

VariablePurposeDefault
AUTH_SECRETHMAC key for session cookies. ≥ 16 chars.A dev-only insecure value (warns)
NODE_ENVWhen production, missing AUTH_SECRET is fatal.unset

Optional

VariablePurpose
DATABASE_URLSelects the storage engine: unset → lowdb JSON; file:… → local SQLite; libsql://… → Turso/remote.
DATABASE_AUTH_TOKENAuth token for a remote libsql:// database.
CORS_ORIGINSSpace/comma-separated origins (or *) allowed to call /api/* cross-origin. Credentials are never allowed (token auth, CSRF-safe).
RATE_LIMIT_PER_MINDefault 60. Increase if you’re behind a CDN.
RATE_LIMIT_STORElibsql (with a libSQL DATABASE_URL) shares rate-limit counters across replicas; default is in-process.
TRUST_PROXY1 to honour X-Forwarded-For (only behind a proxy you control).
COOKIE_SECUREForce the cookie Secure flag on/off (1/0); defaults to on in prod.
CSP_*Per-directive CSP allowlists for CDN/3D assets (see .env.example).
METRICS_ENABLED1 exposes Prometheus counters at /metrics (off by default).
LOG_REQUESTS1 emits one structured JSON log line per request.
HEALTH_TOKEN32+ random characters. Lets a deploy script read GET /api/health/deep without a session (Authorization: Bearer …). Unset means admin session only, and everyone else gets a 404.
MEDIA_ORIGINAL_EXIFkeep retains the uploaded file byte for byte. By default the EXIF block is stripped losslessly (pixels untouched) — it carries GPS and a device serial, and the original is fetchable at a guessable URL.
MEDIA_KEEP_ORIGINALS0 discards the uploaded file once its derivatives exist. On by default: the original is the shop’s master copy, and it is also the larger line item on disk (≈3 MB for a phone photo, against 80–480 KB for all of its derivatives together).
SANITIZE_STRICT_CLASSES1 drops the ab-x-* plugin section namespace from the content allow-list, making stored classes a closed set. Off by default: turning it on strips plugin-contributed sections out of content on save, which is data loss for anyone using one.

Where data lives

  • Database: by default db.json (gitignored, seeded with built-in starter content on first boot; delete to reset; override with DB_PATH). Set DATABASE_URL to use SQLite/libSQL instead — see STORAGE.md.
  • Uploads: public/uploads/<yyyy>/<mm>/<sha1-prefix>.<ext>. Gitignored. Override the directory with UPLOADS_DIR.

Schema migrations & upgrades

The database records a schema version. On startup AstroBaaS runs any pending migrations automatically, in order, across whichever storage driver is active — so upgrading in place is just deploying the new build and restarting. A fresh install is stamped at the current version (nothing to migrate). GET /readyz reports schema_version / expected_schema_version, and returns 503 if the database is stuck below what the build expects (e.g. a failed migration), so an orchestrator holds traffic until the upgrade completes.

Migrations live in src/lib/migrations.ts. Because every driver stores entities as JSON documents, one migration (written against the storage-agnostic Storage interface) upgrades all three. To add one, append an entry with the next integer version and an idempotent up(); never edit or renumber a shipped migration. See CONTRIBUTING for the checklist.

Architecture

Browser / Frontend / AI agent
   │  cookie+CSRF (admin UI)  ·  Bearer API key (headless)  ·  MCP (agent)

Astro SSR (Node adapter)
   ├── middleware.ts       (cookie+bearer auth, CSRF, CORS, rate limit, security headers)
   ├── src/pages/api/*     (REST endpoints, return {success, data|error})
   ├── /llms.txt /openapi.json   (agent-readable contract)
   └── src/pages/*.astro   (admin + public UI)
            │                        └─► webhooks.ts ──► signed POST to subscribers

   LocalDB ──► Storage adapter:  lowdb (db.json)  |  libSQL/SQLite (DATABASE_URL)

Theme/plugin/SDK authors import the stable surface from astrobaas/core and astrobaas/client (never src/lib/*); see STABILITY.md.

See PLAN.md for the forward plan (i18n, theming, editorial depth, runtime plugins, ecosystem), ROADMAP.md for the phased log and CHANGELOG.md for what’s new.

Security

This is pre-alpha software. Read SECURITY.md before exposing it to the internet. The TL;DR:

  • Change the seed admin password (the dashboard banner reminds you).
  • Generate a strong AUTH_SECRET (openssl rand -hex 32).
  • Put it behind HTTPS via a reverse proxy. In production (NODE_ENV=production) session/CSRF cookies are Secure, so HTTPS is required.
  • Persist data across redeploys — docker compose up --build mounts a volume at /app/data for db.json + uploads (or mount your own and back it up off-host).

License

GPL-3.0-or-later — Copyright (C) 2025 Theodor Dimitriou. See LICENSE.

The open-core question — a free community edition alongside paid modules, the way Magento and Adobe Commerce are split — is analysed in LICENSING.md. It is available because the copyright is held by one person, and it is preserved by the contributor agreement below.

Contributing

See CONTRIBUTING.md. Bug reports use the template at .github/ISSUE_TEMPLATE/bug_report.md. Security issues: please follow SECURITY.md, not the issue tracker.

Pull requests are gated on a Contributor License Agreement — a bot asks once, you reply with one sentence, and later PRs are not gated again. You keep your copyright; the grant lets the maintainer license your work commercially as well as under the GPL, which is what funds the project, and §4.1 commits to keeping every merged contribution in the open-source edition. The reasoning, and the honest case for declining to sign, are in CLA.md § Why this exists. Declining is fine: open an issue instead and the change will be implemented independently, with credit.