Every account created after the first must confirm a 24-hour link (Lib\Mailer::sendMail(), log-file or MailJet) before AdminAuth::attempt() allows login, folded into the same generic pass/fail as a disabled account or wrong password. First user and pre-migration rows are grandfathered; create-admin-user.php also auto-verifies for lockout recovery. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
24 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):
- In-house comments — admin login & user management (Done 2026-07-14) unblocked this; comments are tied to real user accounts, not anonymous.
- Ecommerce functionality — its dependencies (SQLite groundwork, session handling for the cart, admin login for order/product admin) are all done now.
- 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 <username> <email>). 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 <details> 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()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.