Repository navigation
Architecture
How AstroBaaS is put together: the single Node process, the path a request
takes through the middleware, the storage drivers, media handling, background
work, extensions, health checks and shutdown. It is for operators who want to
understand what they are running and for contributors finding their way
around src/.
The short design notes in the repository are architecture.md and PLATFORM.md.
AstroBaaS is one Astro application, rendered on the server by the
@astrojs/node adapter in standalone mode (output: 'server' in
astro.config.ts). npm run build produces dist/, and
npm start runs node ./dist/server/entry.mjs, which listens on PORT
(4321 in the Docker image). That one process serves three things:
-
the admin under
/admin, the screens operators work in; -
the JSON API under
/api, for headless frontends, scripts and agents; - public pages: the blog, pages, the catalogue, feeds, the sitemap and OG images, for installs that use the built-in front end.
There is no build step between writing content and serving it. A request reads the database and renders, so publishing is a write, not a deploy. The same process also runs the scheduler for background jobs.
browser / storefront / script / agent
│
reverse proxy (TLS) nginx or Caddy
│
┌─────────────────────────────────┼──────────────────────────────────┐
│ Node process ▼ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ middleware: observe → main (src/middleware.ts) │ │
│ │ metrics · CORS · maintenance · auth · CSRF · rate limits │ │
│ └───────────────┬───────────────────────────┬───────────────┘ │
│ ▼ ▼ │
│ routes: /admin · /api · public pages /uploads/* (files) │
│ │ │ │
│ ▼ │ │
│ LocalDB (Storage interface) ── plugins, themes, content types │
│ │ │ │
│ scheduler (one leader per database) ───────┤ │
└──────────────────┼───────────────────────────┼─────────────────────┘
▼ ▼
lowdb JSON file │ libSQL file │ Turso UPLOADS_DIR, PRIVATE_UPLOADS_DIR
It is not a static site generator, not multi-tenant (one install serves one site), and not clustered by default. Those limits are deliberate; see multiple processes below.
src/middleware.ts is the single gate every request passes, composed as
sequence(observe, main). Everything that must be true of every request lives
there, so a new route cannot forget it.
observe runs outermost. It counts the request as in flight (so a
shutdown can wait for it), times it for the metrics histogram, records its
status class, and writes the request log line when enabled.
main then runs these steps in order:
| Step | What happens |
|---|---|
| Locale prefix |
/de/blog is served by the /blog route with the locale set to de. |
| CORS preflight |
OPTIONS to /api is answered from CORS_ORIGINS, before anything else, so a storefront can read even a maintenance answer. |
| Maintenance (environment) | With MAINTENANCE_MODE set, requests get a 503 page (or JSON on /api), without touching the database. The health probes, the admin and sign-in stay open. |
| Redirects | Redirect rules an operator stored are applied to public paths (never /api or /admin). |
| Session | The astrobaas_session cookie is resolved to a user, re-checked against the database. |
| API key | On /api, an Authorization: Bearer abk_… header is resolved to the key's role and scopes. Expired keys authenticate as nobody. |
| Client address | The caller's IP, from the socket or, with TRUST_PROXY=1, the right-most X-Forwarded-For entry. A key marked forward_client_ip may name the shopper's address instead. |
| Crawler policy | Public pages only: a plugin may refuse a user agent. |
| Rate limits | On /api: per key, per signed-in person, or per address, plus per-route budgets. Refusals are 429 with Retry-After. |
| Body limits | Writes without Content-Length get 411; bodies above the route's ceiling get 413, before anything is read. |
| Maintenance (scheduled) | A maintenance window an admin scheduled in settings. Signed-in staff still see the live site. |
| Commerce switch | While commerce is off, commerce API paths answer the same 404 as an unknown path. |
| Admin gate |
/admin screens are checked against the role's access list. |
| API authorisation | Scopes are enforced; writes and non-public reads need a session or key (401 otherwise); cookie-authenticated writes need a matching CSRF token (403 CSRF_FAILED). |
| Route | The Astro page or endpoint runs. |
| Decoration | Security headers, X-Frame-Options, CORS headers, RateLimit-* headers, and the CSRF cookie on HTML pages. |
API routes answer with one envelope, built by
src/lib/api-response.ts:
{ success, data, meta } or { success: false, error }. See
Headless and REST API.
Every response gets X-Content-Type-Options: nosniff,
Referrer-Policy: strict-origin-when-cross-origin, a restrictive
Permissions-Policy, and X-Frame-Options: DENY (relaxed to SAMEORIGIN for
PDFs under /uploads, so the built-in viewer can frame them).
Strict-Transport-Security is opt-in with HSTS_MAX_AGE, because sending it
from a host that is not fully HTTPS locks visitors out.
The Content-Security-Policy is Astro's hash-based CSP. Astro lists the SHA-256
hash of every script and style it bundles, so script-src is 'self' plus
hashes, with no 'unsafe-inline', and Astro's own island scripts still
run. Extra sources are added with CSP_SCRIPT_SRC, CSP_STYLE_SRC,
CSP_IMG_SRC, CSP_CONNECT_SRC, CSP_FONT_SRC, CSP_FRAME_SRC,
CSP_WORKER_SRC, CSP_CHILD_SRC, CSP_ALLOW_WASM=1 and
ANALYTICS_PROVIDERS. The hashes are computed at build time, so these are
build-time variables: set them before npm run build. See
Security and
FRONTEND_EFFECTS.md
for loading libraries from a CDN.
- CORS is an exact-match allow-list in
CORS_ORIGINS. Credentials are never allowed. Every/apianswer to an allowed origin carries the CORS headers, errors included. - CSRF is a double-submit cookie (
astrobaas_csrf) checked against theX-CSRF-Tokenheader on cookie-authenticated writes. Bearer requests are exempt. A short list of machine callers (payment-provider webhooks, the consent receipt beacon, one-click unsubscribe) is exempt because each authenticates itself another way. Cookie-lessPOSTs from an allow-listed origin to the public checkout and form endpoints are also accepted without a token.
Astro's own checkOrigin is turned off because the middleware does this job.
Counters live in memory by default. RATE_LIMIT_STORE=libsql (with a
DATABASE_URL) keeps them in the database so several processes share them.
The startup log says which is active. Budgets are in
Headless and REST API.
Routes never talk to a database driver. They call LocalDB
(src/lib/localdb.ts),
which implements the Storage interface
(src/core/storage.ts)
over one of three drivers, chosen at boot from the environment by
src/lib/storage/select-adapter.ts:
| Driver | Selected by | Shape | Good for |
|---|---|---|---|
| lowdb JSON | no DATABASE_URL (file at DB_PATH, default db.json) |
one JSON document | local development, a small single-process site |
| libSQL doc-blob |
DATABASE_URL (file: or libsql://) |
the same document in one SQLite row | a durable single process |
| relational libSQL |
DATABASE_URL + DATABASE_DRIVER=relational
|
one table per collection, one row per entity (JSON data column) |
production, several writers |
The smoke suite runs against all three in CI, so a driver that disagrees with
the others fails the build. Local file: databases are opened in WAL mode
with a 5-second busy timeout and synchronous=NORMAL. On an empty database the
schema and the first admin account are created at first boot; there is no
separate migration command. Schema migrations run at start-up under a
migrations lease, so two processes booting together do not run them twice.
The doc-blob driver rewrites the whole document on every write. Two processes writing to it at once lose each other's changes without an error, so use the relational driver whenever more than one process writes. Full details, including backups under WAL and moving between drivers, are in Backups and storage and STORAGE.md.
AstroBaaS keeps a few small caches in memory. Each one is bounded or expires, so a change made by another process shows up without a restart.
| Cache | Invalidation |
|---|---|
| The lowdb document | Keyed on the file's inode, size and modification time, so a write by any process (a CLI import, a restore) is seen. The libSQL drivers are not cached this way. |
Media base (public_site_url, site_url) |
Cleared on a local settings write; expires after 5 seconds. |
| Active theme | Expires after 5 seconds. |
| Plugin registry (active plugins, their routes, content types) | Rebuilt locally after a change; other processes notice within 15 seconds. |
| OG card images | The 100 most recent, in memory. |
| Article view counts | Buffered and written on the scheduler's next tick, and on shutdown. |
Uploaded files live on local disk in UPLOADS_DIR (default public/uploads)
and are served at /uploads/…. In a production build a route serves them,
since the adapter's static handler only knows files that existed at build
time. Files attached to public form submissions go to PRIVATE_UPLOADS_DIR
instead, which nothing serves publicly; staff download them through an
authenticated route.
Every file goes through one ingest path (src/lib/media/ingest.ts), whether it
came from the admin, the API or an importer:
- Type detection from the bytes, never from the extension or the declared type.
-
Optional virus scan through a ClamAV daemon when
MEDIA_SCANis configured. - SVGs are sanitised and stored as their sanitised form.
-
Metadata such as EXIF is stripped from images (
MEDIA_ORIGINAL_EXIF=keepkeeps it on the original). - Derivatives: raster images are re-encoded with sharp into WebP at 400, 800 and 1600 pixels wide, once at upload rather than per request.
- Content-addressed write: filenames come from a hash of the content.
The library accepts files up to 10 MB, and video up to 100 MB
(MEDIA_MAX_VIDEO_MB). The request body limits are derived from those
numbers, and the shipped nginx and Caddy configurations are tested against
them.
API responses publish meta.media_base, so a frontend on another host can
turn stored /uploads/… paths into absolute URLs. Set the public_site_url
setting in production. See
Headless and REST API
and Content and media.
Uploads are local disk. Several processes on one host can share the directory; processes on different hosts need a shared filesystem.
Post content is stored as raw HTML. It is sanitised against an allow-list on
save (src/lib/sanitize.ts) and rendered through plugin filters on read, so
content is the source of truth and content_rendered is derived from it.
Because content is sanitised, <script> and <canvas> cannot live inside a
post; interactive effects belong in the theme or frontend.
A scheduler (src/lib/scheduler.ts) runs inside the same process, once at
start-up and then every SCHEDULER_INTERVAL_MS (default 60 seconds). A sweep:
- publishes scheduled posts whose time has come;
- writes buffered view counts;
- expires payment holds and cancels abandoned unpaid orders;
- sends back-in-stock notices and newsletter batches;
- sends recovery reminders;
- sweeps orphaned private uploads;
- pushes the off-site backup when one is due.
With more than one process on a database, only the one holding the
scheduler lease sweeps. The others stand by and take over within about three
intervals if the leader dies, or at the next tick if it shut down cleanly. On
the libSQL drivers the lease is a row in a leases table; on lowdb it is a
file beside the database. SCHEDULER_DISABLED=1 turns the scheduler off.
SCHEDULER_LEASE=0 makes every process sweep, which is only safe with a single
process. The operations screen and GET /api/operations show which process
leads.
Outbound webhooks are also delivered in the background of the process that fired them, with retries on in-process timers. See Webhooks.
Both load at boot, into the same process, with no sandbox. They are trusted code, as appropriate for a self-hosted install.
Plugins (src/plugins/index.ts) come in two tiers:
-
Code plugins: the bundled ones in
src/plugins/, plus external packages named inASTROBAAS_PLUGINS. They can register filters and actions on named hooks, serve their own API routes (through the/api/[...pluginRoute]catch-all) and admin screens, contribute content types and payment providers, and keep data in a namespaced store with their own migrations. - Declarative plugins: JSON manifests installed at runtime from the admin. They are stored in the database and compiled to the same plugin shape. No code is executed.
Activation state is stored in the database. ASTROBAAS_PLUGINS_ACTIVATE names
plugins to switch on at boot. What loaded, what failed and what is active is
reported by the deep health check.
Themes (src/themes/) are the bundled ones plus external packages named in
ASTROBAAS_THEMES. A theme replaces named template slots and design tokens
and inherits the rest, so a new slot never breaks an existing theme. The active
theme is chosen in the admin.
Custom content types registered by plugins or defined in the admin get the
generic /api/content/<type> CRUD with the same auth and validation as the
built-in types. See Plugins, Themes and
Custom content types.
| Endpoint | Access | Answers |
|---|---|---|
GET /healthz |
public | Liveness. 200 when the process is up and storage answers a read, otherwise 503. Probes are cached for 2 seconds, so a flood cannot load the database. |
GET /readyz |
public | Readiness. 503 while the process is shutting down, when storage is unreachable, or when the database schema is older than this build expects. Point load balancers here. |
GET /api/health/deep |
admin session, or Authorization: Bearer $HEALTH_TOKEN
|
Exercises the things that break quietly and reports each as ok, warn or fail. 503 if anything essential fails, so curl -fsS works in a deploy script. |
GET /metrics |
public when enabled | Prometheus text format. Off (404) unless METRICS_ENABLED=1. |
The deep check runs these checks: health_token, image_pipeline (sharp
actually encodes an image), uploads_writable, public_site_url,
cors_origins, database (including a test write; add ?write=0 for
frequent polling), rate_limit_store, plugins, media_records,
email_channel, payment_return_urls and offsite_backup. Without a
HEALTH_TOKEN, only an admin session can read it; anyone else gets 404.
/metrics exposes request counts by status class, 5xx and 429 counters, a
latency histogram, in-flight requests, a draining flag and uptime. It carries
no content or secrets, but keep it behind your network policy. Per-request
logging is controlled by LOG_REQUESTS.
On the first SIGTERM or SIGINT the process:
- marks itself draining, so
/readyzanswers503and a balancer stops sending traffic; - stops the scheduler, so no new sweep starts;
- waits for in-flight requests to finish, while still serving new ones;
- waits for a running sweep to reach its next safe point;
- writes buffered view counts;
- releases the scheduler lease, so another process takes over at once;
- exits
0with one log line saying what it did.
The whole budget is SHUTDOWN_TIMEOUT_MS (default 25 seconds). It must be
shorter than your supervisor's stop timeout: the systemd unit in deploy/
allows 30 seconds and docker-compose.yml sets a 30-second grace period. A
second signal exits immediately. GRACEFUL_SHUTDOWN=0 disables all of this.
The tested shape is one process. More is possible, with these rules:
- Use
DATABASE_DRIVER=relationaland a database every process reaches: afile:URL for processes on one host,libsql://across hosts. - Set
RATE_LIMIT_STORE=libsql, or each process keeps its own counters and the effective limit multiplies. - Uploads are local disk. Processes on different hosts need shared storage for
UPLOADS_DIRandPRIVATE_UPLOADS_DIR. - Only one process runs the scheduler (the lease holder). Never set
SCHEDULER_LEASE=0with more than one process. - Settings, redirects and plugin switches changed through one process reach the others within 15 seconds.
- Webhook retries live in the memory of the process that fired the event.
See Deployment and deploy/README.md.
| Path | Contents |
|---|---|
src/middleware.ts |
The request pipeline |
src/pages/api/ |
REST endpoints |
src/pages/admin/ |
Admin screens |
src/pages/ (rest) |
Public pages, feeds, /healthz, /readyz, /metrics, /openapi.json, /llms.txt
|
src/core/ |
The public contracts: models, Storage, content types, theme slots, manifests (astrobaas/core) |
src/lib/ |
Internals: storage drivers, auth, media, scheduler, webhooks, payments, email |
src/client/ |
The typed client (astrobaas/client) |
src/plugins/, src/themes/
|
Bundled plugins and themes, and their registries |
Anything under src/lib/ is internal and may change between releases.
Plugins and themes should import from astrobaas/core only.
- Headless and REST API
- Security: the threat model behind the pipeline
- Backups and storage: drivers, backups and restores in depth
- Deployment: systemd, Docker, reverse proxies
- Staging and maintenance: maintenance mode and staging copies
- Plugins and Themes
- architecture.md, PLATFORM.md, STORAGE.md, FRONTEND_EFFECTS.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