AstroBaaS

Run it

Upgrading

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

Behaviour changes that an existing deployment needs to know about, newest first. Everything here was verified against a running instance, not inferred from the diff.

If you run a headless storefront against AstroBaaS, read §“Headless storefronts” first — it is the short version.


Headless storefronts — the short version

Everything a storefront needs to render is unchanged and still anonymous:

WhatStatus
Image files under /uploads/**public, 200 — unchanged
GET /api/productspublic, 200 — unchanged
GET /api/postspublic, 200 — unchanged
GET /api/posts/{slug}public, 200 — unchanged
GET /api/categories/getpublic, 200 — unchanged
POST /api/orders, /api/orders/quoteunchanged
GET /rss.xml, /sitemap.xmlunchanged

One thing changed for API keys: GET /api/media/get is no longer public, so a key now needs a media:read scope. See U-2.

One field changed shape: content on a post is now the RAW stored HTML; the plugin-rendered version moved to content_rendered. See U-1.

One field was added, and it is opt-in: posts gained kind to distinguish articles from standalone pages. GET /api/posts still returns articles only, so your response is byte-identical until you ask for pages. See U-11.

Custom content types are now private by default. If your storefront reads GET /api/content/<type> anonymously, the plugin that registers that type must declare visibility: 'public' or the read starts returning 404. See U-12.


U-14 — the optical module now ships separately (ACTION REQUIRED for eyewear shops)

Affects: installs selling prescription eyewear. No one else.

What changed. src/plugins/optical/ has left this repository. The eyewear vertical is a separate private package, @astrobaas/optical, because a file committed to a GPL-3.0 repository is GPL-licensed permanently and could not then be licensed commercially.

If you sell prescription eyewear, a deploy of the core alone will stop validating prescriptions. The order still completes — that is the core’s documented behaviour without a vertical — so a lens sells with nothing to grind and no customer-facing error appears.

The server reports it on every boot:

[astrobaas] plugin "optical" is ACTIVE in the database but no implementation is
loaded. Anything it provides is silently not happening. The optical module ships
separately: install @astrobaas/optical and set ASTROBAAS_PLUGINS.

To restore it, install the package and name it:

npm install @astrobaas/optical
ASTROBAAS_PLUGINS=@astrobaas/optical

Restart. The plugin’s stored record is already active, so it resumes where it left off. Verify with curl -s -o /dev/null -w '%{http_code}' <site>/api/commerce/prescription-schema — 200 means loaded, 404 means not.

Not affected: every install that does not sell eyewear. And no stored data changes: prescriptions frozen onto past order lines are untouched and still render in the admin, with or without the module. Prescription and summarisePrescription() moved INTO src/core/models.ts precisely so that stays true.

New for everyone: ASTROBAAS_PLUGINS loads compiled plugins from outside this repository — paid modules and per-client integrations — without editing BUNDLED_PLUGINS. See src/plugins/external.ts.


U-13 — optical features moved into a module; existing optical shops keep working

Affects: installs selling prescription eyewear, and anything reading /api/commerce/prescription-schema.

What changed. Prescription validation moved out of core checkout into a bundled plugin, optical (src/plugins/optical/). Core now exposes one generic hook, PLUGIN_HOOKS.ORDER_LINE_EXTRAS, and the plugin is what makes the answer optical. Frame geometry, face measurement and fit arrived in the same module.

You do not need to do anything. Migration v11 switches the module on for any install with evidence it was already selling optical goods — a product flagged requires_prescription, or a historical order line carrying a prescription. Verified on lowdb, libSQL doc-blob and relational by booting a real pre-upgrade database and confirming an Rx lens still cannot be bought without a prescription.

A general shop is left alone: the module stays inactive, because turning an eyewear vertical on for a shop that sells shoes would be the migration inventing a decision you never made. Switch it on under Admin → Plugins if you want it.

With the module inactive, the install behaves like one that never had it:

  • /api/commerce/prescription-schema and /api/commerce/frame-schema return 404;
  • requires_prescription becomes an inert flag — the product sells without a prescription, and nothing errors;
  • prescriptions already frozen onto past order lines are untouched and still render. Deactivating a module never rewrites what a customer bought.

New: GET /api/commerce/frame-schema publishes frame geometry limits, face measurement limits, the ISO/IEC 7810 ID-1 card dimensions and the fit tolerance, shaped like the prescription schema. It also publishes pd_lab_grade_methodsa PD measured from a photo is for choosing a frame, never for a lens order.


U-12 — custom content types are private unless they say visibility: 'public'

Affects: plugins that call registerContentType(), declarative manifests with a contentTypes capability, and any storefront reading GET /api/content/<type> without credentials.

What changed. A registered content type used to be world-readable at GET /api/content/<name> and GET /api/content/<name>/<id>, with no way to say otherwise. It is now private unless it opts in:

 registerContentType({
   name: 'event',
   label: 'Event',
+  visibility: 'public',     // omit -> staff only
   fields: [ /* … */ ],
 });

