AstroBaaS

Commerce

The optical vertical

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

Status: EXTRACTED TO A PRIVATE REPOSITORY (2026-08-24). It is no longer in this repository and must never come back.

Owner decision, 2026-08-08, unchanged:

  1. Commerce stays in the core. It is not being extracted into a plugin.
  2. The optometry / eyewear features are a separate module, enabled for gr-optics.com and optikigwnia.gr, and licensed for everyone else.
  3. No licensing code is to be written yet.

Where it went, and why it had to

astrobaas-commercial/packages/optical, private, proprietary licence.

The reason is mechanical rather than a preference: a file committed to this repository is GPL-3.0 licensed permanently. Once published, anyone may fork, modify and redistribute it, and no later decision withdraws that. Selling a module depends entirely on its never having been published under the GPL — so the module had to leave before this repository goes public.

The GPL binds everyone who receives the code; it does not bind its author. Copyright here is held by one person, so that person may license the same work commercially — which is exactly how Magento and Adobe Commerce are split. That asymmetry only survives while the proprietary copy stays out of the public tree.

Nothing from that repository may be copied back here. Code flows one direction only.

What replaced it in the core

PieceWhere
Loading a compiled plugin from outsidesrc/plugins/external.ts, driven by ASTROBAAS_PLUGINS
The order-line extension pointPLUGIN_HOOKS.ORDER_LINE_EXTRAS
Vertical-published schemasPLUGIN_HOOKS.COMMERCE_SCHEMA — core serves the routes, a plugin supplies the content
Prescription SHAPE + displaysrc/core/models.ts — data outlives the plugin that understood it
Degradation, assertedtests/smoke.mjs, all three storage drivers

The two schema routes still exist at their original URLs and now ask the hook, so a storefront sees no change when the module is present and an honest 404 when it is not.

Prescription, EyeRx and summarisePrescription() stayed in core on purpose. A prescription is frozen onto an order line at purchase, and that record must keep meaning something after the module is removed — an order is what a customer actually bought, and uninstalling a plugin must never rewrite it. The SHAPE is core; every clinical JUDGEMENT is the module’s.

The failure this is built to avoid

A shop whose database says optical is active, redeployed without the package, stops validating prescriptions — and the sale still completes, because that is the core’s correct behaviour without a vertical. Silent, and it reaches a lab with nothing to grind.

So bootstrap 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.

Verified by booting a real database through both states.


---| | The module | src/plugins/optical/ — a bundled, compiled-in plugin, id optical | | Prescription rules | src/plugins/optical/prescription.ts (moved from lib/commerce/) | | Frame geometry, face measurement, fit | src/plugins/optical/frame-size.ts (new) | | The one hook core exposes | PLUGIN_HOOKS.ORDER_LINE_EXTRAS | | Is the module live? | src/lib/optical-runtime.ts — one function, three callers | | Public schemas | /api/commerce/prescription-schema, /api/commerce/frame-schema (both 404 when inactive) | | Upgrade safety | migration v11, activate-optical-module-for-existing-optical-shops |

How the extraction was done

Core used to validate prescriptions inline in checkoutcommerce-service.ts knew what a cylinder axis was. Core now asks one generic question:

PLUGIN_HOOKS.ORDER_LINE_EXTRAS
  filter(result, { line, product, name }) -> { ok: true, patch } | { ok: false, message }

…and the optical plugin is what makes the answer optical. A future vertical answers the same question its own way; core never grows a second hole in checkout.

The data stays in core, the logic moved. Product.requires_prescription, prescription_type and the Prescription type on the order line remain in core/models.ts on purpose: shape is data, and data has to outlive the plugin that understood it. Deactivating the module must never rewrite what a customer already bought.

Degradation — the contract, and it is tested

With the module INACTIVE the core behaves exactly as it does for a shop that never bought it. Asserted in tests/smoke.mjs on all three storage drivers:

  • both schema endpoints return 404;
  • an Rx product sells without a prescription — the flag becomes inert data, and nothing 500s;
  • a prescription already frozen onto an order line is still there.

