# Backlog & Roadmap **Official issue tracker:** https://git.4lt.ca/4lt/novaconium/issues — bug reports and feature requests are filed and discussed there, not here. This file is the roadmap that sits *above* the tracker: a curated, lower-noise list of what's planned, in progress, or decided against, used to triage and clean up the tracker (grouping related issues, deciding priority/sequencing, deciding what's not going to happen) rather than to replace it. An entry here should generally reference the tracker issue(s) it corresponds to once one exists; an entry can also exist here before any tracker issue is filed, for things that are still just an idea. ## How to use this file - Add new items to **Backlog** using the template below. Don't build anything the moment it's added — Backlog is "known, not yet started." - Link the tracker issue once one is filed (`Issue:` line). Not every Backlog entry needs one yet — file the tracker issue when it's ready to be actionable/discussed, not necessarily when the idea is first written down here. - When work begins, move the item to **In Progress**. - When shipped, move it to **Done** and add a `Shipped:` line with the date and, once committed, the commit/PR reference. Keep a Done entry only as long as it's referenced by (a `Depends on:`, or otherwise relevant context for) something still in Backlog/In Progress — once nothing active points back to it, delete it rather than letting this file grow without bound. This is a change from the file's earlier "never delete" policy; if a stale Done entry's history is ever needed again, it's in git history / the linked tracker issue. - If something is decided against, move it to **Won't Do** with a `Reason:` line rather than deleting it — the "why not" is worth keeping. Close the corresponding tracker issue with a link back to that entry. - Keep entries terse. This file is a map, not a design doc — link out to `/admin/docs/design-notes`, the tracker issue, or a future `docs/` note for anything long enough to need one. ### Entry template ``` ### - **Type:** Feature | Bug - **Status:** Backlog | In Progress | Done | Won't Do - **Priority:** Low | Medium | High - **Added:** YYYY-MM-DD - **Depends on:** — omit if none - **Issue:** https://git.4lt.ca/4lt/novaconium/issues/N — once filed - **Shipped:** YYYY-MM-DD (commit/PR ref) — only once Done <1-3 sentence description: what and why. For bugs, include repro steps and expected vs. actual behavior. For features, include the motivating use case.> ``` --- ## Backlog Suggested build order (foundations first): 1. **In-house comments** — admin login & user management (Done 2026-07-14) unblocked this; comments are tied to real user accounts, not anonymous. 2. **Ecommerce functionality** — its dependencies (SQLite groundwork, session handling for the cart, admin login for order/product admin) are all done now. 3. **Paywall functionality** — needs everything Ecommerce needs, plus Ecommerce itself for the recurring-billing/payment-gateway plumbing; build after it rather than in parallel — also now has a concrete precedent to follow for the "gated content must skip the static cache" part of its design (see Draft pages (admin-only preview) in Done, and the caching/auth standing rule in `AGENTS.md`), which was still an open question when this entry was originally written. Session handling (with flash sessions), Draft pages (admin-only preview), Admin login & user management, and Media/file manager all shipped (see Done) — 2026-07-14 for the first three, 2026-07-15 for Media/file manager. See **Won't Do** below for 404 tracking, dropped in favor of Matomo. ### In-house comments - **Type:** Feature - **Status:** Backlog - **Priority:** Medium - **Depends on:** Admin login & user management (Done), SQLite groundwork (Done) - **Added:** 2026-07-14 A self-hosted comments library — no third-party service (Disqus, Commento, etc.) — a `Lib\` class any sidecar can call to attach comments to any page, not just blog posts, the same way `Lib\SpamGuard`/ `Lib\FormValidator` are reusable across any form rather than hardcoded to the contact page. Comments tied to a real user account rather than anonymous name/email fields, which is why this rides on Admin login & user management rather than SQLite groundwork alone — needs that feature's user store to exist first. Likely a `comments` table (route/user/body/created_at/approved or similar — a migration under `App/migrations/`, following the two-root convention documented in `/admin/docs/database`) plus a small set of sidecar-callable methods (list comments for a route, submit one, moderate one). Needs a decision on moderation model (auto-approve vs. admin-approval queue, reusing the `/admin/*` auth gate for the moderation UI) and on spam handling (reuse `Lib\SpamGuard`'s honeypot/timing approach rather than inventing a second mechanism, consistent with how CSRF protection already works — see `/admin/docs/sidecars`'s "Form security" section for the existing input-cleaning/CSRF/spam-prevention layers a comment form should compose the same way the contact form does). ### Ecommerce functionality - **Type:** Feature - **Status:** Backlog - **Priority:** Low - **Depends on:** SQLite groundwork (Done), Session handling (with flash sessions) (Done), Admin login & user management (Done) - **Added:** 2026-07-12 Product catalog, cart, checkout, and order storage — a `products` / `orders` table in SQLite, a session-based cart (rides on the flash-session work above), and a payment gateway integration for actually taking money. Given the project's no-Composer/no-vendored-SDK philosophy, prefer calling a payment provider's HTTP API directly (e.g. Stripe's REST API via cURL) over vendoring a full SDK, same reasoning as vendoring only Twig's `src/` rather than pulling in a package manager. Needs a decision on which provider(s) to support first. Order/product management rides on admin login above. Large feature — likely worth its own sub-breakdown (catalog, cart, checkout, order admin) once it's actually picked up rather than planning it all up front here. ### Paywall functionality - **Type:** Feature - **Status:** Backlog - **Priority:** Low - **Depends on:** Ecommerce functionality (recurring billing/payment plumbing), SQLite groundwork (Done), Session handling (with flash sessions) (Done), Admin login & user management (Done) - **Added:** 2026-07-12 Subscription/membership content gating, similar to OnlyFans/Patreon: recurring billing tied to a user account, content (posts, pages, media) marked as gated behind an active subscription, and access checks in sidecars (`$_SESSION`'s logged-in user + subscription status) — the access-check half of this now has a shipped foundation to build on: `Lib\Access` (see User roles, groups & page access control in Done) already handles login-gated/group-gated sidecar content; a paywall mostly adds "does this account have an active subscription" as a rule source on top of it. Reuses Ecommerce's payment-gateway plumbing for the recurring-charge side rather than integrating a payment provider a second time — build after Ecommerce rather than in parallel. Also needs a decision on how gated content is authored (a `gated: true` flag in a sidecar's returned context vs. a separate content root) and what happens to cached pages once caching only applies to sidecar-less pages — gated pages will need a sidecar to check access, so they're never statically cached, which is consistent with the existing caching model but worth being explicit about up front. ## In Progress _Nothing yet._ ## Done ### Email verification for user accounts - **Type:** Feature - **Status:** Done - **Priority:** Medium - **Depends on:** Admin login & user management (Done) - **Added:** 2026-07-14 - **Shipped:** 2026-07-15 Every user created after the first must click a 24-hour link (`bin2hex(random_bytes(32))`, same pattern as `Lib\Csrf::token()`) emailed to their address before `AdminAuth::attempt()` lets them log in at all — folded into the same generic pass/fail as a disabled account or a wrong password, no oracle revealing which. Two exemptions, both following the last-active-admin guard's reasoning that a safety mechanism can't depend on something that might not be configured: the very first user (auto-verified at creation, since there's no other admin to vouch for them, and they're auto-logged-in the same instant) and every row that predates this migration (`novaconium/migrations/0003_add_users_verification.sql` backfills `verified_at` from `created_at`). `create-admin-user.php` also auto- verifies, since lockout recovery can't depend on a working mail transport either. `/verify-email` follows `/admin/logout`'s GET-confirms/POST-mutates shape (the content-index crawl forces a GET on every route, and mail-security scanners prefetch links — either would burn a GET-mutates token); an invalid/expired/already-used token never touches the database either way. Changing an account's email at `/admin/users` resets verification and re-sends to the new address; `/admin/users` also grew a per-row **Resend verification** button, visible only when unverified. `Lib\Mailer` grew a second method, `sendMail()`, for this and future transactional mail (password-reset-by-email later, per the original spec) — separate from `send()`, which the contact form keeps using unchanged. `sendMail()` is driver-dispatched via a new `mail_driver` config key: `'log'` (default) reuses the same `contact-log.txt` fallback so a fresh checkout can create and verify accounts with zero external dependency; `'mailjet'` sends through MailJet's Send API v3.1 (`mailjet_api_key`/ `mailjet_api_secret`/`mail_from_email`/`mail_from_name` in `App/config.php` — not gitignored, same as every other config key, per the user's decision that deployer-owned secrets are acceptable here). Adding another provider later means one more `match` case in `sendMail()` — callers never change. Documented in `/admin/docs/admin-auth`'s new "Email verification" section. ### Media/file manager - **Type:** Feature - **Status:** Done - **Priority:** Medium - **Added:** 2026-07-12 - **Shipped:** 2026-07-15 An upload/browse/delete UI for media (images, PDFs, etc.) at `/admin/media` so sidecars and Twig templates have a consistent place to reference uploaded files from — e.g. a blog post's header image — instead of authors manually copying files into `public/`. Covered by the existing `/admin/*` auth gate the moment the page was added, no extra wiring needed — as planned, there's no separate `*_enabled` flag (unlike admin auth/content index) since it has no SQLite dependency to gate. Backed by a plain directory, `public/uploads/` (gitignored per-file with a tracked `.gitkeep`, same convention as `data/`), rather than a database — no metadata store (alt text, captions) yet; that's flagged as a natural SQLite fit if wanted later, not built now. Also removed the top-level `images/` scaffolding directory/volume (reserved for a future image-upload feature, per the prior `AGENTS.md` wording): nothing had ever consumed it, and `public/uploads/` now covers the use case it was held open for. Safety handling: an extension allowlist and max upload size, both configurable (`media_upload_extensions`/`media_upload_max_bytes` in `App/config.php`), plus filename sanitization (`basename()` + safe-charset reduction, collision-safe via a `-1`/`-2`/... suffix) and a `realpath()` re-check on delete to confirm the resolved path still lands inside `public/uploads/` before unlinking. Documented at `/admin/docs/media-manager` (new topic, linked from the docs nav/index and the admin dashboard), with a matching README feature/docs-index entry. ### User deletion & email addresses - **Type:** Feature - **Status:** Done - **Priority:** Medium - **Depends on:** Admin login & user management (Done) - **Added:** 2026-07-14 - **Shipped:** 2026-07-14 (b882c30) Second same-day follow-up to Admin login & user management (below), also pre-commit — so the `email` column went into the existing `0002_create_users.sql` like the roles change before it. `/admin/users` gained a hard-delete action (username/email become reusable; any live session dies on its next request via the same per-request row re-check disabling uses; the last-active-admin guard covers delete as well as disable/demote) and a change-email action. Every account now requires a unique email address — validated and normalized (trim + lowercase) via the existing `Lib\Validate::isEmail()`, so uniqueness is case-insensitive by construction (verified: `BOB@example.com` collides with `bob@example.com`) — on the create form, the change-email action, and `bin/create-admin-user.php` (now ` `). Not used for login or any mail yet; it exists so the planned email-verification flow (new Backlog entry above) has an address for every account that predates it. Delete stays deliberately distinct from disable in the UI (inside a confirm-style `
` with a warning): disable is keep-but-shut-out, delete is gone-for-good. ### User roles, groups & page access control - **Type:** Feature - **Status:** Done - **Priority:** Medium - **Depends on:** Admin login & user management (Done) - **Added:** 2026-07-14 - **Shipped:** 2026-07-14 (b882c30) Follow-up to Admin login & user management (below), shipped the same day before any of it was committed — so the `users` schema change went into the existing `0002_create_users.sql` rather than a third migration. Two roles (`users.role`): the first user created is `'admin'`, everyone after is `'registered'` with an optional single group (`users.user_group`, a plain text label matched exactly — deliberately no groups table). `/admin/*` and draft preview are admin-only now — a logged-in registered user gets a plain 404 there (not a login redirect; they're authenticated, what they lack is the role) — and the last-active-user lockout guard became a last-active-*admin* guard, applied to both the disable and the new demote action (`/admin/users` also grew group-assignment and promote/demote). `Lib\Access` (`novaconium/lib/Access.php`) is the sidecar-level content gate, per the "assign a page/section to a user or group" spec: `Access::require('group:members', 'user:bob')` at the top of a sidecar returns `null` or a ready-made `Response` (anonymous → login redirect carrying a `?return=` path, validated against open redirects; wrong-account → 404, drafts' hide-don't-tease posture). No rules = any logged-in user; admins pass everything. Public is the default twice over: sidecars that never call it are untouched, and static (sidecar-less) pages *can't* call it — always public, which also means a gated page necessarily has a sidecar and is therefore never written to the static HTML cache: the caching/auth standing rule satisfied by construction, no bootstrap exclusion needed. Sections are gated by composition (a shared `_access.php` file `require`'d by each sidecar in the section), not per-directory config. `Access::require()` has no side effects on deny, so the content-index crawl (anonymous GET per sidecar) both drops gated pages from `/search`/`/sitemap.xml` automatically and can't touch the visiting user's session. Verified end-to-end with three real accounts (admin / registered-with-group / registered-without) and a cookie jar each: rule matrix, return-path round trip, `//evil.com`-style return rejection, registered-user 404s on `/admin/*` and drafts, promote/demote + guards, group reassignment taking effect immediately, crawl exclusion, and flag-off zero-footprint posture. Documented at `/admin/docs/access-control` (new topic, linked from the docs nav/index), with supporting updates to `admin-auth`, `drafts`, `sidecars`, `config`, and `libraries`. ### Admin login & user management - **Type:** Feature - **Status:** Done - **Priority:** Medium - **Depends on:** SQLite groundwork (Done), Session handling (with flash sessions) (Done) - **Added:** 2026-07-12 - **Shipped:** 2026-07-14 (b882c30) Shipped as specified: the single-user HTTP Basic Auth stopgap that gated `/admin/*` was **replaced, not layered on** — `AdminAuth` keeps its name and call sites (`bootstrap.php`'s admin gate and draft gate) but is now a session login (`Lib\Session`, with a new `Session::regenerate()` against session fixation) against a `users` table (`novaconium/migrations/0002_create_users.sql`, the second framework-shipped migration) with `password_hash()`/`password_verify()` and no external auth library. The `admin_username`/`admin_password_hash` config keys and the `/admin/password-hash` page are gone, superseded by a single `admin_auth_enabled` flag (default `false`) plus new `/admin/login`, `/admin/logout` (a real page now — the pre-router special case in `bootstrap.php` is gone too — and POST-only with a GET confirm form, because the content-index crawl runs every sidecar as a GET and a logout-on-GET would have ended the crawling admin's own session the first time a lazy reindex rendered it), and `/admin/users` (create/disable/enable/change-password) pages, and a `novaconium/bin/create-admin-user.php` CLI (password via stdin, for deploy scripts and lockout recovery). Documented at `/admin/docs/admin-auth`. Decisions worth recording: same zero-footprint posture as the content index (flag off → the three auth routes 404 and `Lib\Db` is never touched, so no `data/novaconium.sqlite` appears — the route sidecars self-load config the same way `/search` does); first-user bootstrap keeps the gate open only while the `users` table is empty (creating the first user at `/admin/users` auto-logs you in as it and closes the gate — running the CLI *before* enabling the flag avoids the window entirely); `admin/login` is the one `/admin/*` route exempted from `requireLogin()`, or its redirect would loop; disabling a user kills any live session on its next request (`currentUser()` re-checks the row per request), and disabling the last active user is refused since the empty-table window never reopens — that would be a permanent lockout. Login/user passwords read `$_POST` directly, extending the documented `Lib\Input` exact-value exception (verified with a password containing `<>` end-to-end). Verified route-by-route with real HTTP requests and a cookie jar: flag off (404s, no DB file), setup window, first-user auto-login, fresh-client redirect, wrong/right password, logout, enable/disable/change-password, live-session lockout on disable, last-user guard, CSRF failure paths, CLI creation, and draft gating via the session cookie (including confirming a pre-existing static-cache copy of a page still serves after the page is marked a draft until the cache is cleared — the documented cache-clear step, not a new bug). ### Draft pages (admin-only preview) - **Type:** Feature - **Status:** Done - **Priority:** Medium - **Added:** 2026-07-13 - **Shipped:** 2026-07-14 Let a page under `App/pages/` be written and previewed by an admin without being visible to the public — a config-driven list, `draft_routes` in `App/config.php` (`Route::$dir`-format entries, e.g. `'blog/upcoming-post'`), checked in `novaconium/bootstrap.php` right alongside the existing `/admin/*` gate. Reuses `AdminAuth::isAuthenticated()` (a new method, extracted out of `requireLogin()` so the credential check could be reused with a different failure response) rather than a second auth mechanism: not authenticated → the same plain 404 an unmatched route gets (not a login prompt, so a draft's existence isn't revealed to anyone poking at the URL), authenticated → renders normally. No separate login flow needed — an admin authenticates once at `/admin`, and the browser then resends those same Basic Auth credentials to draft URLs automatically, since they're scoped to the whole origin/realm. The gotcha flagged when this entry was written was designed around correctly: sidecar-less pages get written to the static HTML cache and served by `.htaccess` before PHP ever runs, so a draft without its own sidecar needed an explicit exclusion, not just the auth gate — `Renderer::render()` gained an `$excludeFromCache` param for this. **A second, real instance of the same bug was found while testing this feature, pre-dating it entirely:** `/admin` itself (`novaconium/pages/admin/index.twig`) has no sidecar, so it was already being written to the static cache — meaning once any admin visited `/admin` once, the admin panel was served to every subsequent visitor, unauthenticated, straight from `public/cache/admin/`, completely bypassing `AdminAuth`. Fixed in the same change by passing `$excludeFromCache = true` for every `/admin/*` route too, not just drafts. Documented as a standing rule in `AGENTS.md` (next to the `mb_substr`/`|slice` and `|escape('js')` notes) and at `/admin/docs/drafts`/`/admin/docs/caching`: any future mechanism that conditionally hides page content from the public has to make the same check, not just gate the initial request. ### Session handling (with flash sessions) - **Type:** Feature - **Status:** Done - **Priority:** High - **Added:** 2026-07-12 - **Shipped:** 2026-07-14 A `Lib\` wrapper around PHP's native session handling (`session_start()`, `$_SESSION`, not a custom session store) — `Lib\Session` (`novaconium/lib/Session.php`), all-static and lazy-start, same shape as the already-shipped `Lib\Csrf` (which also touches the native session; the two coexist in the same request without conflict). Ships `get()`/`set()`/`has()`/`remove()` plus CodeIgniter-style flash data (`flash()`/`getFlash()`) — a value set now that's readable on exactly the next request, then gone, for post/redirect/GET flows like `App/pages/contact/index.php`'s hand-rolled `?sent=1` (not refactored to use it in this change — cited in the original spec as the motivating example, not a mandate to touch working demo code). The flash mechanism is a single per-request swap (snapshot last request's flash bucket into an in-memory static on first touch, then clear the stored bucket so this request's `flash()` calls fill a fresh one for the request after), not a separate expiry/sweep pass — verified end-to-end across three real, separate HTTP requests sharing a cookie jar (not three calls in one PHP process), confirming a flashed value appears on exactly the next request and is gone on the one after. Foundational alongside SQLite groundwork — admin login (next up) depends on it for logged-in state. Documented at `/admin/docs/session` and in `AGENTS.md` next to the `Lib\Csrf`/`Lib\Db` sections. ### SQLite groundwork - **Type:** Feature - **Status:** Done - **Priority:** High - **Added:** 2026-07-12 - **Shipped:** 2026-07-14 Laid the groundwork for optional SQLite storage: `Lib\Db` (`novaconium/lib/Db.php`) is a thin, no-ORM PDO wrapper (prepared statements only, no string-interpolation helper ever, per `Lib\Input`'s existing documented security stance) so features that need persistence — 404 tracking (see Won't Do; superseded by Matomo before this shipped), admin login, blog tags, internal search, and anything future — have a common place to store data instead of ad hoc flat files. Data lives in a new top-level `data/` directory — deliberately outside both `public/` (would be web-accessible) and `novaconium/` (gets wholesale-replaced by the "Updating the framework" workflow documented at `/admin/docs/getting-started`, so anything persisted there would be destroyed by the next update) — gitignored per-file, with a tracked `.gitkeep`. Designed driver-agnostic (no SQLite-only SQL in the mechanism itself) specifically so MySQL support wouldn't need a retrofit — see that entry below (shipped 2026-07-14) for the connection/config/migration API, which superseded the single-connection shape (`db_driver`/`db_path`/ `db_migrations_dir` config keys) this entry originally shipped with. ## Won't Do ### 404 tracking - **Type:** Feature - **Status:** Won't Do - **Priority:** Medium - **Added:** 2026-07-12 - **Reason:** Matomo (see `/admin/docs/matomo`) already tracks 404 hits — `Renderer::renderNotFound()` sets `is_404 => true` and the tracking snippet tags the document title as `404/URL = /From = ` before `trackPageView`, so 404s are already filterable in Matomo under Behaviour → Page URLs. A separate in-app SQLite-backed 404 log would just duplicate what Matomo already captures, for no real benefit. Originally proposed as: log requests that hit the 404 page (path, timestamp, referrer, user agent) into SQLite, similar to Joomla's 404 log, with an `/admin` page to view them.