Same key in a declarative manifest:

"contentTypes": [
  { "name": "event", "label": "Event", "visibility": "public", "fields": [ /* … */ ] }
]

Why. The old default was fine for event or doc and a data breach for job-application or enquiry — and it fell on the plugin author who never considered visibility, which is exactly the author most likely to get it wrong. Deny-by-default costs one line and puts the decision where the answer is known. This is TRUTH_PLAN D2-4.

Symptom if you are affected. An anonymous GET /api/content/<type> returns 404 where it used to return 200 with a list. It is 404 and not 403 on purpose: whether a private collection exists is itself information, so it is indistinguishable from a type that was never registered.

Not affected:

  • Writes. POST/PUT/DELETE were always admin/editor and are unchanged.
  • Authenticated reads. A session, or an API key scoped to content, reads private types exactly as before.
  • The bundled product-catalog plugin, which now declares visibility: 'public' — a demo catalogue is shop-window data.
  • The built-in /api/products, /api/posts, /api/categories/get and every other core endpoint. This applies only to plugin-registered custom types.

Fixing it is one line in the plugin that registers the type. An unrecognised value ("publik") is rejected at registration and at manifest validation rather than falling back, because both possible fallbacks are wrong: one leaks the collection, the other breaks a storefront with no message saying why.


U-1 — content is the source of truth again; rendered output moved

Affects: anything reading content or title from GET /api/posts or GET /api/posts/{ref}.

GET used to return content: applyFilters('post_content', …) — the RENDERED text — under the same key the editor writes back. So opening a post in the admin and pressing Save persisted the plugin’s output as the stored content: cumulative on every save, and it survived deactivating the plugin.

Now:

{
  "content":          "<p>What you actually wrote.</p>",   // raw, safe to PUT back
  "content_rendered": "<p class=\"reading-time\">⏱ 1 min read</p><p>What you actually wrote.</p>",
  "title":            "Raw title",
  "title_rendered":   "Filtered title"
}

What to do: if your storefront renders plugin output (reading time, badges), switch to content_rendered. If it just renders the post body, content is what you want and nothing needs to change — it is valid HTML either way.

Why this direction: a GET must round-trip through a PUT without changing the resource. Returning a derived value under the stored field’s name is what corrupted posts.


U-2 — GET /api/media/get is no longer public

Affects: API keys that list the media library. Not image rendering.

The route was in the public allow-list, so anyone could enumerate every uploaded file — original filenames, sizes, upload dates. For a real shop that means things like price-list-2026.pdf, or a customer’s prescription scan, where the filename alone leaks.

Two consequences:

  1. Anonymous callers get 401.
  2. Because it left the allow-list, it also left the scope-gate exemption — a bearer key now needs media:read. An existing storefront key minted before this will get 403 on this route.

