Files
novaconium/novaconium/ISSUES.md
T
code e699027b4b Add email verification for user accounts
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>
2026-07-15 19:23:20 +00:00

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 (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

### <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):

  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 <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 onAdminAuth 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 = <path>/From = <referrer> 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.