Upgrade safety — the bug this nearly shipped with

Bundled plugins seed inactive. Without a migration, upgrading an existing optical shop would have silently stopped prescription validation: a spectacle lens would sell with no prescription and reach the lab with nothing to grind, and nothing would error.

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. A general shop is left alone.

It also seeds its own plugin record, and that detail is the whole fix. Migrations run inside LocalDB.init(), which ensurePluginsBootstrapped() calls before it seeds bundled plugins — so at migration time there is normally no optical record at all. The first version bailed on “no record”, did nothing, and the boot then seeded the plugin inactive. Caught by booting a real v10 database with the new build and watching an Rx lens sell with no prescription. Verified fixed on lowdb, libSQL doc-blob and relational.


Why commerce stays in the core

The earlier plan was to extract commerce into a paid plugin. Measured against the code, that is a large piece of work: 10 files under src/lib/commerce/, 9 API route directories, 3 admin screens, 12 interfaces in core/models.ts, and 25 references in the Storage interface — commerce is woven into the storage contract every driver implements.

Extracting it means inventing a storage extension point, which is the kind of abstraction that is easy to get wrong once and then live with. Meanwhile the two live shops need commerce and would gain nothing from the split.

So: commerce is part of what AstroBaaS is. The paid boundary moves up a level, to the vertical.

Why the optical features are the right thing to sell

They are genuinely separable in a way commerce is not, and they are worth more to fewer people — which is exactly the shape of a paid module.

What is in scope (all currently in the core, to be moved):

PieceWhere it lives today
Prescription parsing, validation, summarisingsrc/lib/commerce/prescription.ts
requires_prescription, prescription_type on a productcore/models.ts, the Inventory tab of the product form
Prescription capture at checkout and on the orderorder model + admin/orders.astro
Dioptre / PD / axis integer handlinglib/commerce/prescription.ts
Lens-configurator work (not yet built)

The units matter and are already right: dioptres in hundredths, PD in tenths of a millimetre, all integers. That work does not need redoing, only moving.

The shape it should take

The plugin dependency mechanism already exists (src/core/semver-range.ts, src/lib/plugin-dependencies.ts, capabilities + dependencies in a manifest). It was built for exactly this and is tested. What it needs is something to depend on — and with commerce staying in the core, the dependency is on the HOST version rather than on a commerce plugin:

{
  "id": "optical",
  "name": "Optical & Eyewear",
  "version": "1.0.0",
  "astrobaasApi": "^1.0.0",     // already enforced
  "capabilities": { /* sections, content types, … */ }
}

A vertical that needs more than a manifest can express (prescription validation is real logic, not data) is a compiled-in plugin, not a declarative one. That is the tier that can register hooks and server logic.

Licensing — deliberately not designed here

The commercial model itself is analysed in LICENSING.md: the Magento shape (free community edition + paid commercial edition) is available because the copyright is held by one person, and the single time-critical action is adding a CLA before the first external contribution.

Recording here only the constraints that a future design must not violate:

  • The two live shops must never be gated. Whatever the mechanism, an outage or an expiry must not stop gr-optics.com or optikigwnia.gr from selling. Fail-open for existing installs, always.
  • No phone-home on the request path. A licence check inside a page render couples a shop’s uptime to a licence server’s uptime. Whatever is built, it is checked out of band and cached.
  • The core stays GPL-3.0 and honest about it. A paid module sitting on a GPL host has licensing implications that need a real answer before any code is written, not after.
  • Nothing in the core should reference the licence. If the optical module is absent, the core must behave exactly as it does for a shop that never bought it — which is the same degradation rule sections already follow.

What to do first, when this is picked up

  1. Move the optical pieces out of src/lib/commerce/ into a plugin, with the core keeping generic hooks where they attached.
  2. Confirm the two live shops still work with the plugin ACTIVE — that is the only acceptable definition of “no regression” here.
  3. Confirm they degrade sanely with it INACTIVE: prescriptions become plain stored data, nothing 500s.
  4. Only then think about licensing.

Do not start at step 4.