Lets a page under App/pages/ be previewed by an admin before the public
can see it: list its route in draft_routes (App/config.php), checked in
bootstrap.php alongside the existing /admin/* gate. Not authenticated ->
same plain 404 an unmatched route gets, not a login prompt, so a draft's
existence isn't revealed. Authenticated -> renders normally. No separate
login flow needed - Basic Auth credentials are scoped to the whole
origin/realm, so authenticating once at /admin covers draft URLs too.
AdminAuth::isAuthenticated() is extracted out of requireLogin() so the
draft gate can reuse the same credential check with a different failure
response (404 vs. a 401 challenge).
Renderer::render() gains an $excludeFromCache param so a draft without
its own sidecar can't get written to the static HTML cache - .htaccess
serves a cached file before PHP, and therefore any auth check, ever runs
again, so an uncached exception is required, not just the auth gate.
While testing this, found the same bug already existed for /admin itself:
novaconium/pages/admin/index.twig has no sidecar, so it was already being
cached - meaning any admin visiting /admin once caused the panel to be
served to everyone, unauthenticated, straight from
public/cache/admin/. Fixed in this change by excluding every /admin/*
route from the cache the same way, and documented as a standing rule in
AGENTS.md: any future mechanism that conditionally hides page content
from the public has to make the same check, not just gate the initial
request.
Closes the "Draft pages (admin-only preview)" backlog item in
novaconium/ISSUES.md.
Lib\Session (novaconium/lib/Session.php) is a thin, all-static wrapper
around PHP's native session handling — get/set/has/remove plus
CodeIgniter-style flash data (flash()/getFlash()): a value set now is
readable on exactly the next request, then gone, for post/redirect/GET
flows like the contact form's hand-rolled ?sent=1 (not refactored here —
the original spec cites it as a motivating example, not a mandate).
Lazy-start, same shape as the already-shipped Lib\Csrf, which the two
classes can share a native session with in the same request without
conflict. Flash data is a single per-request swap (snapshot last
request's bucket, clear the stored one) rather than a sweep/expiry pass.
Verified end-to-end across three separate HTTP requests sharing a cookie
jar (not just in-process calls), confirming a flashed value survives
exactly one subsequent request.
Closes the "Session handling (with flash sessions)" backlog item in
novaconium/ISSUES.md.
Redesigns Lib\Db from a single global connection to a config-driven map
of named connections (config['db_connections']), each independently
lazy-connected, each with its own optional migrations_dir and its own
schema_migrations table. A sidecar can use more than one connection in
the same request (e.g. Db::query(...) against SQLite alongside
Db::query(..., 'legacy') against MySQL) — sidecars have full access to
any Lib\ class, so nothing stops a request from needing two databases
at once, which the original single-driver spec didn't account for.
Db::query()/Db::connection() both default to the 'default' connection
name so the common single-database case is unchanged at the call site.
Supersedes the flat db_driver/db_path/db_migrations_dir keys shipped in
the SQLite groundwork commit (a3b9967) — no downstream consumers yet,
so no migration path needed.
db_connections needed one deliberate exception to the project's usual
shallow config-merge rule: merged one level deeper, by connection name,
so App/config.php adding a connection doesn't delete 'default'. Verified
end-to-end against a real local MariaDB instance running alongside the
existing SQLite connection, which caught a real ordering bug in the
initial merge implementation (capturing defaults after they'd already
been overwritten) before it shipped.
Closes the "MySQL support" backlog item in novaconium/ISSUES.md.
Lib\Db (novaconium/lib/Db.php) is a thin, no-ORM PDO wrapper — lazy-connect
like Lib\Csrf, Db::query() as the only query-running helper (prepared
statements only, no interpolation shortcut, per Lib\Input's existing
security stance). Plain .sql migrations under App/migrations/, applied in
filename order and tracked in an auto-created schema_migrations table, run
automatically on first connection or via novaconium/bin/migrate.php.
Data lives in a new top-level data/ directory rather than novaconium/ or
public/ — outside public/ so it's never web-accessible, and outside
novaconium/ since that directory gets wholesale-replaced by the "Updating
the framework" workflow, which would otherwise destroy it on every update.
New config keys: db_driver (only 'sqlite' implemented), db_path,
db_migrations_dir. Documented at /admin/docs/database and in AGENTS.md.
Closes the "SQLite groundwork" backlog item in novaconium/ISSUES.md.
Injects a hover-revealed copy button into every <pre><code> block
site-wide via a single layout partial, reading textContent so escaped
samples copy as literal text. Ships copy/check icons and matching Sass.
Passes icon markup to JS via <template> elements instead of Twig's
|escape('js'), which calls mb_ord() and fatals without mbstring — same
class of bug as the existing |slice/mb_substr gotcha, now documented in
AGENTS.md.
Closes the "Copy-to-clipboard button on code blocks" backlog item in
novaconium/ISSUES.md.
Full rewrite: swap out the v1 framework (src/, controllers/, views/,
twig/, sass/, skeleton/) for the working v2 codebase from phpproject
(App/, novaconium/, public/).
Documents project structure, Docker-only build/run commands, and
conventions so any coding agent (Claude Code, DeepSeek, etc.) can
orient quickly. CLAUDE.md imports AGENTS.md as the single source of
truth.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>