Where it is going
Execution plan
Everything we are actually building, in the order we are building it. Nothing else.
Generated from docs/EXECUTION-PLAN.md in the AstroBaaS repository. The repository is the source of truth; this page is a copy of it.
This supersedes the planning half of WP-PARITY-ROADMAP.md —
that document remains the market-analysis record (what WordPress users install and why);
this one contains only what goes into AstroBaaS. The eleven refused capabilities are
gone entirely; anything we decided against does not appear here. C-## references point
back to the parity document for context.
Standing decisions this plan is built on
- AstroBaaS goes open source, commerce included. Ecommerce stays in the GPL core — disabled by default on fresh installs, switched on when an instance is meant to sell. A data-aware migration enables it automatically wherever commerce data already exists (products or orders present), so the two production shops and any other running eshop upgrade without a single visible change. The old “extract commerce first” question is closed.
- The paid tier is a set of named plugins, sold separately, developed in the
commercial repo. Free core never gates a capability on payment; the paid seam is
always a plugin boundary:
- Payment gateways (IRIS, Stripe, and future PSPs)
- White-label AstroBaaS (rebranded admin)
- Paywall / metered access for news sites and blogs
- Cart-page and checkout-page customization suites
- Loyalty & bonuses programme
- Reviews aggregator (pulls reviews from external platforms into the site)
- Subscriptions & recurring billing · sales funnels · marketing automation · membership/front-end accounts (from the parity analysis)
- The existing verticals: optical, IRIS, suite
Distribution stays as established: plugin tarballs +
ASTROBAAS_PLUGINS_ACTIVATE, no licence-key gating in code.
- Every phase lands the same way: its own branch and PR; code modular and DRY; security defaults on (deny-by-default visibility, rate limits, CSRF, validated input — no unexpected types or symbols); documented in the relevant guide; unit + smoke + build green before the PR opens; negative tests proven to fail without the fix. The login throttle and forgot-password limiter are untouchable. Uploads stay sequential.
Phase 0 — The owner’s batch (in progress now)
The features ordered directly, before the roadmap resumes:
| # | Deliverable | Definition of done |
|---|---|---|
| 0.1 | Commerce master switch — commerce_enabled setting, off for fresh installs; migration auto-enables when products/orders exist; gates commerce APIs (404 when off), admin nav, public shop routes, sitemap/search inclusion | Fresh install shows no shop anywhere; seeded shop keeps working with zero action; negative tests for both directions |
| 0.2 | SVG in the media API — accepted, sanitized server-side (scripts, event handlers, foreignObject, external references stripped), skips raster derivatives, served with headers that keep it inert | A hostile SVG uploads and serves with its payload removed; test corpus of attack SVGs in the suite |
| 0.3 | Every content type gets its admin UI automatically — dynamic sidebar entries per registered type (admin + plugin types) under Content; the builder link stays; no type exists without a screen | Registering a type (either path) makes it appear in the nav for permitted roles without any code change |
| 0.4 | Local CAPTCHA (proof-of-work) — no third parties: HMAC-signed challenges, difficulty-tuned browser PoW, single-use solutions, per-endpoint enforcement on contact, newsletter, login, forgot-password | Zero external requests; replay refused; suites cover challenge lifecycle; enforcement togglable per surface in settings |
| 0.5 | Magic-link login — Shopify-style emailed one-time link beside password login; hashed single-use token, short TTL, throttled, no user enumeration; selectable only when the email channel is active | Login page offers it only when email works; link logs in exactly once; existing auth untouched |
| 0.6 | 2FA offered behind the email-channel gate — enablement (and the settings surface for it) requires a working email channel, same predicate as 0.5 | One shared emailChannelActive() predicate; UI explains why it is unavailable when it is |
| 0.7 | Legal-page templates, ready to activate — privacy, terms, cookies, imprint, returns/withdrawal in en/el/de with placeholders; admin activates one, it becomes an editable page rendered through the active theme | Activating a template on any theme produces a correctly-styled page; placeholders obvious; content marked “template, review before publishing” |
| 0.8 | Theme polish pass — spacing/typography refinement across default, editorial, marquee; hover/focus states; dark-mode audit; responsive audit at 375/768/desktop | Screenshot evidence per theme per viewport; no unstyled slot anywhere |
Phase 1 — Visible wins ✅ (complete)
- JSON-LD structured data + breadcrumbs (C-11, C-12) — one tested module builds
Article, WebPage, CollectionPage, Organization, WebSite, BreadcrumbList and Product;
Breadcrumbsis the 8th theme slot, and its trail is the same array the BreadcrumbList is serialized from, so the reader and the crawler cannot be told different things. Product is for the headless storefronts: money is integer cents here and a decimal string in schema.org, converted in exactly one place. - Duplicate post/page (C-47) —
POST /api/posts/{ref}/duplicate, always a draft. It also forced a real fix: slug uniqueness had no database constraint and a single-shot timestamp suffix, so two copies inside one millisecond collapsed onto one slug.uniqueSlug()is now the constraint and both create paths call it. - “Discourage indexing” (C-16) — the toggle already emitted meta robots on every
public page (verified, not assumed). What was missing:
/rss.xmland/llms.txtignored it, so a hidden install still fed every reader and handed agents a map of its API; and the key was outside the boolean settings guard, so POSTing the string"false"hid the whole site while reporting success. Both closed, plus a new per-post noindex that also drops the post from the sitemap and the feed. - Website
/docsnow links the execution plan, the parity analysis and both roadmaps — and stops describing two different documents as one.
Phase 2 — The WordPress-refugee wave
WXR import, finished (C-90)— done.lib/import/{wxr,plan,apply}.tsparse, decide and write as three reviewable pieces; everything goes throughLocalDB, so all three drivers behave identically. Pages, categories (keeping the WordPress nicename as the slug), tags, publish dates, media fetched through the sharedlib/media/ingest.ts, and 301s for every permalink that moved. Dry run is the default in the CLI, the endpoint (POST /api/import/wordpress) and the screen (/admin/import); re-running is safe because each record is stamped with the id it came from. WooCommerce got the same treatment:lib/import/{woo,woo-apply}.ts, prices refused rather than rounded, staff never imported as customers, and the commerce switch turned ON — never off — when products or orders actually land. One deliberate narrowing: authors are REPORTED, not invited. Creating invitations from an uploaded file would let anyone who can reach the import screen make this server email thousands of addresses of their choosing. The operator invites the real people and reassigns; the logins and emails are shown so they can.Public-write content types → the form builder (C-20)— done. A type declareswritable: 'public'(deny-by-default, separate axis fromvisibility) and gets an anonymous POST endpoint guarded by a per-address submission limit, a honeypot, and the local proof-of-work check on a newformssurface. Submissions are stampedsubmitted_atby the server, echo nothing back, and land in the generated admin screen; fields the type never declared are dropped rather than stored./forms/<type>renders a working form from the definition — no theme edit and no JavaScript to write — and the sameSubmissionFormcomponent is importable anywhere.notifyOnSubmissionemails the site address when a channel is configured. The admin warns, loudly, about the one combination nobody picks deliberately: public reads AND public writes, which publishes every submission.Relation & media field kinds (C-122, C-124)— done.refnames the collection it points at and a dangling reference is REFUSED rather than stored and rendered blank;mediastores the id and a resolved<field>_urlis added on read, so a record survives the site moving domain. Both are checked on create AND update, from one function, and the list and single-entity reads resolve identically. Found on the way in: the validator’s switch had nodefault, soslug,email,urlanddate— four of the ten types the builder already offered — were accepted and then SILENTLY DISCARDED.{ ok: true, value: {} }, on a screen shipped to every user. All four are implemented now, and the switch fails closed, so the next field type added without a case is a visible error on one record rather than a silent hole in every one.Consent Mode v2 signals (C-104)— done. All seven signals, including the two v2 added in March 2024 (ad_user_data,ad_personalization) that a site sending only the v1 three is non-compliant without. The category map lives once inlib/consent.tsand is shipped to the browser as data rather than restated in the served script. A deliberate divergence from Google’s own guidance: Google says to loadgtag.jsunconditionally so it can model un-consented traffic. This does not. Nothing third-party loads until somebody says yes — the stricter reading of ePrivacy and the promise the banner already makes — and the signals are queued into adataLayerthat exists from the start, which sets no cookies and contacts nobody. A tag that loads later reads the whole history. What is given up is Google’s modelling; what is kept is that a visitor who rejects has no request made on their behalf at all.Consent audit trail (C-102)— done. Article 7(1) asks a controller to DEMONSTRATE that consent was given. The obvious implementation — log the IP and user agent with every decision — answers a privacy obligation by collecting more personal data, and every one of those rows then becomes subject to access and erasure requests of its own. So a receipt carries only the decision: which categories, under which version of the text, at what time, under an opaque id the VISITOR’S BROWSER generated and keeps in their own consent cookie. The only copy of the link between a person and a receipt is in that person’s browser. Idempotent on the id (a banner retries), capped separately from the audit log so consent traffic cannot evict security events, and readable only by an admin — at/admin/privacy, next to the data requests it supports.Data-subject export/erase (C-105)— done./admin/privacyandGET|POST /api/privacy/subject, admin only, both audited. Access assembles the customer record, every order placed with the address or by that customer (a guest order carries one and not the other — matching on either alone loses half a history), messages, the newsletter subscription and entries in custom collections whose declared email fields carry it. Credentials never leave the server. Erasure deletes those, and anonymises orders rather than deleting them: totals, line items, dates and tax survive so the shop can still produce its accounts, and everything identifying the person is replaced. Article 17(3)(b) does not require erasing records held to meet a legal obligation, and a shop that deletes its invoices to satisfy a request has broken a different law. A staff account with the same address is reported and never removed automatically. Erasure needs the literal wordERASE, and is idempotent. Free-text is deliberately NOT searched: a message that merely mentions an address is somebody else’s data too, and a substring sweep would answer a data request with a breach.Off-site backups (C-86)— done. Any S3-compatible bucket (Backblaze B2, Cloudflare R2, Wasabi, Hetzner, MinIO), on the scheduler’s existing timer. Credentials come from the environment and nowhere else: the settings table is partly readable by anonymous callers and schemaless, so a bucket secret stored there is one careless projection away from being published. Signing is hand-written rather than pulling in@aws-sdk/client-s3— seventy packages for PUT, LIST and DELETE against one bucket, in a project with eleven dependencies total, each of which an operator has to trust and patch. SigV4 is eighty lines of a published algorithm, andtests/s3-signing.test.mjschecks it against an independently written implementation of the same specification: “it worked against my bucket yesterday” proves nothing about the next key containing an apostrophe. It archives what the ACTIVE DRIVER stores —db.jsonfor lowdb, the SQLite file for afile:libSQL URL, uploads in both cases. A remote libSQL/Turso install is REFUSED with an explanation rather than handed a staledb.jsonthat would restore to an empty site. Retention runs only after a successful upload and never removes the archive it just wrote: otherwise the day uploads start failing is the day the last good backup disappears.Replace media in place (C-60)— done. The record keeps its id, so everything referencing it shows the new file at once. The new bytes get their own address, because uploads are content-addressed and servedimmutablefor a year — writing over the old path would leave browsers and CDNs serving the old file for months, invisibly to the person who made the change. So every stored reference is rewritten instead: post and page content, product images and descriptions. Old files are unlinked except any another record still points at, since identical bytes share one path. Replacing with the bytes already there is a no-op rather than a sequence that deletes what it just wrote. Audited, because it is a content migration. Derivative rebuild (C-59) is now redundant: a replacement runs throughingestMedia, which regenerates the whole set. A button that rebuilds derivatives without changing the file would only matter after a change to the width list, which is a deploy-time event — left out rather than shipped as a button that usually does nothing.Translation status dashboard (C-133)— done./admin/translations, open to anyone who can write content rather than admins alone: the person who does the translating is usually an editor, and a backlog only admins can see is a backlog nobody works through. It answers one question across BOTH translation models — a separate record per language for articles, ani18nsidecar for products — because that split is exactly why the question never gets asked. Two judgements worth knowing: an article written directly in German is not counted as an English gap (it is not a translation somebody forgot), and an empty site reads as 100% rather than 0%. The row nothing else reports is half translated: a product with a German name and an English description has a sidecar entry forde, so every check that looks for the locale key calls it done — and the page ships with English body text under a German heading.Scheduler screen + outbound email log (C-155, C-109)— done. One screen,/admin/operations, because the answer to “why has my scheduled post not gone live?” is often in the answer to “did that email go out?” — a worker that is not running has not sent the notifications either. Both were previously answerable only by reading server logs, which the person running a small shop cannot do. The scheduler reports whether it is running in this process, not merely enabled in the environment: those are different things and only the first one publishes anything. It also reports the last sweep’s error, which is what an operator staring at an unpublished post actually needs. The email log records recipient, subject, transport and outcome — and never the body, because password-reset links, magic sign-in links and invoices all go through the same sender and a log holding those would be a list of live credentials every admin can read. It holds recipient addresses, which makes it personal data, solib/gdpr.tssearches and erases it along with everything else — a new store of personal data outside the tooling built to answer requests about personal data would have been exactly the gap that tooling exists to close.Section composition UI, first pass (C-41)— done. Reordering was already there (insert, move, duplicate, delete, all e2e-tested); configuring was there for core sections and impossible for plugin ones —modifiers: {}was hard-coded in the palette, so a plugin section could be inserted and moved but never varied. A manifest may now declaremodifiers: { group: [values] }, validated as hard as its template and its CSS: kebab-case names, bounded counts, no character that could smuggle a selector. The class is namespaced to the plugin. Core turns{ align: ['center'] }intoab-align-center, which core CSS styles; a plugin declaring the same group producesab-x-<plugin>-align-centerinstead. Without that, a three-line manifest would inherit core’s styling for a class it does not own — the exact thing the plugin-CSS namespace exists to prevent, and a hole that would have opened the moment plugin modifiers shipped.
Phase 3 — Reach & polish
- AI-assisted SEO/readability hints (C-13) and draft-translation action (C-134), both on the existing assistant.
- Weighted search (C-146) · related posts (C-147) · table of contents (C-46) · table sections (C-45).
- Comments, moderated (C-142) — spec decides core vs first-party plugin; PoW + honeypot
- moderation queue regardless.
astrobaas migrate+ CLI verbs for content/users/backups (C-87, C-162) · CSV import/export for content types (C-91).- Repeater fields (C-123) · admin-defined settings groups (C-127) · custom taxonomies (C-128).
- Media folders (C-61) · alt-text audit (C-62) · author/date archives (C-153).
- Cookie declaration table (C-103) · analytics charts in admin (C-107) · editable email templates (C-112).
Phase 4 — Depth
- Product reviews, first-party basic (C-35) — aggregation from external platforms is the paid plugin.
- Invoicing / PDF receipts (C-39) — core basic; Greek myDATA stays commercial.
- Newsletter campaigns (C-111) — core composing/sending for the existing list.
- Conditional logic for forms and fields (C-22, C-125) · public upload fields with quarantine (C-23) · flexible layouts (C-126).
- Custom roles capability matrix (C-138) · audited user switching (C-141) · editorial workflow (C-150) · manual ordering (C-149).
- npm-distributed themes (C-168) · child themes (C-170).
- Content spam scoring for public writes (C-79).
Phase 5 — Long tail
AVIF derivatives (C-56) · video poster frames (C-64) · upload AV-scan hook (C-76) · build-time file-integrity manifest (C-82) · staging documentation (C-89) · RTL (C-135) · print/PDF styles (C-152) · per-request performance breakdown (C-157).
Go-public checklist interaction
Phase 0.1 clears the largest gate in GO-PUBLIC.md (the commerce decision). Still owner-only
before flipping the repo public: the history strategy for the leaked VPS IP (fresh-mirror
recommended), the CLA PERSONAL_ACCESS_TOKEN secret, and NPM_TOKEN for the npm publish.
Plan drawn up 2026-08-29. Status of record for each phase lives in the PRs that land it.