What to do: nothing, unless your storefront calls /api/media/get. Images render from /uploads/**, which is still public, and product image URLs are embedded in the product record. If you do need the list, re-mint the key — scripts/mint-storefront-key.mjs now includes media:read.


U-3 — credential-shaped settings are never returned

Affects: anything reading secrets back out of GET /api/settings/get.

visibleSettings began if (isStaff) return settings, so the deny-list never ran for staff: an editor — a role that cannot reach most of the admin — could read smtp_password and stripe_secret_key from a route in the public allow-list.

Keys whose NAME looks like a credential (password, secret, token, api_key, private_key, …) are now withheld at every role and reported as a boolean instead:

{ "smtp_password__is_set": true, "stripe_publishable_key": "pk_live_…" }

publishable and public_key are deliberately exempt — a Stripe publishable key is meant to be public and withholding it would break checkout.

What to do: nothing for a storefront. The admin UI reads settings server-side and is unaffected. If you have tooling that read a secret back out of the API, it must now get it from the environment instead — which is where a secret should have come from.


U-4 — read-side ownership on posts

Affects: callers authenticated as author or viewer.

The check was if (!user), so any authenticated caller saw every unpublished body — embargoed announcements, unreleased pricing, other people’s drafts. Now:

rolepublishedown draftsothers’ drafts
anonymous / vieweryesno
authoryesyesno
editor / adminyesyesyes

What to do: nothing if your key is editor (the default from the mint script) — it sees everything as before. Note that this also means a storefront key with editor role can read drafts; if your storefront renders whatever the API returns, consider minting it as viewer so unpublished content cannot appear on a live site.


U-5 — the change feed no longer hands out snapshots

Affects: anything polling GET /api/content/changes.

The feed stores FULL entity snapshots — draft bodies, and orders with customer name, email, phone and address — and gated on if (user). Any session, or any scoped key, read all of it.

Snapshots now require editor/admin. Everyone else gets the timeline: what changed and when, never what it said.

What to do: nothing for an editor key. If you polled this as a lower role, you now get metadata and must fetch the entity itself.


U-6 — production refuses the seeded password

Affects: deployments still using admin@local / admin.

Once this project is public, admin is not a default — it is a published credential, and every install that kept it is one /login scan away.

  • A fresh production boot with no ADMIN_PASSWORD generates a random one and prints it once to stdout.
  • Login refuses the seeded password when NODE_ENV=production, with a distinct SEED_PASSWORD_REFUSED error.

What to do: set ADMIN_PASSWORD before first boot, or run npm run reset-password on an existing install. ALLOW_SEED_PASSWORD=1 overrides for a trusted private deployment. Development is unchanged — zero config local dev is the point of the lowdb driver.


U-6b — the Site URL setting now wins over SITE_URL

Affects: any deployment where the admin’s “Site URL” field and the build-time SITE_URL differ.

The field was stored and read by nothing: sitemap.xml, rss.xml, robots.txt, the canonical tag and the JSON-LD all used Astro’s build-time site. Filling it in had no effect.

It is now the highest-precedence source (setting → build-time SITE_URL → request origin), because the setting is editable at runtime and SITE_URL is baked in at build — the setting is the one an operator who moved domains, or who runs a prebuilt image, can actually change.

What to do: if your admin “Site URL” contains a stale or placeholder value while SITE_URL is correct, the absolute URLs in your sitemap, feed, canonical tags and structured data will change to the stored value. Check the field before upgrading, or clear it to fall back to SITE_URL as before. Values that are not valid http(s) URLs are ignored rather than emitted.


U-7 — 2FA material never leaves the server

GET /api/users/get stripped the password fields and not two_factor, so the TOTP secret and backup-code hashes were returned. POST /api/users/update did the same. An admin able to read another admin’s second factor can enrol it, which makes 2FA a second copy of the first factor.

All user-returning routes now use one sanitiser and expose two_factor_enabled: boolean only.


U-8 — media deletes are refcounted (data-loss fix)

Filenames are content-addressed, so two uploads of identical bytes are two records sharing one file. Delete unlinked unconditionally, so removing one record permanently broke the other — the row survived, its url still looked right, the bytes were gone.

It was also an escalation: an author could re-upload an admin’s image and delete their own copy to destroy the admin’s file.

Deletes now remove the file only when no other record points at it, and clean up the -thumb.webp derivative, which was previously left readable forever at a derivable URL.

No action needed. Existing data is unaffected; this only changes what happens on future deletes.


U-9 — thumbnails are persisted (performance)

thumb_url/width/height were attached to the upload RESPONSE after the record was created, so the database never held them and the library rendered full-resolution originals as 200px tiles.

Migration v10 backfills existing rows by deriving the sibling filename. It records a pointer only — it never creates or deletes a file — so a row whose derivative is missing simply keeps falling back to the original, exactly as today.


U-10 — the lowdb read cache

lowdb’s read() re-parses the whole JSON document on every call, and every getter calls it. Measured: 158.7 ms per parse on a 26.5 MB database, ~9 parses to serve one /blog request.

Reads are now cached and validated on inode + size + mtime, so a write from any other process — an import CLI, a restore, a second replica — is still picked up. Benchmarked at 17× fewer parses on a 3.8 MB database.

Only the lowdb driver is cached. The libSQL drivers are untouched, because there is no file to stat and a cache there would have to assume no other replica has written.

No action needed. This is the change most likely to be felt as “the admin got faster”.


U-11 — posts gained a kind, and pages are excluded by default

Post now has an optional kind: 'post' | 'page'. A page is the same record routed at /{slug} instead of /blog/{slug}, with no date or author byline — an “About” or “Contact” document rather than an article.

Every record you already have has no kind at all, and absent means “article”. Nothing was migrated and nothing needed to be: the classifier tests kind !== 'page', so a row written years ago is still an article on every surface it appears on.

What a headless storefront sees

Nothing, unless it asks. Pages are excluded from the article surfaces rather than added to them:

SurfaceBehaviour
GET /api/postsArticles only — unchanged, byte-for-byte, until you opt in
GET /api/posts?kind=pagePages only
GET /api/posts?kind=allBoth
GET /api/posts/{slug}Returns either — unchanged
/blog and /rss.xmlArticles only
/sitemap.xmlPages at /{slug}, articles under /blog/{slug}

Excluding rather than including is the deliberate choice. Had pages simply been added to the list, the day an operator wrote an “About” page it would have appeared at the top of a storefront’s blog listing and in every RSS subscriber’s reader — a content change nobody made. An unrecognised kind= value falls back to the default rather than erroring, so a typo in a query string cannot empty a production listing.

If you want a CMS page as your home page

Set home_page_slug in Settings → General (a picklist of your pages). The setting is published through GET /api/settings, so a decoupled front end can render the same document. If the named page is later renamed, unpublished or deleted, the stock welcome page renders instead — the front door cannot go down because of a stale setting.

No action needed. This section exists so that when you do create a page and it does not appear in /api/posts, you know that is the design.