# 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.