# Changelog
All notable changes to AstroBaaS will be documented here. Format inspired by
[Keep a Changelog](https://keepachangelog.com/); we don't follow strict
semver yet because the API surface is pre-alpha.
## [Unreleased]
Nothing yet.
## [0.1.2] — 2026-09-23
### Uploads are always served with their security headers
`astro build` copied `public/uploads` — the default upload directory — into
`dist/client`, and the node adapter serves that copy before the app runs. On a
built server, every file uploaded before the build therefore came back with no
`nosniff`, no sandbox CSP on SVG and no `X-Frame-Options`, while newer uploads
went through the app's `/uploads` route and got all three. It also shipped the
working tree's uploads inside a release. `src/lib/strip-built-uploads.ts` now
removes them after every build (`.gitkeep` stays), so the route serves them all:
an SVG that returned bare headers from a build now returns
`content-security-policy: default-src 'none'`, `nosniff` and `DENY`.
`tests/build-uploads.test.mjs`.
### `setup` and `reset-password` work with piped input
Both read their answers with `readline.question()`, which fails once piped stdin
reaches end-of-file: `reset-password` died with `ERR_USE_AFTER_CLOSE` having
changed nothing, and `setup` exited **0 without writing an admin**. They now
share `scripts/lib/prompt.mjs`, which queues lines as they arrive and turns
input that ends early into a clear error that changes nothing. Typed input in a
terminal behaves as before. `tests/cli-prompts.test.mjs` pipes both scripts:
10 of its 12 checks fail on the old scripts.
### `npm run dev` no longer fails its dependency scan
Vite finds `` closed the JSON-LD
element and the rest parsed as markup — in `
`, on every public post
page, authored by the lowest role that can write a post.
- **HSTS** available via `HSTS_MAX_AGE` (opt-in on purpose), and
**`frame-ancestors`** added to the CSP — it had been omitted on the mistaken
belief that Astro emitted a `` CSP, when `output: 'server'` means it is
a real response header.
### Data integrity
- **Media deletes are refcounted.** Filenames are content-addressed, so deleting
one record unlinked a file another record still pointed at. It was also a
privilege escalation: an `author` could destroy an admin's file by
re-uploading it and deleting their own copy.
- **A WebP upload no longer deletes its own stored file.** For a WebP input the
raw and derived paths are identical, so the "remove the original" unlink
removed the file the record points at.
- **The lowdb read cache** (`withReadCache`). lowdb re-parsed the entire JSON
document on every getter — measured at ~159 ms per parse on a 26.5 MB database
and ~9 parses per request. Validated on inode + size + mtime so another
process's write is still seen. **17× fewer parses** on a 3.8 MB database.
- **Copy-on-read for every collection getter**, so `(await getOrders()).sort()`
can no longer reorder the stored document — which, with the cache, would have
been persisted.
- **The change feed no longer sorts its own cached array**, which had made the
1000-record ring buffer evict its *newest* entries.
- **Plugin filter output is no longer stored as post content.** `GET` returned
rendered text under the same key `PUT` writes back, so opening a post and
saving baked the plugin's output into it, cumulatively and permanently.
### Theming
- **~30 design tokens** replacing 8: semantic colours, a typography scale, and
enum-driven radius, density, shadow, container width, button and header
styles. Enum values resolve through an allow-list, so a stored value can
*select* CSS but never *be* CSS.
- **Dark mode** with a working toggle. The preference persists to a cookie the
server reads, so `data-theme` is in the first byte of HTML — no flash, and no
inline script, which the CSP would refuse.
- **Eight one-click presets**, each asserted to keep body text at WCAG AA
contrast.
- Fixed the tokens that saved successfully and changed nothing:
`backgroundColor`/`textColor` had no consumers, `fontSize` was never emitted,
and the chosen webfont was never fetched.
### SEO
- `meta_title` / `meta_description` are **rendered** — they were declared,
validated, stored, and read by nothing.
- **Article + BreadcrumbList JSON-LD**.
- The admin's **Site URL** setting now drives the sitemap, feed, `robots.txt`,
canonical tags and structured data, through one resolver.
- Fixed `PublicLayout` never forwarding a head slot, which had silently dropped
anything any public page tried to put in ``.
### Mobile
- The admin drawer scrolls, closes on backdrop tap and Escape, locks body
scroll, and returns focus. Previously nav items below the fold were
unreachable and tapping outside did nothing.
- 44px minimum touch targets on coarse pointers — row actions were ~25×20px
beside a destructive Delete.
### Commerce
- **Optical prescriptions**: per-eye lens powers captured at checkout, stored as
integer hundredths of a dioptre, validated against clinical rules (axis
required with a cylinder and refused without one, ADD positive, both eyes
required). Refused before stock is reserved.
### Repository
- **Real customer data removed from git history.** `data/import/` held 24
records of real people — names, emails, phones, addresses. Replaced with
synthetic fixtures on RFC 2606 reserved domains; history rewritten and
verified against a fresh clone.
- **CI restored.** It had not executed a test since PR #3 — `npm ci` died at
install on a lockfile that no longer satisfied `fdir`'s `picomatch` range.
- `UPGRADE.md` added, documenting every behaviour change with remediation.
### Extensibility, i18n, and editorial (Tracks A–E)
- **Declarative plugins.** Runtime-installable JSON manifests that execute
nothing — capabilities (`headTags`, `css`, `contentTypes`, `webhooks`) are
interpreted onto existing engines. Curated registry with SHA-256 verification,
`astrobaas plugin validate` (same validator as the install endpoint), and an
admin install/uninstall UI. Code plugins and themes remain build-time.
- **Theme template overrides.** Themes can replace `Header`, `Footer`,
`PostCard`, `PostArticle`, and `Sidebar`, inheriting the built-in default for
every slot they don't override (so new slots can be added without breaking
existing themes). Bundled `editorial` theme demonstrates it. Theme catalog now
comes from the code registry; the DB holds activation + customized tokens.
- **i18n.** `SITE_LOCALES` enables multilingual content: posts carry `locale` and
`translation_of`, `/de/blog` serves German from the same templates, the API
takes `?locale=`, and the admin gains a language picker + filter. Unset =
identical behaviour and URLs to before.
- **Post revisions + autosave.** Every save snapshots the previous content,
drafts autosave without publishing, restore is undoable, retention is capped
(`REVISIONS_KEEP`), and deleting a post removes its revisions. Stored in a
dedicated collection (not `CustomEntity`) because they hold unpublished drafts.
- **Extension scaffolding.** `astrobaas plugin new`, `theme new`, and
`plugin manifest` generate complete, compiling templates; `/llms.txt` now
documents how to extend the server, not just how to call it.
- `GET /api/content//` (fetch one custom entity) — previously missing;
`GET /api/locales`; and the OpenAPI spec now covers every agent-facing route,
with a test that fails if a new route is left undocumented.
### Security & auth (hardening pass)
- **Two-factor authentication (TOTP).** Optional per-account 2FA using any
authenticator app (RFC 6238, implemented with `node:crypto` — no dependency),
confirmed against the RFC test vectors. One-time backup codes (stored hashed),
and a two-step login via a short-lived pending token so the code step never
re-asks for the password. Manage it in Profile; `POST /api/2fa/{setup,enable,
disable}` + SDK `auth.twoFactor.*`. TOTP secrets/backup hashes are stripped
from every API/page via `toPublicUser()`.
- **Adversarial security review.** Fixed deny-by-default for scoped API keys,
DB-revalidated admin-page gating, webhook SSRF guard, request-body size caps,
open-redirect hardening, media role/ownership checks, and backup-driver
guards. See the `security:` commit for the full list.
- **Shared multi-node rate limiting.** `RATE_LIMIT_STORE=libsql` shares API +
login + password-reset counters across replicas (atomic SQL); startup logs
the active store and warns on the silent per-process foot-gun.
- **Versioned schema migrations.** The DB records a schema version and pending
migrations run automatically at startup across all three storage drivers;
`/readyz` reports `schema_version` and holds traffic if an upgrade is behind.
- **Hash-based CSP.** The production build emits a Content-Security-Policy where
`script-src` is `'self'` + the SHA-256 hash of every bundled script (via
Astro's built-in CSP) — `'unsafe-inline'` is dropped for scripts *and* styles.
Theme tokens moved from an inline `` to an external `/theme.css`;
remaining dynamic styling uses CSSOM. CSP source allow-lists are now build-time
env knobs (`src/lib/csp-config.ts`). Config moved to `astro.config.ts`.
- Added a `CODE_OF_CONDUCT.md` (Contributor Covenant).
### Repositioned as AstroBaaS
- Rebranded from AstroCMS to **AstroBaaS** and repositioned from "CMS" to a
TypeScript-native, self-hostable backend (auth + data + API + storage) for
Astro/React/Vue frontends — admin included, not required.
Functional renames: package `astrobaas`, import path `astrobaas/core`, cookies
`astrobaas_*`, `window.__ASTROBAAS__`. (Repo URL unchanged.)
### Database / storage
- **Pluggable persistence.** `DATABASE_URL` selects the engine: unset → lowdb
JSON file (zero-config dev); `file:`/`libsql://` → SQLite or remote Turso via
libSQL (durable, deploy-portable, serverless/multi-host capable). Implemented
as a lowdb-compatible adapter so every storage method is identical across
drivers. See [STORAGE.md](./STORAGE.md).
- **Relational libSQL driver** (`DATABASE_DRIVER=relational`): a per-entity
storage engine (one `(id, data)` table per collection) giving row-level
concurrency, partial updates, and `json_extract` queryability — the
recommended engine for production multi-writer / multi-host (vs. the doc-blob
default's last-write-wins). `LocalDB` delegates to it through the same
`Storage` interface, so no route/plugin/admin code changes. All three engines
(lowdb, doc-blob, relational) run the full smoke suite in CI
(`npm run smoke` / `smoke:libsql` / `smoke:relational`).
### BaaS / headless integration
- **API-key / bearer auth.** Mint keys at `POST /api/keys` (admin; secret shown
once, SHA-256 at rest, constant-time compare). Send `Authorization: Bearer
` to authenticate cross-origin/headless callers; bearer requests are
CSRF-exempt and act with the key's role. Revoke at `DELETE /api/keys/{id}`.
- **Configurable CORS.** `CORS_ORIGINS` allow-lists origins (or `*`) for
`/api/*`. Credentials are never allowed (token auth in a header, not a cookie),
so the API stays CSRF-safe even with a wildcard origin.
- **Agent-readable contract.** `/llms.txt` (plain-text brief: base URL, auth
schemes, endpoints) and `/openapi.json` (OpenAPI 3.1 with a `bearerApiKey`
scheme) — both public, no auth.
- **Typed client SDK** (`astrobaas/client`): `createClient(url, { apiKey })` →
typed `posts` / `content(type)` / `keys` / `webhooks` / `auth`; returns
unwrapped `data`, throws `AstroBaasError(status, code)`. Isomorphic, zero-dep.
- **`astrobaas` CLI:** `init` (scaffold `.env` with a CSPRNG `AUTH_SECRET`),
`secret`, `setup`. Zero-dep, runs under `npx`.
- **MCP server** (`astrobaas-mcp`): a zero-dependency stdio JSON-RPC server so AI
agents can operate the backend. Tools cover the full CRUD surface (`whoami`;
posts list/get/create/update/delete; content list/create/update/delete), and
published posts are exposed as **resources** (`astrobaas://post/`). It
probes the configured key at startup and logs the resolved role to stderr.
- **Outbound webhooks.** Register URLs at `POST /api/webhooks` (admin; signing
secret returned once) to receive signed POSTs on `post.*` / `content.*`
lifecycle events. Signature: `X-AstroBaaS-Signature: sha256=HMAC-SHA256(secret,
rawBody)`. `GET`/`DELETE /api/webhooks[/{id}]` to manage. Fire-and-forget,
never blocks the triggering request.
- **Webhook delivery durability.** Failed deliveries retry with backoff
(`WEBHOOK_RETRY_DELAYS_MS`) and every attempt is recorded in a persisted
delivery log: `GET /api/webhooks/deliveries` (admin) and manual re-send at
`POST /api/webhooks/deliveries/{id}/redeliver`. The SDK adds
`webhooks.deliveries()/redeliver()` and a receiver-side
`verifyWebhookSignature()` (WebCrypto, constant-time).
- `/api/auth/me` is now auth-scheme-aware: returns a `type:'user'` principal for
cookie sessions and `type:'apikey'` for bearer keys.
- **List pagination.** `/api/posts` and `/api/content/{type}` accept
`?limit=&offset=&page=` and return `meta: { total, count, limit, offset, page,
hasMore }`. The SDK adds `posts.page()` / `content(type).page()` returning a
typed `Page`, plus `listAll()` auto-paginators.
- **SDK resilience.** `createClient` accepts `timeoutMs` (abort), `retries` +
`retryBackoffMs` (exponential retry on 429/5xx/network, honoring `Retry-After`),
and per-call `signal`.
- **Pluggable rate-limiter.** `RATE_LIMIT_STORE=libsql` shares rate-limit
counters across replicas via an atomic libSQL windowed counter (correct behind
a load balancer); the in-process store stays the default. Both the API limit
and the login throttle route through it; it fails open on a store error.
- **Security audit log.** Records logins (success/failed/throttled), API-key
lifecycle (create/revoke/rotate), webhook create/delete, role/status changes,
and password resets — with actor + client IP, never secrets. Admin
`GET /api/audit?action=&limit=`, SDK `audit.list()`, and a `recordAudit()`
helper exported from `astrobaas/core` for plugins/custom routes.
- **Admin screens** for the headless features (previously API-only):
`/admin/api-keys` (mint with role/scopes/expiry, copy-once secret, rotate,
revoke), `/admin/webhooks` (register, delete, delivery log + redeliver), and a
read-only `/admin/audit`. New sidebar nav entries.
- **Publishable package.** `npm run build:pkg` (esbuild bundle +
`tsc --emitDeclarationOnly`) emits self-contained `.js` + `.d.ts` for
`astrobaas/core`, `/client`, and `/plugins` into `pkg/`; the published
`exports` point there (in-repo dev still resolves TS source via tsconfig
`paths`). `files` allowlist + `prepublishOnly` make it `npm publish`-ready, and
`npm run test:pkg` imports the BUILT artifacts in plain Node ESM (in CI). The
client bundle is dependency-free.
### Platform / extensibility
- **Public `astrobaas/core` API** — a stable barrel (mapped via package `exports`
+ tsconfig paths) re-exporting domain models, the `Storage` interface,
`PluginManager`/`PLUGIN_HOOKS`, `definePlugin`/`defineTheme`, custom
content-type helpers, `sanitizeHtml`, `validate`, `ApiResponseBuilder`, and the
API client. Themes/plugins import from this, never internal paths. See
[STABILITY.md](./STABILITY.md) and [PLATFORM.md](./PLATFORM.md).
- Domain models extracted to `src/core/models.ts` (storage-independent); the
`Storage` contract (`src/core/storage.ts`) is implemented by LocalDB with a
compile-time conformance check.
- Hook catalog expanded with `before_post_save` and `head_tags` (both fired +
tested); `after_post_save` now also fires on update.
- **Custom content types** via `registerContentType()` + generic, schema-
validated CRUD at `/api/content/`; example `product-catalog` plugin.
- `apiClient` CSRF source is configurable (`configureApiClient`).
### Added
- **Scheduled-post worker.** An in-process interval sweep auto-publishes
`status:'scheduled'` posts once their `publish_date` passes (firing a
`post.updated` webhook). Tune with `SCHEDULER_INTERVAL_MS`, disable with
`SCHEDULER_DISABLED`.
- **Observability.** `/readyz` readiness probe (200 only when storage is
reachable, distinct from `/healthz` liveness); `/metrics` Prometheus counters
(requests by status class, 5xx errors, uptime) behind `METRICS_ENABLED`; opt-in
structured JSON request logging (`LOG_REQUESTS=1`) via a `sequence()` wrapper;
and a `reportError()` shim exported from `astrobaas/core`.
- `/healthz` endpoint for container orchestration.
- `src/pages/500.astro` error page.
- `scripts/reset-password.mjs` — CLI to reset an admin's password without
database access.
- `/admin/tools` page with backup export + restore import.
- `/api/backup/export` (zips `db.json` + uploads) and `/api/backup/import`
endpoints.
- `scripts/import-md.mjs` — import a directory of `.md` files (frontmatter
parsed for title/slug/status/tags).
- `scripts/import-wp.mjs` — import a WordPress WXR export.
- Image optimization on upload — Sharp pipeline generates `.webp` +
thumbnail variants.
- `/og/[slug].png` — auto-generated Open Graph images for every published
post.
- `SECURITY.md`, `CHANGELOG.md`, GitHub issue templates.
- One-click deploy buttons for Render + Railway in the README.
- Screenshots in the README.
### Changed
- README rewritten with a clearer pitch and a "Why AstroBaaS" section.
- Settings page: removed tabs that didn't persist (SMTP, Custom CSS/JS,
allowed file types). Only General + Reading actually save now.
- Soft-delete: `/admin/posts` adds a "Trash" filter via the existing
`trashed` status.
- Data layer serializes reads/writes through a mutex to prevent concurrent
read-modify-write data loss (single-node).
- Database path and uploads dir are configurable via `DB_PATH` / `UPLOADS_DIR`;
the Docker image stores both under a persistent `/app/data` volume.
- `astro.config.mjs` `site` is read from `SITE_URL` (was a placeholder URL),
and `RATE_LIMIT_PER_MIN` now actually applies.
### Security
- **Post access control:** `PUT /api/posts/update` and `DELETE /api/posts/delete`
enforce ownership/role — editors/admins may modify any post, authors only
their own, viewers none (previously any logged-in user could edit/delete any
post).
- **Unguessable IDs:** entity IDs are now `crypto.randomUUID()` instead of
`Date.now()+Math.random()`.
- **No draft/trashed leakage:** `GET /api/posts` and `/api/posts/[slug]` return
only published content to anonymous callers; `/api/content/changes` strips
entity snapshots for unauthenticated pollers.
- **Upload hardening:** uploads are validated by magic bytes (not the client
Content-Type or filename); SVG is rejected; allow-list is
PNG/JPEG/GIF/WebP/PDF/text. A `/uploads/[...path]` route serves them with
`X-Content-Type-Options: nosniff` (and works in the standalone build).
- **RBAC:** category/settings/theme writes and the user directory now require
admin (or editor) roles; the last active admin can't be deleted, demoted, or
deactivated.
- **Cookies:** session + CSRF cookies are `Secure` in production (override with
`COOKIE_SECURE`).
- **Proxy spoofing:** `X-Forwarded-For`/`X-Real-IP` are trusted only when
`TRUST_PROXY=1`; otherwise the socket address is used for rate limiting.
- **Backup restore** sanitizes imported post HTML.
- HTML sanitizer hardened against entity/whitespace-encoded `javascript:` URLs
and dangerous container tags; `data:` URLs limited to raster images.
### Fixed
- Docker now declares a `/app/data` volume + `HEALTHCHECK`; `db.json` and
uploads no longer reset on redeploy.
### Removed
- Stale planning docs (`security-assesment.md`, `nextsteps.md`, `AUDIT.md`) and
dead Supabase env typings — they described a pre-LowDB, no-auth codebase.
## [0.0.1] — internal pre-alpha (not released)
The state before this changelog existed. Roughly:
- Astro 5 SSR with the Node standalone adapter.
- LowDB-backed CRUD for posts, categories, users, media, settings.
- Session auth (PBKDF2 + signed cookies), CSRF, security headers,
per-IP rate limit.
- Public blog with `/sitemap.xml`, `/rss.xml`, `/robots.txt`.
- HTML sanitization on post save.
The phased plan that produced this state is kept outside this repository.