Replace the single Ecommerce entry with three composable Lib\ pieces (Money, Cart, payment gateway) instead of a framework-owned catalog/ checkout/order-admin system — a project's idea of a "product" is too site-specific to standardize, so it builds its own on Lib\Db like any other feature. Also removes four Done entries (In-house comments, Email verification, Media/file manager, User deletion & email addresses) that nothing open still depends on or references, per this file's own stated retention policy — their history remains in git log and the shipping commits. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
19 KiB
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 (aDepends 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 futuredocs/note for anything long enough to need one.
Entry template
### <Short title>
- **Type:** Feature | Bug
- **Status:** Backlog | In Progress | Done | Won't Do
- **Priority:** Low | Medium | High
- **Added:** YYYY-MM-DD
- **Depends on:** <other entry title(s)> — 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):
- Ecommerce: Lib\Money — no dependencies, and the other two Ecommerce pieces below both need it.
- Ecommerce: Lib\Cart — needs Lib\Money.
- Ecommerce: payment gateway helper — needs Lib\Money; independent of Lib\Cart, so it could also go before it.
- Paywall functionality — needs the payment gateway helper above for
its recurring-billing/payment 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), and Admin login & user management all shipped (see Done) — every open entry above still depends on at least one of them. The original single "Ecommerce functionality" entry was scoped down into the three Lib-helper pieces above on 2026-07-15 — see Lib\Money's entry for why.
See Won't Do below for 404 tracking, dropped in favor of Matomo.
Ecommerce: Lib\Money
- Type: Feature
- Status: Backlog
- Priority: Low
- Added: 2026-07-15
First and smallest piece of the former "Ecommerce functionality" entry —
scoped down (2026-07-15) from a full catalog/cart/checkout/order-admin
system to a handful of composable Lib\ helpers, since a project's idea
of a "product" is too site-specific to standardize; the project builds
its own catalog and admin UI on Lib\Db the same way it would for any
other feature, the same split Lib\Comments already makes for what a
"page" is. Lib\Money: integer-cents arithmetic (add/subtract/multiply
by a quantity) and formatting, avoiding the classic float-rounding bugs
of storing prices as floats. No dependencies — the foundation Lib\Cart
and the payment gateway helper below both need a non-lossy way to
represent an amount before either can be built.
Ecommerce: Lib\Cart
- Type: Feature
- Status: Backlog
- Priority: Low
- Depends on: Ecommerce: Lib\Money, Session handling (with flash sessions) (Done)
- Added: 2026-07-15
Second piece of the former "Ecommerce functionality" entry (see Lib\Money
above for the scoping note). A session-based cart primitive — add/remove/
update line items, line and cart totals via Lib\Money — riding on
Lib\Session the same way Lib\Csrf/Lib\AdminAuth lazily touch the
native session. Generic on purpose: the cart holds id/qty/price entries a
sidecar hands it, with no opinion on what a "product" is or where its
catalog data comes from.
Ecommerce: payment gateway helper
- Type: Feature
- Status: Backlog
- Priority: Low
- Depends on: Ecommerce: Lib\Money
- Added: 2026-07-15
Third piece of the former "Ecommerce functionality" entry (see Lib\Money
above for the scoping note). A driver-dispatched Lib\ class for taking
a payment, mirroring Lib\Mailer's mail_driver config-key pattern —
Stripe first (one charge()-shaped call plus webhook signature
verification), calling the provider's REST API directly via cURL rather
than vendoring an SDK, same reasoning as vendoring only Twig's src/
rather than pulling in a package manager. Adding a second provider later
means one more driver case, same as Lib\Mailer::sendMail().
Paywall functionality
- Type: Feature
- Status: Backlog
- Priority: Low
- Depends on: Ecommerce: payment gateway helper, 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
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()setsis_404 => trueand the tracking snippet tags the document title as404/URL = <path>/From = <referrer>beforetrackPageView, 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.