27 August 2026¶
52 changes:
- A select and a pager that need no form around them
- Four legacy input classes become one
- One search box over many entities
- A model save cost 1358 milliseconds
- Two flags that keep a migration honest
- reset() left the page where the next request would find it
- The form field now uses the controls
- Nine readers of a stream that can only be read once
- An API endpoint's status code was untestable
- A flash that worked once per process
- A permanent cache entry was deleted by the next sweep
- The dashboard's JSON endpoints returned a web page too
- The backup codes a user saved were never the stored ones
- Adding a member to an organization was impossible
- Any signed-in account could rewrite the system settings
- The lockout settings configured nothing
- The tailwind scaffold theme is a daisyUI theme
- A view directory you half-own rendered a page shell
- An argument a method does not declare is dropped silently
- The datatable search that could not be slowed down, and the columns that were all searched the same way
- Every page after a sign-in page lost its header
- Seventy-nine queries had lost the table prefix
- The Redis cache listing read a key nothing writes
- Vendored assets that were still remote
- A page could not call its own API
- One palette, every UI system
- The administration area has its own directory
- Six filings from one project, all small and all silent
- The palette moves under app/themes/
- Two guides that had become their own history
- A generated screen the vanilla stacks could not reach
- Every breadcrumb in the area pointed at /adminusers
- URL is the administration area, sURL is the site
- Organizations had no way to look at one
- A column filter per column, on the lists that need one
- What a usertype means, and how an application changes it
- Row actions are icons, and the row is a link
- The log viewer's own endpoints 404'd inside its iframe
- Everything the framework knows about a user, on the user's screen
- Per-user settings, and permissions edited where the user is
- A screen for the message templates the framework already had
- New-sign-in alerts got a site policy
- A login left the previous session's token valid for a month
- One definition of the guard every admin screen opens with
- What each usertype may do, written down and on a screen
- A correct password was refused, and bcrypt had been dropping characters
- No language was ever selected
- The administration area may be in another language than the site
- A second factor that needs nothing set up in advance
- A new device can be made to prove something, not just reported
- A second factor an application can bring its own of
- Seven account-security switches, every one off until asked for
A select and a pager that need no form around them¶
Two structural UI pieces the framework nearly had. Requested with counts — 37 files and 8 files in a consuming application — which is what made them framework work rather than application work.
Added¶
Html\Select — a <select> outside any form¶
$status = new \Pramnos\Html\Select('statusSelect', $current);
$status->addOption('All', '');
$status->addOption('Active', 1);
$table->addColumn('Status', true, false, true, '', $status->render(), …);
Html\Form\Field renders a <select> too and is the right thing for a field in a
form — it carries a label and its render() requires the form's style preset. The case
this fills has no form: a footer filter in a Datatable, whose rendered string is passed
to addColumn() as markup. 37 files do that, 35 of them through addOption(). Asking each
to construct a style preset in order to render one dropdown is the coupling that gets a
class copied instead of used.
The label is the first argument — addOption($label, $value). That is the legacy order
and it is kept deliberately: reversing it to the more obvious (value, label) would
compile, run, and render 35 files' dropdowns with labels and values swapped, with no error
anywhere.
Two things the implementation it replaces did not do:
- It escapes. Labels and values were concatenated into markup untouched, and the documented use is a filter populated from a database column.
- It compares as strings. The old
==answered0 == ''differently on PHP 7 and PHP 8, so a select with an "All" option of''and a current value of0selected a different option depending on the interpreter — precisely the kind of thing a version migration finds the hard way.
Html\Pagination — the presentation half of paging¶
$pagination = new \Pramnos\Html\Pagination($totalPages, $page, '/genres/p/:page');
$pagination->containerElementClass = 'results-pages';
$pagination->previousButtonText = '<img src="/img/prev.svg" alt="Previous" />';
$pagination->displayFirstLast = false;
QueryBuilder already covered the query side — forPage(), limit(), offset(). Nothing
turned "page 3 of 17" into links, so every application wrote that loop again.
The URL is a pattern with :page, not an appended query string, because paginated
listings are usually indexable pages: a crawler treats /genres/p/4 as a page while it may
treat ?page=4 as a variant of /genres. firstPageUrl gives page 1 its own address, for
the same reason.
Four defects in the 367-line implementation this replaces, all of them in its output, which is why they survived:
- the opening container tag was built as
'<' . $element . $class . '">', so with no class set it emitted<span">; - the per-item container was opened at both ends —
'<li>'where'</li>'was meant — so a list nested instead of listing; - every link carried
alt="", which is not valid on<a>, leaving image and ellipsis links with no accessible name at all; render()appended/:pageto its own property, so a template with a pager above and below a list produced/items/:page/:pageon the second call.
It also declines to render anything for a single page, keeps both ends reachable in a long
list, shows the elision dots only where something is actually hidden, drops
previous/next at the ends, clamps an out-of-range page from a URL, and labels each link
with aria-label/aria-current.
The button-text properties are not escaped, deliberately — they exist to hold an
<img>. That is the one place a caller's string reaches the page unfiltered, and it has a
test of its own so nobody "fixes" it later and breaks every arrow icon.
Not added, and why it is worth saying¶
The request named what it did not want, and that is the more useful half: chart
rendering, country and currency data, input/checkbox/colorpicker/time widgets, a form
builder, YouTube embedding, a payment-addon base class and a site-search registry. All of
them are application logic wearing a framework name. Within Html\, every remaining legacy
class is now either present or explicitly declined — the inventory gap is closed, not
merely reduced.
Documentation¶
- New HTML Components Guide: both classes, a table
of every component
Pramnos\Html\*ships, and — the question the request itself raised — when to reach forHtml\Selectrather thanForm\Field.
Four legacy input classes become one¶
Html\Select on its own was half a job: a standalone dropdown and no standalone text
field. This is the other half — and it is smaller than what it replaces.
Added¶
Html\Input — one control, no form around it¶
$search = new \Pramnos\Html\Input('q', $current);
$search->placeholder = 'Search…';
echo $search->render();
Covers text, hidden, password, email, url, tel, search, number, range,
date, time, datetime-local, month, week, color, checkbox, radio, file and
textarea. Documented in the HTML Components Guide.
The same reasoning as Select: Form\Field is right for a control in a form — it
carries a label, a title, validation state, and its render() requires the form's style
preset — and a filter above a table has no form to take a preset from.
Why it is one class and the legacy was four¶
The old input was a dispatcher. type decided, and for date, time, checkbox
and color it constructed a different class — checkbox, colorpicker, a time widget —
and forwarded a dozen properties into it. Most of that existed because those input types
did not work in browsers when it was written, so each needed JavaScript to fake it.
<input type="color"> and type="time" have been native for years. Once the widgets are
not needed, what is left is a tag with attributes, and a dispatcher with nothing to
dispatch to is just indirection.
Attributes appear only where they apply¶
The tests that matter most here are about attributes not being emitted:
- no
min/max/stepon a text field — invalid markup that browsers accept and validators reject, which is the kind of wrong nobody notices - no
valueon a file input — ignored by every browser, and the one type whose value could only have come from the server - no
idunless you set one — two controls for one field on a page is ordinary, and duplicate ids break<label for>andgetElementByIdsilently. The legacy generated one from the name plus auniqid(), so it also changed between renders - no
foron a label without an id — a screen reader announces the association and then finds no control - no trailing colon after a label. The legacy appended one unconditionally, which is a typographic decision that belongs to the page and could not be undone from outside
Values, labels and placeholders are escaped; the legacy escaped none of them, and a value here comes from a request or a database by definition.
What was deliberately left out¶
No validate / addcss / addjs. Those legacy properties made an element push CSS and
JavaScript into the document while rendering itself — so echoing a search box changed the
page's asset list. Document::addScript() and addStyle() are where a page declares what
it needs.
One search box over many entities¶
The legacy framework had a search class — a registry of providers, 66 lines. Bringing it
back as-is would have been a downgrade, because the part it did is now the part the
framework already does best. What was missing was the other half.
Added¶
Search\Registry — the aggregate, and only the aggregate¶
// app/search.php
use Pramnos\Search\Registry;
Registry::register('Users', \Pramnos\User\User::class, [
'display' => ['username', 'email'],
'url' => '/users/edit/:id',
]);
Documented in the new Cross-Entity Search Guide.
Searching one entity was never the gap. Every model implements ApiListSource, and
ApiListQuery turns a term into a query with paging, per-field search, dialect-correct
ILIKE/LIKE and honest totals — that is what a Datatable search field uses and what
create:crud generates. The legacy provider contract ($object->search($query)) did
strictly less than the interface every model already satisfies.
What no entity can implement for itself is one term across several entities. So that is all this class is: a list of sources and a loop over the engine that already existed.
Permissions, at two levels¶
The endpoint is guarded as a whole (guard('search')). Inside, per source and per row:
Registry::register('Orders', \App\Models\Order::class, [
'display' => ['reference', 'customer_name'],
'permission' => 'orders.list', // Auth\Gate, or fn($user): bool
'filter' => fn($user) => 'tenant_id = ' . (int) $user->tenantid,
]);
Three properties worth naming, because each is a decision that could have gone the other way:
- A hidden source leaves no trace — not an empty group. An empty group headed "Invoices" tells somebody who may not see invoices that invoices exist, how they are labelled, and that a search found none. It is also never queried.
- A filter callable must return a string. A
nullfrom a missing tenant drops the source rather than degrading to "no filter" — degrading would return every row of the table to a viewer entitled to a subset. - Grouped, not ranked. No score across sources: a relevance number comparing a
username to an invoice line is invented.
1/12beside a group is honest in a way a merged, re-sorted list is not.
Html\SearchBox and Omnibox.svelte¶
Both scaffold styles get a working box, not the parts for one. Server-rendered themes get
Html\SearchBox in the header — all three UI systems — with behaviour from
data-pf-omnibox in pf-utils.js; SPA projects get components/Omnibox.svelte, wired
into the app shell. One endpoint, one result shape, two front ends.
Both debounce and both abort the previous request. Without the abort a slow answer to
an lands after a fast answer to anna and replaces newer results with older ones, which
on screen looks like the search returning wrong matches rather than like a race.
create:crud offers to register the new entity¶
It asks. Registering every generated CRUD silently would decide what an omnibox may surface, by default, in the direction that leaks — an audit table and a join table should never appear in one.
Fixed¶
The SPA API client dropped AbortSignal¶
api.get(path, query, options) forwarded method, body, query, headers and
anonymous to fetch, but not signal. Any caller passing one got a request that could
not be cancelled and no indication of it — the abort silently did nothing. signal is now
forwarded, and an AbortError is no longer reported to the debug panel as a network
failure: a search box cancelling a request per keystroke would otherwise fill the request
tab with errors nothing went wrong for.
Not brought over from the legacy¶
The provider contract required each source to return objects with a render() method,
which put HTML inside models — so the admin's markup could only be changed by editing the
model layer. Display is configuration here: which columns, which URL pattern.
A model save cost 1358 milliseconds¶
Not in a pathological case. On every write, on every deployment, with an empty cache.
What was happening¶
Database::cacheflush() is called by Model::save(). It calls
Cache::clear($category), and FileAdapter::clear() ended like this:
cleanup() walks the entire cache tree — not the category being cleared, all of
it — and reads and unserialises every file to decide whether it has expired. So the
cost of writing one row was the cost of inspecting everything the application had
ever cached.
Measured on this project's container: 1358 ms per call. With the cache holding no files at all.
The second half¶
No files, and still 1358 ms. The walk was of 3064 empty directories.
cleanup() finished with cleanEmptyDirectories($this->cacheDir), and that method
begins:
It walks upward from a directory it is given, removing empties as it goes, and stops at the root. Handing it the root is a guaranteed no-op — so it had never removed anything. Every directory a cache write created stayed for good, and each one was walked again by every subsequent sweep. The tree only grew, and the sweep only slowed.
Two bugs, and each one hid the other: the sweep was slow because of the directories, and the directories accumulated because the sweep's cleanup call did nothing.
The fix¶
The sweep is sampled. Expired entries are not a correctness problem — load()
checks the timestamp before returning anything, so a stale file is never served.
The sweep only reclaims disk, and that is work to do occasionally rather than on
every write. It now runs on one clear() in a hundred.
And never under PRAMNOS_TESTING. A suite in which some calls sweep and others
do not is a suite whose result depends on a random draw: this framework has a test
asserting on cache contents that earlier tests left behind, and it started passing
or failing by luck the moment the sweep became occasional. The sweep is covered by
overriding the sampling method, which is the only way to test it without a coin.
cleanup() prunes properly, bottom-up, so a directory whose children have just
gone is seen as empty in the same pass.
1358 ms → 0.21 ms.
What it was costing¶
This surfaced as a test-suite measurement — the framework's suite was 12:28 and is now 3:19 — but the suite was only the place it was visible. Every model save in every deployment paid it, on a cache that had been running long enough to accumulate directories. Nobody profiles a save.
Notes¶
- Nothing to change in a project.
- A cache directory with thousands of empty subdirectories in it will be tidied by the first sweep that runs. Deleting them by hand is safe at any time.
- If you have your own adapter with a
clear()that sweeps, it has the same shape.
Documentation¶
Pramnos_Test_Suite_Performance.md— a dated section with this and the two other findings from the same measurement.Pramnos_Testing_Guide.md— a row in the "does not slow the suite down" table.
Two flags that keep a migration honest¶
Both of these came from the same place: an application deleting a legacy class and finding the replacement had made a decision for it.
Added¶
Pagination::$displayEdgePages — opt out of the pinned ends¶
$pagination->displayEdgePages = false; // … 8 9 [10] 11 12 … — no 1, no 20
$pagination->displayFirstLast = true; // « and » still reach both ends
Html\Pagination always showed the first and last page beside the ellipses, and the
changelog defended it: one click to either end is the point of a numbered pager over
previous/next alone. The default has not changed. What was missing was the opt-out.
The class it replaces had the equivalent flag, and the reporting application sets it off in 12 of 13 places while leaving the first/last buttons on in 9 of them — the ends stay reachable, and the number row keeps a constant width as the reader moves through it. Without the flag, adopting the framework's pager would have altered twelve search pages: a small change, but a product decision, and not one a library upgrade should make silently. The alternative on the table was keeping 367 lines of the old class for two links.
The ellipses stay honest either way, and that is the part that is not simply two ifs.
With 1 on screen a gap exists from page 3, so 1 … 2 would be a lie about a page that
is right there; with 1 off screen a gap exists from page 2, and omitting the dots there
would hide that page 1 exists at all. The threshold follows the setting.
Input::$minlength, $title and $inputmode¶
From an application replacing a JavaScript validation library with native constraints. Its
five validator types — none, email, integer, real, url — all already map onto types
Input has. Two attributes were what made the native path incomplete:
$code->pattern = '[0-9]{4}';
$code->title = 'Four digits, e.g. 1234';
$code->minlength = 4;
$code->inputmode = 'numeric';
minlength—maxlengthwas emitted and its sibling did not exist even as a property. The asymmetry was the finding: a field could declare a ceiling and not a floor, though both are halves of one HTML constraint. Emitted under the same rule, on the textual types only.title—patternwas emitted without it, so a failed pattern showed "Please match the requested format". That tells the user they are wrong and not what right looks like, which is validation that fails without explaining. Not restricted to textual types: it is also the only waymin/maxon a number explains itself.inputmode— not validation, the mobile counterpart ofpattern. A field that accepts only digits should not open a QWERTY keyboard, andtype="text"with a numeric pattern is exactly the combination that does.
extraAttributes could carry all three, but it escapes nothing, and a title is text
written for a person — precisely the kind of value that needs escaping.
Error message text stays the browser's, in the browser's language. That is more correct than a framework shipping its own translated strings, and was not asked for.
reset() left the page where the next request would find it¶
A login page 1.7 MB long, carrying a hundred copies of an inline script from a screen it has nothing to do with. Every assertion against it passed.
What was happening¶
Document::reset() did this:
Which looks complete, and is not. The page body does not live on the instance.
Every concrete document type — Html, Raw, Json, Amp, Png — reads it from
a static buffer that addContent() appends to. Discarding the instances left that
buffer exactly where the next document would find it.
So each request added its page to the one before. Measured in a project's suite: a login page grew by about 2.9 KB every time it was requested, and by the end of a run it was 1.7 MB.
Why it went unnoticed for so long¶
Because it makes tests pass.
assertSee() on a page that carries every page before it succeeds on content the
test never asked for — a test written to prove that /login shows a password field
passes if any earlier page had one. assertDontSee() fails on a page the test has
already left, which at least gets investigated. The passing half does not.
It had been worked around three times in one project without being recognised. A security assertion — "the status page publishes no host measurements" — was scoped to the page's own markup because the whole response contained the admin dashboard. A "the revoked session is gone from the list" assertion was rewritten to query the database instead. Each looked like a quirk of that particular page.
The fix¶
reset() is what DocumentIsolation and TestClient call between requests, so
one line covers both.
The same shape one layer up¶
View::display() returned $this->output, and getTpl() appends to it.
The appending is deliberate — it is how a caller renders several templates into one
buffer with successive getTpl() calls. display() is not that caller: it renders
one template and returns the result. Returning the accumulated buffer gave it
everything that view had ever rendered, and a view is cached per controller, a
controller per application. Any process that serves more than one request gets the
previous pages in front of this one.
display() now starts from empty. getTpl() still appends.
Notes¶
- Nothing to change in a project.
- In production this is mostly invisible — one request, one process — which is why it survived. It is real for anything that renders twice: a worker, a long-running server, a controller that displays two views.
Documentation¶
Pramnos_Testing_Guide.md— the "one client, many requests" material now names the static buffer as the deeper half of the accumulation.
The form field now uses the controls¶
Html\Input and Html\Select were added for controls with no form around them. The
field inside a form went on building its own tags — and the two copies had already
drifted apart in a way that only a validator would have told you about.
Fixed¶
Form\Field emitted min, max and step on every type¶
min on a text field is invalid markup. Every browser accepts it and ignores it, so
nothing looked wrong — which is why it survived. Html\Input has never done this: it
restricts the numeric attributes to the types that can use them, and that rule simply did
not exist in Field's own copy of the loop.
Changed¶
Form\Field builds its control with Html\Input and Html\Select¶
One rule about which attributes a type may carry, instead of two that agree until they do not.
Field keeps everything that is about being in a form: the label, the description,
the required marker, the style preset, effectiveValue(), readSubmitted(), an id
defaulted to the name — Input refuses to invent one, correctly, but a form field is the
case where <label for> requires it — and the checkbox's hidden companion, without which
an unchecked box submits nothing and a setting can be switched on but never off.
Two things had to be kept deliberately on this side of the boundary, and both have a test saying so:
Field's own type vocabulary.datetime→datetime-local,image→text,textfield→text.Inputhas never heard ofdatetime, and its documented answer for an unrecognised type istext— so handing the raw type over would have turned a date-time picker into a text box and an upgrade into a bug report about a disappearing calendar. The type is resolved before it is passed on.- The checkbox's broader idea of "on". Anything that is not
'0'or'', because a setting stored as1,yesortrueall mean the same thing here.Inputtests equality against itscheckedValue, which is right for a filter and wrong for this.
The attribute order in the rendered markup has changed — name now precedes id, and
the preset's classes come last. No signature changed, and the only consumer in the
framework is SettingsForm (via Theme::addSetting()), whose tests pass unchanged. Worth
stating for anything asserting on exact output.
Added¶
Form\Field gains the native-validation attributes¶
pattern, title, minlength, maxlength, placeholder, autocomplete, inputmode —
the properties Input already had and this class could not express at all.
Changed — a rename, and one silent break¶
Field::$title is now the HTML title attribute. The label is $label.
$title meant label here, inherited from the legacy form class. Rather than work around
that — an intermediate commit today offered the attribute as $tooltip, which preserved
the collision instead of resolving it — the label was renamed and title now means what
it means in Input, Select and HTML itself.
What this costs, stated precisely:
- Positional construction is unaffected.
new Field('email', 'Email')and everyaddField('email', 'Email', …)keep working — which is every caller in the framework and every documented example. $field->title = 'Email';breaks silently. It now sets a tooltip and leaves the label auto-generated from the field name. Nothing errors. Change it to$field->label. This is the one case worth grepping for.addField(name: 'x', title: 'Y')breaks loudly — PHP refuses an unknown named parameter. That is the intended outcome and it has a test: a stack trace naming the property beats a page rendering the wrong label.
Html\Select gained $title in the same change, so a control in a form explains itself
the same way whichever kind it is.
FieldTest¶
Field had no direct tests — it was covered only through SettingsForm. 25 now, over the
delegation boundary: what must survive it, what must not come back, and that the rename
above is refused rather than misread.
Nine readers of a stream that can only be read once¶
php://input is a stream. Nine places in the framework opened it independently, and
whichever of them ran second saw a request with no body.
Fixed¶
Pramnos\Http\Request::rawBody() is now the one place the raw request body is read,
and it reads once and keeps the result.
The nine readers were Auth\Controllers\Capabilities, Passkey, Account, ApiAccount,
Gdpr and Oauth, plus User\Token, Application\Api and Webhook\WebhookHandler. Each
called file_get_contents('php://input') for itself. That works when it happens once. It
does not survive a second reader:
- for
multipart/form-data, PHP has already consumed the stream — the read returns an empty string under every SAPI; - with
enable_post_data_readingoff, the first read drains it and every later one is empty; Oauthwas worse than the rest: it handed League an open handle to the stream (createStreamFromResource(fopen('php://input', 'r'))), so anything that had read the body first left the token endpoint with a body positioned at EOF.
The failure shape is the same in each case, and it is the misleading kind — the request
arrives complete and the handler reports the payload as missing. A capabilities manifest
refused as Malformed or missing JSON manifest. A token request refused as
invalid_request with client_id, grant_type and client_secret all present. Nothing in
either response points at the body having been read somewhere else first.
Request::rawBody() also returns a string, never false. A false from
file_get_contents() fails an if ($raw === '') test for "no payload" and goes on to
json_decode(), which turns what should be a 400 into a 500.
The cache is per request: Request::resetInstance() clears it, so a worker serving several
requests in one process does not answer with the previous one's body.
The second half: those paths were untestable¶
Request::setRawInput() has always been the documented way to supply a body in a test. A
handler reading php://input for itself cannot see it — so the body-reading branch of all
nine could not be exercised by a test at all, which is why the bug above lasted. Routing
them through rawBody() makes setRawInput() reach them, and the branch testable:
Request::setRawInput('{"resources":[{"name":"invoices"}]}');
// the capabilities endpoint can now be called end to end in a test
ApiAccount::rawBody() lost its @codeCoverageIgnore for the same reason: it is now
reachable.
Documentation¶
Pramnos_Framework_Guide.md— "The raw body, when you need the bytes", under Reading the request body.
An API endpoint's status code was untestable¶
The body of an API response has always been assertable. The status was not, and the status is the half a client acts on.
Added¶
Pramnos\Application\Api::$lastStatusCode — the HTTP status the last dispatched request
answered with, set for every dispatch whatever the SAPI.
Under CLI the kernel deliberately does not emit the status: http_response_code() has
nowhere to put it, and calling it there is noise. So a test dispatching a request through
Api could read the body and nothing else — and 400 "you sent no credentials", 401 "they
were wrong" and 405 "wrong verb" are three different instructions to a client that can all
carry a body of the same shape. A test asserting only on the body cannot tell them apart,
which means an endpoint whose status changed silently kept passing.
$api = new \Pramnos\Application\Api();
$api->init();
$api->exec();
$status = $api->lastStatusCode; // null before the first dispatch
Set for both response kinds — a Response object's own status, and the status inside the
legacy array/string envelope. A middleware short-circuit (missing or invalid API key) never
reaches the dispatch and puts its status in the body instead, which is the documented
fallback.
Documentation¶
Pramnos_API_Guide.md— "Testing an endpoint's status code".
A flash that worked once per process¶
Request::resetInstance() cleared the request's derived state but not its captured flash bag,
so the second request served by a process got the first one's — already consumed — messages.
Fixed¶
Request::resetInstance() now clears the captured flash and validation state —
$flashMessages, $flashErrors, $validationErrors and $oldInput — nulled rather than
emptied, so the next reader loads what is in the session at that moment.
The flash bag is captured once per request and the session keys are unset as they are read; that is deliberate, and it is what lets a controller and a template both read a message without one eating the other's. What was missing is that the capture is per request. Left behind, it answered for the next request too, with contents that had already been consumed.
One process, one request hides this completely, which is why it lasted. Anything serving more
than one sees it: a queue worker, a daemon, and every test that makes two requests. What it
looks like from outside is a flash mechanism that works once and then goes quiet —
addMessage() writes to the session, the redirect lands, and the page renders with no
message. Indistinguishable from a save that silently did nothing.
It also made the flash untestable end to end, which is the second half: a test can only reach the "after the redirect" state by making two requests, and the second one could never see what the first one flashed.
Documentation¶
Pramnos_Framework_Guide.md— Three things to know about the mechanism, under Flash messages.
A permanent cache entry was deleted by the next sweep¶
timeout = 0 means "never expires" — save() documents it and load() honours it. The
file adapter's expiry test did not, and it is what the garbage collector asks.
Fixed¶
FileAdapter reads timeout = 0 as never expiring, everywhere.
checkIfFileIsExpired() compared filemtime($file) < time() - $details['timeout'], with no
guard on the timeout. With a timeout of 0 that is true of every file written more than a
moment ago — so cleanup(), the sampled garbage collection, deleted exactly the entries a
caller had asked to keep permanently.
Sampled, which is what made it hard to attribute: a permanent value survives for a while and
then is gone, so it presents as "the cache does not work" rather than as a rule about zero.
load() had the guard ($timeout > 0 && …) all along, which is why the same value could be
readable and yet be deleted by the next sweep.
And getAllItems() reports the seconds actually left.
It returned 'ttl' => $isExpired ? 0 : -1 — a boolean widened back out into the field that
is supposed to carry a duration. Every live entry read as -1, "never expires", so the cache
browser's TTL column said nothing expires: the one thing that column exists to say, said
wrongly, on the screen an operator opens to find out when a value will be dropped. An entry
saved to be permanent was listed the other way round, as expired, in red.
The expiry was always computable — filemtime + timeout — and both readers now go through one
remainingTtl():
ttl |
Means |
|---|---|
| a positive integer | seconds left |
-1 |
never expires |
0 or negative |
past its timeout — expired is true |
| absent (null) | the file is not a readable cache entry; it is never deleted |
Documentation¶
Pramnos_Cache_Guide.md— Cache with Timeouts, and a new WhatgetAllItems()reports.
The dashboard's JSON endpoints returned a web page too¶
Six endpoints set Content-Type: application/json, echoed their payload, and then let the
request render the theme. r.json() throws on that, so every AJAX widget on the admin
dashboard was quietly failing.
Fixed¶
A JSON action must switch the document, not only the header.
DashboardController::activeusers(), apistats(), dbstats(), cacheitem(),
clearcache() and ServicesController::status() now call
Factory::getDocument('json') before echoing.
Without it the action echoes, returns, and the request goes on to render the theme — so the response is the JSON followed by a complete HTML page. The status is 200 and the body begins with exactly the right JSON, which is why nothing looked wrong from the server side. What a person saw was a widget whose numbers never appeared.
It is also why a unit test did not catch it: those tests capture the echo with ob_start(),
and the page that follows is emitted later, by the application. The new test asserts on the
document type instead — the mechanism that prevents the page.
The cache browser's View button never worked.
cacheitem() read its key with Cache::load($key), and the two keys are not the same thing:
getAllItems() reports the key an entry is stored under, while load() builds a storage
key out of a logical id and the instance's category. So the endpoint was handed the first
and looked up the second, and answered Item not found or expired for every entry listed on
the page — a screen whose whole purpose is to show what is in the cache, unable to show any
of it.
It now reads through the adapter, by storage key, with the namespace the row was listed
under (?key=…&namespace=…, added to the three bundled themes' cache view). Without the
namespace the adapter falls back to splitting the key on _ to find the directory, which is
right only for a single-word category — schema_columns_users resolves to schema.
The read passes a timeout of 0 so it applies no expiry of its own: this is a viewer, an
expired entry is exactly what somebody is looking for when they open it, and the list already
marks it as expired.
Contract note. GET dashboard/cacheitem now expects the storage key, as the browser
lists it. The previous behaviour cannot be preserved because it did not work: no key the page
displays was ever resolvable.
Documentation¶
Pramnos_Document_Output_Guide.md— "A JSON endpoint inside an MVC controller".Pramnos_Cache_Guide.md— whatgetAllItems()reports, and reading one back.
The backup codes a user saved were never the stored ones¶
Three faults in one flow, and each of them removes the recovery path or the step-up check that two-factor authentication is there to provide.
Fixed¶
The codes on the setup screen could never work¶
startSetup() generated ten backup codes and the setup screen listed them under "store
these in a safe place — they will not be shown again". Then completeSetup() generated ten
different codes, hashed those, and stored them. The plain set it stored was dropped on the
floor.
So the account's real recovery codes were known to nobody. The page enrolment redirects to
says "Setup complete. Save your backup codes before leaving this page" and had none to show
— it only ever populated newBackupCodes after a regeneration. A user who followed the
instructions exactly ended up with ten codes that could never work, and found out the first
time they lost their phone.
startSetup()no longer returnsbackup_codes; the key is gone rather than misleading.completeSetup()keeps the plain codes, andtakeNewBackupCodes()hands them over once (through the session, so they survive the redirect; cleared on read).TwoFactorAuth::backup()populates the view with them on?setup=complete.- The three bundled themes' setup views say the codes are coming after verification instead of listing a set that is about to be replaced.
Showing them after verification is also the right moment: somebody who abandons setup halfway should not walk away holding recovery codes for an account with no second factor.
disable() ignored the password it was given¶
TwoFactorAuth::disable() collects the account password and calls
$service->disable($userId, $password). The service took one parameter, and PHP
discards extra arguments to a userland function — so nothing was verified. Any signed-in
session could turn the second factor off with an arbitrary string, and the controller's
"That password is not correct" branch was unreachable: the service returned false only when
the account had no 2FA row at all.
A stolen session cookie is exactly what a second factor is for. A step-up check that does not check is worse than none, because the screen in front of it says otherwise.
regenerateBackupCodes() ignored it too¶
The same discarded argument, and destructive as well as disclosing: rotating the codes invalidates every code the account's owner had written down, and prints ten new ones to whoever asked.
Both now take ?string $password = null and verify it when it is supplied:
| Call | Meaning |
|---|---|
disable($userId, $password) |
the user's own action — refused on a wrong or empty password |
disable($userId) |
administrative — an operator clearing 2FA off an account whose owner cannot |
An empty string counts as wrong, not absent. null is absent, and means the caller's own
authority is the authorisation — which keeps the administrative recovery path working.
User::verifyPassword() on an account with no password¶
It passed a null hash to password_verify() — a deprecation on PHP 8.4 and an error later,
and a comparison against nothing in the meantime. Accounts in that state are ordinary: one
created by an administrator, or provisioned by an SSO run, and never given a password.
Now refused before the call.
Documentation¶
Pramnos_Authentication_Guide.md— TwoFactorAuthService — full setup flow.
Adding a member to an organization was impossible¶
The screen has an Add Member form and a Remove link on every row. Neither could run, and both reported success.
Fixed¶
Organizations::addmember() never received the organization id. The form posts to
organizations/addmember/{id} and carries only userid. The action read the id from its own
route argument — and the classic dispatcher passes the request's arguments array to every
action, so (int) of it was never the id. $_POST carried no org_id either, so the id was
0: the screen answered "No valid entries were selected" and redirected to
organizations/0/members.
members() had it right all along, reading Request::staticGetOption(). Both member actions
now go through one idFromRoute() that does the same.
removemember() could not receive two ids at all. The link was
removemember/{orgId}/{userId}, and the framework's URL parser turns action/a/b into
$_GET['a'] = 'b' rather than into two options — so the second id arrived as neither an
argument nor an option. The link is now removemember/{orgId}?userid={userId} (updated in
all three bundled themes), and the user id is read from the request only. Not from
staticGetOption(): that is the organization segment, and resolving both the same way made
them equal — the update matched no row and the screen still said "Removed."
A removed member stayed on the list. removemember() keeps the row and sets
is_active = 0 for the audit trail, and members() selected every row regardless. So a
removed member remained on screen, indistinguishable from one who still has access: the
button looked broken, and the page answered "who is in this organization" with everyone who
ever was. Now filtered to active memberships.
And "Removed." was reported whether or not anything was. A second click, a back button, a link bookmarked before somebody else removed them — all matched no row and still reported success. It now says "That person is not a member of this organization."
Documentation¶
Pramnos_Framework_Guide.md— a new Reading an id out of the URL under Controllers: an action's parameters are not URL segments, one id per path, and the rest as query parameters.
Any signed-in account could rewrite the system settings¶
SettingsController had no usertype floor. addAuthAction() requires only being signed in,
and the administration area's floor does not cover a path that skips the prefix.
Fixed¶
SettingsController now declares requiredUserType = 80 and calls
requireMinUserType() in every action — display, saveSystem, list, edit, save
and delete.
It was the only administration controller without one. Dashboard, Users, Organizations, Logs
and Services all carry their own floor; this one declared its actions with
addAuthAction() and nothing else, so being signed in was the whole check. An account
created a minute earlier could:
- read the settings form, which renders the SMTP host, user and password into fields;
POST /Settings/saveSystemand rewritesite_url,forcessl,admin_mail, every SMTP field and the login lockout rules;- and reach the raw editor behind it (
list,edit,save,delete), which is the same settings by another screen.
The administration area's floor did not cover it, and could not. AdminArea strips the
prefix before routing, so /admin/Settings and /Settings are served by the same
controller. /admin/settings correctly refused an ordinary account with a 302; /settings
answered 200 with the form. A floor that applies to requests arriving through a prefix
protects exactly the paths that nobody has to use.
The test subclass in SettingsControllerIntegrationTest had overridden
requireMinUserType() with return false; // bypass for tests all along — somebody expected
the floor to be there. It was not.
Documentation¶
Pramnos_Routing_Guide.md— What changes inside the area now states plainly that the area's floor is defence in depth and never the check, with this as the example.
The lockout settings configured nothing¶
The settings screen has an editor for the progressive lockout ladder, validation for it, and a warning when it clamps what you typed. None of it reached the lockout.
Fixed¶
Loginlockout now reads loginlockoutsteps and loginlockoutwindowseconds.
calculateDuration() consulted self::DEFAULT_STEPS and nothing else, and the window
arithmetic used DEFAULT_WINDOW_SECONDS directly. So an operator could tighten the ladder,
the page would confirm "Settings saved.", and every account kept locking on the shipped
3/5/7/10 → 60/300/900/3600. The two settings were written by the form, validated by the
controller, warned about when clamped — and read by nothing.
It is the kind of gap that only shows up from the outside: every unit test of
calculateDuration() passed, because they assert the defaults, and the defaults were all it
ever used.
An unusable loginlockoutsteps — not JSON, empty, or with no usable attempts: seconds
pair — falls back to DEFAULT_STEPS, and a window outside 60–86400 falls back to 900.
Never to an empty ladder: a malformed setting must not be a way to switch brute-force
protection off, and a window of zero would reset the counter on every attempt, which is the
same thing by another route.
Documentation¶
Pramnos_Authentication_Guide.md— a new Configuring the ladder under Login Lockout.
The tailwind scaffold theme is a daisyUI theme¶
Seventy-one bundled views, rewritten from hand-built Tailwind utilities onto daisyUI components — and onto daisyUI's tokens, which is the half that makes a dark theme possible.
Changed¶
scaffolding/themes/tailwind now renders through daisyUI 5. btn btn-primary rather
than a hand-built px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-sm;
card bg-base-100 border border-base-300 rather than bg-white border border-gray-200;
alert alert-error, navbar, menu, table, input, badge.
The colours are the point. A component carries whichever theme is active; a utility carries
one palette — so bg-white and text-gray-700 are invisible or unreadable under
data-theme="dark", and nothing in any log says so. The page renders and the text is simply
not there.
- Light and dark both ship. A toggle in the header stores the choice;
head.phpapplies it todata-themebefore the first paint, inline and synchronous — deferred, it paints light and then flips. auth_brand_primary_colornow reaches the buttons. It used to be an inlinebackground-coloron one button per auth screen, which is both a CSP-relevant inline style and an override of whatever the theme said. It now sets--color-primaryon the card, so every daisyUIprimarycomponent on that screen follows the brand.style.cssreads tokens. The breadcrumb and omnibox blocks had literal greys and whites; they now read--color-base-100,--color-base-content,--color-primarywith literals only as fallbacks.- A guard test.
AdminUrlInViewsTestnow fails when any bundled view carries a hardcoded palette class. This theme already had Bootstrap classes leak into it once, by the same route — a view edited without the theme in mind.
No build step¶
daisyUI 5 is a Tailwind plugin, and a plugin needs module resolution, which Tailwind's
browser build cannot do. So @plugin "daisyui" is not available to a scaffolded project,
and a scaffolded project has no npm. What it uses instead is the prebuilt stylesheet daisyUI
publishes for exactly this case — every component and both token sets in one file — vendored
locally next to the Tailwind runtime by init and by project:switch-ui.
Order in head.php is load-bearing: the browser build, then daisyui.css (components, in a
daisyui sublayer of utilities), then style.css. Tailwind's utilities override a
component's defaults; the project overrides both.
An application in front of real traffic should compile instead —
@import "tailwindcss"; @plugin "daisyui"; into www/assets/css/style.css, with its own
themes as token blocks. The markup does not change, because it was written against tokens
rather than against a palette.
Documentation¶
Pramnos_Theme_Guide.md— a new The bundled scaffold themes, which is also the first timeproject:switch-uiis documented in a guide rather than only in a changelog post.
A view directory you half-own rendered a page shell¶
The scaffolding fallback worked on view directories. So an application that owned one template in a group and not the others got an empty page for the others.
Fixed¶
View::getTpl() now falls back to the bundled scaffolding per template.
Controller::getView() has always fallen back to the bundled theme — but it does so when it
cannot find the view directory, and the template lookup had no fallback at all. So the unit
of inheritance was the whole directory:
src/Views/services/logs.html.php ← the application owns this one
src/Views/services/services.html.php ← absent
getView('services') matched the directory, getTpl('services') did not find the file, and
the services list came back as a page shell. Status 200, chrome, no panel, and one line in a
log nobody reads. Any project that customised one screen out of a group was in this state for
the rest of the group.
Which is the shape a project actually wants inverted: keep the screens you rewrote, inherit the others — and get their fixes with the next framework update rather than copying them again. The lookup order is unchanged otherwise:
- the theme's
views/<view>/<tpl>.html.phpoverride - the application's
src/Views/<view>/<tpl>.html.php - the bundled
scaffolding/themes/<scaffold_theme>/views/<view>/<tpl>.html.php
Silent when there is nothing to find, so the existing "cannot find view template" log — the one that says where the lookup came from — stays in charge of that case.
Verified against a real application: 39 of its admin views deleted, its suite of 248 tests still green, every screen served from the bundled theme.
Documentation¶
Pramnos_Theme_Guide.md— a new Inheriting a bundled view, one template at a time.
An argument a method does not declare is dropped silently¶
Two password checks were skipped that way. So the codebase was scanned for every other call site doing the same thing, and the step-up checks were changed so the check cannot be skipped by omission either.
Changed¶
TwoFactorAuthService::disable() and regenerateBackupCodes() now require the
password. The earlier fix made it optional, which closed the call site that had the bug and
left the hole open for the next one: omit the argument and nothing is checked, silently.
A step-up check in front of removing the second factor is not something to skip by accident, so skipping it now has a name:
| Call | Meaning |
|---|---|
disable($userId, $password) |
the user's own action — refused on a wrong or empty password |
disableForOperator($userId) |
administrative — an operator clearing 2FA off an account whose owner cannot reach it |
regenerateBackupCodes($userId, $password) |
the user's own action |
regenerateBackupCodesForOperator($userId) |
administrative, and destructive: it invalidates every code the owner holds |
A call with no password is now a TypeError before any code runs, which is the strongest
form the guarantee can take — and a test asserts the signature, not just the behaviour.
Fixed¶
The cache dashboard's "Categories" tile showed the number of items.
FileAdapter::getStats() called listDirectoryFiles($path, true), and that method takes one
parameter — so PHP dropped the true and returned the same recursive file list as the line
below it. The two tiles were always the same number, in all three bundled themes and in the
DevPanel. It now counts through getCategories(), which already listed the directories.
Auth::useraccess() was being handed two arguments it did not declare.
User::hasaccess() passes eight to a method that took six, so $extraflag (deprecated, and
declared as such by setaccess()) and a $nonExistEqualsFalse flag were discarded. The
flag matters: it distinguishes "denied" from "no rule was written", which is what a caller
wanting to fall back to its own policy needs — and it could not be asked for. Both are now
declared, and the flag is passed through to Permissions::isAllowed().
The signature is longer, and no caller has to change: both additions are optional. The
contract test that pinned useraccess() at six parameters now pins what actually matters —
each parameter's name and position, and that anything added at the end is optional. A
bare count forbids exactly the change that fixes this, while allowing a rename that would
break every caller.
Not a bug: Controller::display()¶
The scan also flags exec() calling $this->display($args) against a base display() that
declares nothing. Declaring the parameter is a fatal must be compatible with for every
existing function display() with no argument — LogController in this framework included.
The discarded argument is the mechanism that makes the parameter opt-in, and a controller
that wants the arguments declares display(array $args = []) and gets them. Documented in
place so the next reader does not "fix" it.
Controller::getView()'s $args is the third case: advertised, passed to a private method
that did not declare it, and consumed by nothing downstream. Declared and documented as
accepted-and-unused rather than left to be discovered.
Documentation¶
Pramnos_Authentication_Guide.md— the four management calls and why the password is not optional.
The datatable search that could not be slowed down, and the columns that were all searched the same way¶
Two filings from a consuming application migrating off its legacy datatable classes, both blocking: 709 lines with 117 constructions in one case, 571 lines with 128 calls in the other.
Added¶
Datatable::$searchDelay (default 500). Emitted in both places the table debounces:
the footer filters' own keyup handler, where var ms = 500 was hardcoded, and DataTables'
searchDelay option for the global box, which was not emitted at all.
It is not one number for every table. The application that asked sets 1200 on its heaviest
admin list — six LEFT JOINs — and 600 on two reports. With a fixed 500 the query rate on
exactly the heaviest lists is the one nobody can lower.
Three parameters on Datasource::addField() — ignoreOnOthertypes, min, max — and
$groupBy as the thirteenth parameter of render() and getList().
The three had to be declared, not merely tolerated: render() passes a field's details
back through call_user_func_array(), and an associative array becomes named arguments
on PHP 8. So a key the signature does not name is Unknown named parameter, and the
application's suite failed on $ignoreOnOthertypes the moment it tried the modern class.
Fixed¶
The global search treated every column the same way. It was LIKE '%term%' on every
searchable column, which ignored everything addField() had been told. Now each column
decides whether the term applies to it:
format |
Applies when | How |
|---|---|---|
email |
the term is a valid address | LIKE |
phone |
it looks like a phone number | LIKE |
numeric / number / int |
numeric, and within min/max by value |
= |
| anything else | unless ignoreOnOthertypes and the term is a number or an address; within min/max by length |
LIKE |
Each is a real cost, not a nicety. LIKE '%5%' on a numeric column matches 5, 15, 50 and
1523 — a search for an id returning a page of unrelated rows, which is what "the search box
does not work" looks like. Searching every free-text column of a wide list for 12345 costs
a scan each and returns noise. And a column's startWildcard/endWildcard were stored and
then ignored: a leading % is what makes an index unusable, so a caller turning it off was
asking for something real and getting the opposite.
A grouped query's count counted the wrong thing. QueryBuilder::count() preserves a
GROUP BY — it documents that — so COUNT(*) returns the size of the first group rather
than the number of groups, and a pager built on it promises pages that are not there. The
counts are now taken before the grouping is applied, as
COUNT(DISTINCT <the grouped columns>). This is why $groupBy is a parameter rather than
something to smuggle into the where string: the application that asked had two forks of
this class whose only difference was that argument.
Documentation¶
Pramnos_Html_Components_Guide.md— a new Datatable, and its Datasource, and a Breadcrumb section for the heading reset a theme has to carry.
Every page after a sign-in page lost its header¶
The chromeless login layout is selected by writing to the theme object. The theme object is cached for the whole process.
Fixed¶
Theme::reset(), called from Document::reset().
Theme::getTheme() caches by name, so one theme object serves every request in the process.
Pramnos\Auth\Controllers\Account calls setContentType('login') on it to select the
chromeless login.php layout — correctly, for the sign-in page — and nothing put it back.
So every page rendered after a sign-in page in the same process came out with no header
and no footer, and with the login layout's asset list: the navigation simply absent, status
200, nothing in any log.
One process, one request hides it completely, which is why it lasted. A worker, a daemon and
every test that visits /login and then anything else see it — and that is how it was found:
a test asserting what the public header contains failed only when an earlier test in the same
class had rendered a login page. The page under test rendered perfectly; the header was gone.
This is the fourth of the same family, and the shape is worth naming: per-request state on
an object that outlives the request. The others were the document's static content buffer
(2026-08-27), the captured flash bag, and the raw request body. Document::reset() now
clears the theme too, because a document carries one — resetting documents while leaving the
theme was resetting half of it.
Documentation¶
Pramnos_Theme_Guide.md— underlogin.php— the standalone layout.
Seventy-nine queries had lost the table prefix¶
QueryBuilder::table() substitutes #PREFIX# and leaves a bare name exactly as written. So
on any installation with a table prefix — the default — seventy-nine framework queries read
a table that does not exist.
Fixed¶
Every framework query names its tables with #PREFIX#. 79 occurrences across 19 files:
usertokens (40), applications (20), userdetails (11), users (6), sessions (2).
Reported by a consuming application whose suite produced 97 failures on its first
migration attempt, all Table '….users' doesn't exist, the first of them from simply
constructing a user. Ten of the 79 were in User\User and three of those were in its
constructor, so on a prefixed installation the class could not be instantiated.
User\User now resolves its two configurable tables in one place each. It computed
DB_USERSTABLE into a property and then had ten queries write 'users' themselves — six
lines referenced the resolved name while ten bypassed it. The same for DB_USERDETAILSTABLE
and five more. Both go through a private static accessor now, which is also what makes
them usable from getUsers() and getuserid(), the two static methods among the ten.
A constant that only some queries honour is worse than no constant: it works until somebody sets it.
Two smaller consequences of the same reading:
getTableNames()reportednullfor the users table when the object had been constructed with a user id — the constructor returns early on that path (return $this->load($userid)) and never reached the assignment.- Two guide examples taught the mistake:
Pramnos_Database_API_Guide.mdshowed->from('users'). Corrected.
Why the suite could not catch it¶
Both test fixtures declare 'prefix' => '', which makes #PREFIX#users and users the
same string. Every test passes either way — so no amount of running the suite tells you a
query has lost its prefix, and 79 of them accumulated under a green suite.
Pramnos_QueryBuilder_Guide.md already documented the rule, and even predicted this exact
failure: "It works on the developer's machine and finds nothing on the installation that has
one." Writing it down was not enough.
tests/Unit/Database/TablePrefixInQueriesTest.php is therefore a static check on the
source. It fails on any bare occurrence — in a table() / from() / join position — of a
name the framework writes with #PREFIX# anywhere, and derives that list from the source so
a new table following the convention is covered without editing the test. A second assertion
pins the empty fixture prefix, so nobody reads the first and concludes the suite covers this
by running.
Documentation¶
Pramnos_QueryBuilder_Guide.md— Table prefixes now says why the suite cannot catch it and what does.Pramnos_Database_API_Guide.md— three examples corrected.
The Redis cache listing read a key nothing writes¶
Found by giving a project a working Redis. The moment the cache stopped silently falling back to files, the dashboard showed no namespaces at all.
Fixed¶
RedisAdapter::getCategories() reads the category index the adapter actually maintains.
It read a memcachedtags JSON blob — and nothing has ever written that key. Three adapters
read it (Redis, Memcache, Memcached); no code anywhere sets it. So on Redis the method
always answered [], getStats() always reported 0 categories, and the cache dashboard
showed an empty namespace list beside an item count in the dozens. Not an empty cache: a
listing that could not see one.
The real index was in the same file all along. save() writes a catindex:<category> set
and a catindexed:<category> marker per category, and clear($category) already trusts
them. Enumerating the markers means the listing and invalidation now read the same source of
truth, rather than two that can disagree.
The item count was about Redis, not about the cache. It was dbSize(), which counts the
whole database — sessions, queue payloads, another application's keys, and the adapter's own
bookkeeping (two keys per category). It also subtracted one for the memcachedtags key,
which does not exist. It now counts the entries under the cache's own prefix. That costs a
keys() scan where dbSize() was O(1), and the trade is deliberate: the screen it feeds
cannot list entries without a scan anyway, and it is an authenticated dashboard somebody
opens occasionally rather than a request path.
How it stayed invisible¶
Cache falls back to files when the configured backend is unreachable, and it reports the
fallback — the DevPanel line reads "file fell back from redis". An application configured
for Redis whose PHP image has no redis extension therefore runs on files, passes every
test, and behaves differently in production.
Its own test made it worse rather than better: it wrote the memcachedtags key by hand
and asserted getCategories() read it back. A green test proving the reader worked against a
fixture production never produces. Both tests now go through save(), which is the only way
the question means anything, and a second one pins that an empty cache reports an empty list
— the distinction the old fixture could not make.
Documentation¶
Pramnos_Cache_Guide.md— a new What a listing can and cannot see, per adapter, with what each backend can enumerate and what a silent fallback hides.
Vendored assets that were still remote¶
pramnos init downloads a library's stylesheet and stops there. For FontAwesome, Bootstrap
Icons and any web font, the stylesheet is a list of remote URLs — so "install locally"
produced a local file pointing at somebody else's server, and the project's own generated CSP
then refused it.
Fixed¶
A downloaded stylesheet now brings what it points at. Every url() reference is fetched
into a files/ directory beside the stylesheet and rewritten to the local copy.
FontAwesome's all.min.css is nothing but @font-face rules naming ../webfonts/*.woff2;
vendoring the CSS alone left a project whose icons were empty boxes, and there was nothing in
the output to say so. A failed download leaves the original URL alone — a stylesheet that
half-works beats one rewritten to a file that is not there.
Two catalog keys for hosts that care who is asking. user_agent is sent for that
entry, because Google Fonts serves woff2 to a browser and ttf to anything else — three
times the bytes, and the wrong format. And a stylesheet URL that is not a filename
(css2?family=…, whose basename is css2) is saved as <key>.css rather than as a file
with no extension that no server sends as CSS.
The plain-css theme self-hosts Inter. Its header carried three
fonts.googleapis.com tags while the same command generated a CSP restricting style-src
to 'self'. The browser refuses that stylesheet outright, so every scaffolded plain-css
project rendered in the fallback font stack, with two console errors and no other sign — two
halves written by one command, disagreeing. Widening the policy for two hosts is the wrong
end of it: we already download assets at scaffold time, and a typeface is an asset. Self-hosted
it is also one fewer third-party request per page, one fewer dependency on somebody else's
uptime, and one fewer visitor IP address handed to Google, which under the GDPR is not a
stylistic preference.
The generated Dockerfile installs the cache client. Choosing redis wrote the compose
service and the 'method' => 'redis' setting into an image with no redis extension, so a
brand-new project ran on files from its first request — and said nothing, because falling
back is what Cache is supposed to do. memcached had the same hole. Both are now
pecl installed into the image when selected.
And CacheBackendCheck is registered by default, so the fallback is reported rather
than merely survivable. degraded, naming both stores — Running on file, configured for
redis — and hinting at the missing extension. Not down: the site is working, and a check
that pages somebody for a working site is a check that gets muted.
Why this took a real project to find¶
A silent fallback is invisible in exactly the conditions where it matters. The settings said
redis, docker compose ps said redis, every page worked, and the cache was on local disk —
which means invalidation is per-server, and a two-node deployment serves whatever the node
that was not asked still holds. Two Redis-only bugs in this framework's own cache adapters
could not surface for as long as no project ever actually reached Redis.
Documentation¶
Pramnos_Console_Guide.md— A vendored stylesheet brings what it points at, with theuser_agentkey and the filename rule.Pramnos_Cache_Guide.md— the check to read instead of comparing the two properties by hand, and the image extension.Pramnos_Health_Guide.md—cachein the built-in table, and why it is degraded rather than down.
A page could not call its own API¶
Every API request needed an apikey header. A browser page has none, and cannot be given
one. So a server-rendered screen could not call its own application's endpoint at all — the
framework's own search box being the case that found it.
Fixed¶
ApiAuthMiddleware accepts the application's own signed-in page in place of an API key.
An API key names the client. For a same-origin request from our own document the client is
us, and there is no way to hand a page a secret anyway: anything the document can read, a
reader of the document can read. The middleware answered 403 APIKeyMissing, which is
correct for a third party and wrong for the page that rendered a moment earlier.
A request with no API key is now accepted when both of these hold:
- the session carries an active
web_sessiontoken — which every web login already creates; - the request carries
X-CSRF-Tokenmatching the session's own token.
Either half alone is refused. The cookie by itself is not an authentication signal, because
the browser attaches it to a cross-site request too; the CSRF token is the half that proves
the caller read our page. That is the same pair UnifiedAuthMiddleware has always accepted
for first-party route groups — and it is now literally the same code, moved to
SameOriginSessionTrait so the two middlewares cannot drift into two opinions about what a
same-origin session is. An anonymous session is refused with 401 rather than published as an
identity: user 1 is the anonymous account, and every isAuthenticated() check downstream
would read it as a signed-in user.
Applications served by Pramnos\Application\Api could not opt into UnifiedAuthMiddleware
even knowing all this: Api::exec() pipes the API-key middleware itself, before routing, so
there is no route group to configure.
The document prints the token. <meta name="csrf" content="…">, in the <head>, from
Document::csrfHeadMarkup() — so every theme in every project has it without editing a
template, existing projects included. For a signed-in page only, and only when a session
is already running: on an anonymous page the token authenticates nothing, and reading it
would start a session on every public URL, which is the difference between a page a shared
cache can hold and one it cannot.
pf-utils.js sends it. window.pfApiHeaders(extra) adds the header from that tag, and
both fetches in the file now go through it. Use it for your own calls rather than assembling
headers by hand.
How three correct parts added up to a broken feature¶
Html\SearchBox renders a box. ApiAdmin::search() answers a term. The data-pf-omnibox
handler connects them. Each was tested, each worked, and the feature did nothing: the request
went out without a credential the endpoint would accept, the handler logged one line to the
console, and the box showed No results — which is indistinguishable from a term that
matched nothing. It was reported as "the search does nothing", and the parts each pointed at
the others.
Worth keeping in mind for anything assembled from three tested pieces across two processes.
Documentation¶
Pramnos_API_Guide.md— a new Calling your own API from your own page, with what is accepted, what is refused, and thepfApiHeaders()call.Pramnos_Search_Guide.md— How the box authenticates, and the two things a project adopting the omnibox has to have (a currentpf-utils.js, nothing in the theme).
One palette, every UI system¶
A project's colours used to live wherever its UI system happened to keep them: a daisyUI
@plugin block for Tailwind with npm, hand-written custom properties for a buildless one,
Bootstrap's variables under Bootstrap, and a fourth copy inside a SPA's own theme file. Same
palette, four places, and the first thing to go wrong is that they stop agreeing — in
whichever theme nobody develops in.
Added¶
app/theme.css is the palette, in the format
daisyUI's theme generator already emits. pramnos
init writes it, named after the application rather than light/dark, and nothing else in
a scaffolded project carries a colour value.
That format rather than a config file of our own for two reasons. It is the one a designer
can produce without this framework existing — pick colours on the site, copy the block,
paste it in. And for a Tailwind project with npm it needs no build step at all: app.css
imports the file and the plugin reads the blocks.
pramnos theme:build is for everybody else. It turns the same blocks into
www/assets/css/theme-tokens.css — plain custom properties, which is the whole of what a
buildless Tailwind, Bootstrap or plain-CSS project needs — and
www/assets/theme-tokens.json, for a SPA's own components. --check fails instead of
writing, for CI: a generated file in a repository can go stale, and a stale palette is
invisible until somebody opens the theme nobody develops in.
Each theme lands under [data-theme="<name>"], the one flagged default on :root as
well, and the one flagged prefersdark inside a prefers-color-scheme: dark block scoped
to :root:not([data-theme]). That scoping is the difference between a theme switch that
works and one that works only for visitors whose operating system is already in light mode.
ThemeTokens::token() reads one value from PHP, for the places a custom property cannot
reach: <meta name="theme-color">, where the browser chrome has to match the page and the
value has to be in the markup, and an HTML email, which has no custom properties at all.
Fixed¶
The scaffolded theme toggle switched to daisyUI's stock themes. It wrote light and
dark, so a project with a palette of its own lost it the first time a visitor pressed the
button — and got it back by reloading, which reads as a rendering glitch rather than as a
theme name. It now writes the project's own two.
A SPA stopped guessing its colours. scripts/build-theme.mjs scraped the
server-rendered theme's :root properties and mapped what it recognised — it knew
--primary-color, and invented the rest. It reads the palette now, where the token names
are already daisyUI's, and falls back to the old scrape only for a project that predates the
file.
What this deliberately does not do¶
Bootstrap's own variables are not generated. Bootstrap 5 wants --bs-primary as a hex plus
a --bs-primary-rgb triplet, and an oklch() value cannot be decomposed into one without a
colour-space conversion that has no business happening at build time. A Bootstrap project
reads the tokens directly; theming Bootstrap's components still means Bootstrap's Sass.
Documentation¶
Pramnos_Theme_Guide.md— a new One palette, every UI system: the format, the build tool, the generated selectors, reading a token from PHP, and what is out of scope.Pramnos_Console_Guide.md—theme:buildbeside the other project commands.
The administration area has its own directory¶
Every admin screen answered on two addresses. /admin/Users inside the area, and
/Users outside it — the same controller, in the public theme, with no sidebar and
outside the area's usertype floor. It was reported as a link that went to the wrong
place; the shape of it was a second front door to every administration page.
Added¶
src/Admin/Controllers/ and src/Admin/Views/, the counterpart of src/Api/.
Inside the area the framework resolves <Ns>\Admin\Controllers\Users and
src/Admin/Views/users/ first, and falls through to the site's own — so an area
holds the screens that belong to it rather than a copy of the application. A shared
Home, a shared partial, and a project with no src/Admin at all behave exactly as
before.
Outside the area that directory is not in scope, which is the half that closes the door:
/Users finds nothing. Application::$area carries it, is empty for every project that
has not moved anything, and is re-derived per request like the theme — a first request to
/admin must not leave the area's controllers in scope for the public page after it.
pramnos init now writes the twelve admin screens there — Users, Settings, Dashboard,
Logs, Applications, Tokens, TokenActions, Permissions, Organizations, Emails, Services,
Queue — and pramnos project:publish-views publishes an admin view group to
src/Admin/Views/. Health stays with the public controllers on purpose: /health/check
is the JSON endpoint an uptime monitor calls, and putting a usertype floor in front of a
monitoring URL is how a project finds out its monitor has been reporting "down" for a
week.
To move an existing project's screens: move the file, change its namespace. Nothing else
refers to it. 'area' => 'Ops' in the admin config block names the directory something
else.
Fixed¶
Administration screens link within the area — 114 places. Every breadcrumb, every
redirect after a save, every row-action link and every datatable's ajax source in the
bundled admin controllers built its URLs as sURL . 'Logs/viewer', so a click or a save
inside /admin landed on the public copy of the page. The redirect is the worst of the
set, because the visitor is not clicking anything: they save a user and arrive somewhere
that looks like having been signed out.
They go through adminUrl() now, in UsersController, LogController,
SettingsController, EmailsController, ServicesController,
OrganizationsController, PermissionsController, TokenActionsController,
ApplicationsController and TokensController, and in the admin views of all three
bundled themes. The public links in the same files — the password-reset URL that goes in
an email, login, account — are deliberately still bare.
A test now walks those controllers looking for the bare form, alongside the one that already walked the views. The report was one link. The cause was 114.
Documentation¶
Pramnos_Routing_Guide.md— a new Where the area's code lives, and the danger note about the floor now says what the layout does and does not close.
Six filings from one project, all small and all silent¶
Every one of these was reported by an application migrating onto the framework, and every one
of them failed in a way that produced no error: a deprecation notice nobody reads, a table
that does not exist, an isset() answering from the wrong store.
Fixed¶
User::__isset() and __unset() read the store __get() reads. The pair was
inherited from Framework\Base and answered from $_data, which User never writes — so
isset($user->anything) was false for every field __get() would have returned.
The consequence was not an inaccurate isset(). ?? asks __isset() first and calls
__get() only when a class declares no __isset(), so $user->preference ?? '' returned
'' for a value that was in the object and in the database all along. A consuming project
read every notification preference that way: the whole set became "no preference", with no
error, no warning, and the values correctly stored. An unrelated admin test caught it.
User::load(null) refuses instead of using null as an array key. null is a normal
argument — new User($record->userid) on a record that did not load passes it, and the next
line is usually a userid < 2 check — and it reached the user cache as an array offset,
twice per call, on PHP 8.1+. Refused rather than coerced to 0, which already means "load
whoever is in the session".
The four friend methods address the prefixed table. makefriends(),
removefriends(), arefriends() and getfriends() wrote a bare userfriends.
QueryBuilder::table() substitutes #PREFIX# and leaves a bare name as written, so on any
installation with a prefix all four addressed a table that does not exist. They go through a
userFriendsTable() accessor now, honouring DB_USERFRIENDSTABLE, like the users and
user-details tables before them.
Addon::load() refuses a name that cannot name an addon. null reaches it from real
data: the addons setting holds a serialized list, and an entry saved with no addon selected
stores a null name — one installation has 19 of those out of 24. It was harmless while the
method only looked for a file, because file_exists() on a path ending in /.php is
false; once a class-name branch went in front of it, the same input became Passing null to
parameter #1 ($class) of class_exists() nineteen times per request. isActive() and
getAddon() guard the same way.
A footer column filter waits for three characters, and debounces. The per-column inputs
under footerTextSearch filtered from the first keystroke with no debounce at all — the
searchDelay added earlier covers DataTables' own global box, not these. Typing
papadopoulos into one sent twelve AJAX requests and twelve server-side queries, and the
first of them was LIKE '%p%' across the whole table. One application has that on 23 admin
tables, the largest ones included.
Datatable::$minSearchLength guards it, 0 turns the guard off, and the handler now goes
through the debounce the class already had. An empty box is always let through, which is
the one deliberate difference from the older behaviour: clearing a filter has to clear the
filter, or the column stays filtered on a term no longer on screen.
Datasource::addField()'s $startWildcard is false again. It had become true, which
is not a detail: render() calls addField() with a bare column name for every field
declared as a plain string, which is how most applications declare all of them. So the
default is the real rule almost everywhere, and it had turned every column search from
LIKE 'term%' into LIKE '%term%' — the index stops being usable, the range scan becomes a
full one, and the result set changes, with nothing to say so.
Datasource::$lastQuery is back. The SQL of the last list query, published before the
query runs so a failing one can still be read. It is the only way to see what a filter
produced: a screen can show the query behind a list, and a test can assert on an ORDER BY
without building a dataset large enough to make the ordering observable.
The palette moves under app/themes/¶
app/theme.css → app/themes/theme.css, beside the theme directories that read it
rather than loose in app/ among app.php and settings.php. Those are configuration; this
is design, and a stylesheet in a directory of PHP config files is the first thing somebody
tidying up moves.
ThemeTokens and theme:build look in the new place and fall back to the old one, because
the failure mode of not looking is silent: a project that upgrades and leaves its palette
where it was would render with no palette and nothing to say why.
Two guides that had become their own history¶
Pramnos_Document_Output_Guide.md carried the story of every change made to it: which
methods a previous version of the page documented, what a legacy document class emitted, what
a consuming project reported and when. A reader arriving to add a meta tag had to sort the
present from the archaeology. What survives is the behaviour and the reasoning; the dates and
the incident reports are gone.
Pramnos_Test_Suite_Performance.md was a measurement diary: 1,090 lines of dated runs,
superseded plans, and estimates for work already done. It is 75 lines now — how to measure,
what actually makes a test slow here, and what not to do — because that is the part a reader
needs and the part that stays true.
Both are the same failure: notes written during the work, left where the reference lives.
A generated screen the vanilla stacks could not reach¶
create:crud wrote a screen for the vanilla and vanilla-vite stacks all along, and
registered it in screens/registry.js — and nothing rendered it. main.js never read the
registry, so the file existed, the endpoints answered, and the screen was unreachable. The
command reported OK.
Fixed¶
The vanilla shell walks the registry. main.js now renders a navigation from screens,
mounts the screen the route names, and falls back to the home screen for anything unknown — a
mistyped URL rendering nothing reads as an application that crashed. It uses the same
lib/router.js the Svelte shell does, so every screen has a real URL: the back button stays
inside the application, a view can be sent to a colleague, and a refresh keeps the user's
place.
Navigation items are anchors with real hrefs rather than buttons, because the router
intercepts the click — the same element then works in a new tab and as a client-side
navigation. A button is neither. With nothing registered there is no navigation at all: a lone
"Home" link above an empty project is furniture.
The generated vanilla screen was thinner than the Svelte one, and less safe. It was handed
column names while the Svelte path was handed descriptors, so it rendered every column of a
thirty-column table and a form of thirty text boxes — including the primary key. It now reads
the same descriptors: labels from COLUMN COMMENT, required from NOT NULL, an input
type derived from the SQL type, the list trimmed to the first few columns, sortable headers,
and a pager with buttons instead of the sentence "Page 1 — 4,312 records" that told the user
there were more pages and gave them no way to reach one.
And every value now reaches the DOM through textContent or .value. The first version
interpolated row values into innerHTML. A record's own text is untrusted — somebody typed it
into this form — and a generated file is the worst possible place to leave that decision to
whoever edits it next. There is a test that fails if a record ever reappears inside an
innerHTML assignment.
The scaffolded admin screen stays Svelte-only, and the comment in Init that said the
vanilla stacks get "no generated screen" now says what is actually true.
Every breadcrumb in the area pointed at /adminusers¶
adminUrl() with no path returned …/admin — no trailing slash, where sURL has one.
The bundled breadcrumb partials use it as a base: $base = adminUrl(); … $base .
'users'. So every trail in the administration area produced /adminusers,
/adminTokens, /adminLogs — a 404 per crumb, in the one part of a page nobody
proof-reads. And only in an application that had an area configured: without one the same
code got sURL, trailing slash included, and worked.
AdminArea::url() now ends in a slash when it is given no path, which makes it exactly
what sURL is and what the callers assume. adminUrl('Users') is unchanged.
Reported the same day the links were introduced, which is the only reason it was one
report and not a bug hunt: the sweep that put adminUrl() into 114 places also put this
in every one of them that treated it as a base.
URL is the administration area, sURL is the site¶
Two bases, each naming what it is:
<a href="<?php echo sURL; ?>login">Sign in</a> <!-- the site -->
<a href="<?php echo URL; ?>Users">Users</a> <!-- the administration area -->
URL was a second name for the site URL, left over from before the framework had an
administration area at all: sURL was defined from it, and nothing else in the framework
read it. It is the area's base now — what a template inside /admin concatenates onto — so
the two constants answer the two questions a link has.
adminUrl('Users') is the same answer for code that runs where constants may not be
defined: a controller under test, a CLI render. Both are AdminArea::url() underneath, so
they cannot disagree, and both equal sURL in an application with no area configured.
This changes what URL means. Nothing in the framework or its scaffolding read it as the
site URL except one fallback in Application, now sURL. An application that reads it does
need the one-line change.
Organizations had no way to look at one¶
Every other entity in the administration area has a read-only screen — a user, an
application, a token. Organizations went straight from the list to edit, so looking at a
record meant opening the form that changes it. That is the wrong default for the common
case, which is somebody checking what a record says, and the wrong default for safety.
Organizations/view/{id} answers the three questions the list cannot: what the record
holds, who is in it, and what it links to. Every column the table carries is shown,
including ones a later migration added and this screen has never heard of — a column
somebody added is a column somebody wanted to see. Members are the ten most recent active
ones with a link to the full list, and each links to the user's own screen.
All three bundled themes, plus the breadcrumb trails the organization screens never had
(organizations, organizations_view, organizations_edit, organizations_members).
A column filter per column, on the lists that need one¶
One search box over every column answers find this person. It cannot answer the administrators registered this month, and that is most of what an operator asks a list.
Datatable could already do this — the 9th argument of addColumn() is the filter, and it
takes true for a text box or the id of a control rendered into the footer. Nothing in
the framework's own admin used it. The Users, Applications and Organizations lists now do:
text boxes on the columns worth typing into, and a Html\Select on the enumerated ones,
because nobody guesses that "administrator" is stored as 90 — and a numeric column is
matched equal rather than with LIKE, so a text box there is a worse question as well as
a harder one.
The filters wait for minSearchLength characters and debounce, both added earlier today, for
the reason that made them necessary: a column filter without them is one query per keystroke
across the whole table.
See What a usertype means below for where the bands come from now.
What a usertype means, and how an application changes it¶
users.usertype is an integer read as a threshold — >= 90 is an administrator, and
the admin area's floor is whatever admin.min_usertype says. That was never written down
in one place: the number 90 lived in a console command, 80 in app.php, and each bundled
view carried its own copy of the labels. "What is 85?" had three answers depending on which
file you asked.
Pramnos\User\UserTypes is the one place now — label(), labels(), options() — and an
application replaces the bands in app/app.php:
'usertypes' => [100 => 'Owner', 90 => 'Administrator', 50 => 'Staff', 10 => 'Customer', 0 => 'Guest'],
Keyed by the band's floor and read highest-first, so a value between two bands belongs to the lower one; declared in any order, because a config listing them lowest-first would label an administrator "Guest".
The users list shows the band, not the number. A column of bare integers asks every reader to know the bands by heart.
Row actions are icons, and the row is a link¶
View Edit Deactivate on every row spent more width on words than on data, and after the
first row the words carried no information — the actions are identical in every row.
Pramnos\Html\Icon is that set as inline SVG: Icon::link($url, 'edit', 'Edit this user')
gives a 28-pixel action labelled twice, as aria-label and title, because an
icon-only control with neither is a control only its author can use. Inline SVG rather than
an icon font or a class, because a controller renders this into the JSON a DataTable
inserts — it has to work in all three bundled themes and in a project's own; currentColor
and a 1em box make it inherit whatever the cell is.
And the identifying columns are links. A row whose only way in is the last cell makes
the whole row a target people click with nothing happening. The bundled lists link the id
and the name; create:crud links the first visible column of what it generates.
create:crud also generates the per-column filters now, and the framework's full-width
record actions use Icon::svg() — so "edit" looks the same in a table cell and in a
button.
Fixed on the way¶
A column filter's <select> showed 1 and 0 instead of its labels.
Html\Select::addOptions() takes value => label; three call sites passed the reverse, so
every enumerated filter offered its own values as labels.
A column filter's text box was invisible. A bare <input> under a modern CSS reset has
no border, no padding and no background — the filter row was rendered, and looked empty.
The box now carries pf-footsearch, the themes style it, and its placeholder is the
column's own label.
Row icons stacked one per line. Tailwind's preflight sets svg { display: block }. The
three bundled stylesheets now carry .pf-action, .pf-icon, .pf-footsearch, .pf-state
and .pf-muted — as CSS rather than inline styles, because a project whose CSP has no
'unsafe-inline' for styles would strip those and be left with unreadable actions in
exactly the strictest deployments.
Uncaught ReferenceError: dt is not defined, at the first keystroke in a column filter.
renderJs() declares the table into a variable named after it, sanitised — dt-users
becomes dt_users, because a hyphen is not an identifier. fixColumnSearch() interpolated
the raw name, so the two halves of the same script disagreed about what the table was
called: dt-users.fnFilter(…) parses as dt - users.fnFilter(…). One jsVar() now, used
by every emitter. It had been latent since the footer filters were written, and only
surfaced when the framework's own lists started using them.
And the full-width record actions were block, not btn-block. A daisyUI btn is an
inline-flex box that centres its own content; block overrode that display, so the label
stopped being centred by the button and the height came from the text. Four views.
The log viewer's own endpoints 404'd inside its iframe¶
LogViewerView built its base as sURL . '/logs'. Administration screens are resolved
only inside the area now, so /logs/raw/… is a 404 — and it showed as the framework's own
404 page rendered inside the viewer's panel: a page that had loaded fine, with a
not-found notice in the middle of it.
adminUrl('logs'), which is sURL in an application with no area configured — where the
old form was right.
Everything the framework knows about a user, on the user's screen¶
The framework records a user's history in nine stores. The administration screen showed two of them.
Sign-in history was in user_activity_log, GDPR requests in gdpr_requests, failed
attempts and lockouts in loginlockouts, second factors in user_twofactor, passkeys in
passkey_credentials, consent in user_privacy_settings, token history in tokenactions,
memberships in user_organizations. Some of it was visible in the DevPanel — a
development tool, off in production — and the rest was visible nowhere: an operator
answering "why can this person not sign in" or "has this account requested an export" had
a database client and nothing else.
UsersController::userRecords() collects all nine and the screen shows each as a panel,
with the last ten of anything long and a count beside it — "the last ten of 4,312" and "all
ten there are" are different facts, and a list of ten cannot tell them apart. The whole
activity log has its own paged screen, users/activity/{id}, through the same pipeline
every other list uses, with a filter under each column.
Every read is guarded on its own. These tables arrive with features: an application
without authserver has none of the authserver.* ones and one mid-migration has some, so
each store answers for itself and the page renders whatever exists. An empty panel is still
rendered — "no GDPR requests" is an answer, and a section silently omitted cannot be told
apart from one that never existed.
Three operator actions the screen was missing¶
users/unlocklogin/{id} clear a login lockout
users/disabletwofactor/{id} turn off 2FA
users/revokepasskey/{id}?credential={n} remove one passkey
A lockout with minutes left on it is what "I cannot sign in" resolves to most of the time, and the options before this were to wait or to edit a table. A second factor belongs to a phone that can be lost, and the person who lost it is the one who cannot sign in to turn it off. A passkey is bound to a device, and when the device is gone the credential is not.
disabletwofactor goes through TwoFactorAuthService::disableForOperator() — the named
unchecked path, because the user's own disable() requires their password and an operator
cannot be asked for somebody else's. revokepasskey matches on the user and the
credential, so a request naming another account's key deletes nothing. All three write to
the activity log: switching off somebody's second factor is precisely what an audit needs
to show.
And the record actions beside a user gained Send password reset and Find in logs, which existed as endpoints with nothing on screen pointing at them.
Per-user settings, and permissions edited where the user is¶
The user edit form had eight fields. The framework's own schema gives a user twelve — phone, mobile, language and timezone were written by the account screens the user sees, and an operator correcting a typo in somebody's phone number had nowhere to do it.
Added¶
usersettings — a key/value store per user, with the value as JSON, plus
User::getSetting(), setSetting(), deleteSetting() and listSettings(). There were
two places to keep something about a user and neither fits an operator-visible switch:
users columns are the schema every application shares, so an application cannot add to
them, and $otherinfo is a blob — no list, no per-key delete, nothing an administrator can
read. A consuming application had built exactly this table and an editor for it, which is
the definition of something belonging in the framework.
Deleting is not the same as writing null: no row means the application's own default
applies again, a null value means somebody deliberately set it to nothing. An operator
undoing a change wants the first.
Per-user permissions on the user's own screen. The store could always hold them —
authserver.permissions, subject/object/action — and the only screen that wrote to it was
the permissions list, which asks for a user id in a field. Granting from the user's screen
is the direction an operator works in: they are looking at a person and deciding what that
person may do. Only direct grants are listed, because the resolver also answers from
usertype and group membership, and a screen mixing them would offer a revoke button for a
permission with no row behind it.
A message form. users/notify/{id} sends one account an email, recorded on its
activity log — "did anybody tell them?" is a question the log should answer, and an
operator's own mail client leaves no trace on the account.
A token screen. Tokens/view/{id}: whose token it is, which application issued it,
when it was last used and from where, how many calls it has made, and a paged list of the
last of them. Token has been able to answer all of that since it was written — nothing
ever put it on a page. The token value itself is never printed: it is a bearer credential,
and a page that showed it would be a way to obtain one. A fingerprint identifies it
instead.
The usertype select on the edit form now offers the application's own bands rather than a hardcoded five, so it cannot disagree with the badge on the user's own screen.
A screen for the message templates the framework already had¶
The messaging feature shipped mailtemplates — the table, the model, the
(category, language, type) lookup — with no screen. So an application's own notification
wording was editable only through a database client, which in practice meant it was never
edited: a project that wanted to reword a password-reset email changed the code that
composes it and left the template unused.
/admin/MailTemplates lists them grouped by notification, because one notification is
several rows — same category and channel, one per language — and eighty flat rows cannot
answer "is the reset email translated into Greek".
The editor lists the placeholders it found in the template itself. A documented list
goes stale the first time an application adds one, and an editor showing none is a form
where a typo produces mail with a literal {nmae} in it. CSS braces are not mistaken for
placeholders. And it sends a test, because the only way to know a template renders is to
render it — placeholders arrive as [name], so you can see where each lands without
invented data hiding a missing one.
The body is stored as written: an email template is markup, and sanitising it would make the feature useless. It is escaped where it is displayed, which is the correct half.
New-sign-in alerts got a site policy¶
The feature worked and was per-user opt-in and nothing else: an operator could not turn it on for everybody, and could not turn it off during an incident that was generating thousands of sign-ins.
auth_newsignin_policy, on the settings screen: optin (default — the account's own
preference decides), always, or off. always is there because for a service where the
account is the product, telling somebody their credentials were used from a new device is
closer to an obligation than a setting; off is there for the incident where the alert
stops being a security feature and becomes the outage's own mailing list. The default is
what every installation had before, so upgrading starts and stops nobody's mail.
The per-account state is on the user's admin screen, with a toggle when the policy leaves the decision to the user — and a sentence instead of a switch when it does not, because a control that decides nothing is worse than no control.
A login left the previous session's token valid for a month¶
One sign-in mints one web_session token and nothing ended the previous one. A browser
that signed in twice left two rows marked Active, from the same address, for the thirty
days of their lifetime. Reported from a running install, off a screen that was right about
what it was showing.
Two problems in one row. A list of a user's active sessions stops meaning anything — three
rows could be three devices or one browser that re-authenticated three times, which is the
question that list exists to answer. And a token no session cookie can reach is still a
valid credential, because loadByToken() takes the raw value: a copy in a log, an old
client or a backup keeps working for a month after the session that created it ended.
Creating a web-session token now retires what it supersedes — the token this request arrived with, and any other live one from the same device fingerprint. Tokens from other devices are untouched: signing in on a laptop must not sign you out on a phone. Marked inactive rather than deleted, so the history keeps its rows, and done before the insert — retiring afterwards would match the new token's own fingerprint and take it with it.
One definition of the guard every admin screen opens with¶
requireMinUserType() existed as an identical eleven-line copy in eleven controllers.
So a new administration screen either copied it a twelfth time or — as happened while
writing the message-templates screen — assumed the base class had it and failed at runtime
with Call to undefined method.
It is on Application\Controller now, and the eleven copies are gone. One of them differed:
UsersController's returned void where the others returned bool, which is an
incompatible signature and would have been a fatal error rather than a duplicate. Its
callers ignored the return value, so removing it changes nothing.
A screen that forgets to call the guard is now the only remaining way for an administration screen to be unguarded, which is a mistake a reader can see.
What each usertype may do, written down and on a screen¶
The bands existed and their meaning did not. UserTypes could tell you that 90 is called
"Administrator"; nothing said what an administrator may do, so every screen decided for
itself and the answer to "may this account reach the settings" was wherever somebody last
wrote a comparison.
Three things now sit beside the labels, and all three are one thing an application can replace:
UserTypes::label(90); // 'Administrator'
UserTypes::tone(90); // 'warning' — how a badge should read
UserTypes::can(90, 'admin.settings'); // false
UserTypes::can(98, 'admin.settings'); // true
UserTypes::can(99, 'anything.invented.later'); // true — Root holds '*'
The defaults are five bands rather than a spectrum: Root (99), Super Administrator (98), Administrator (90), System User (1) and Simple User (0). Capabilities accumulate upwards, so a band inherits everything the bands below it hold and the difference between 90 and 98 is exactly the two entries 98 adds.
1 is matched exactly and inherits nothing. It is the client-credentials account — a machine, with no browser and no person behind it — and it is below an ordinary user on the scale. Treating it as a threshold would have handed every human account the API's capability.
The tone moved into the registry for a reason worth stating: each bundled view carried its own map from usertype to badge colour, so an application that declared its own bands got labels from the registry and colours from a table that had never heard of them. A view now asks for the tone and maps that to its own classes — one of four names, which a theme can answer for bands it does not know.
And /admin/Users/types renders the whole thing: every band, its tone as it will appear,
the capabilities it declares and the capabilities it inherits, with the area's own floor
marked. The place to look when the question is "what is 85 here".
A correct password was refused, and bcrypt had been dropping characters¶
Two defects in the same eight lines, one reported and one found beside it.
The reported one. verifyPassword() assumed the framework wrote every row: it appended
the per-account pepper and compared. An application sharing the user table had written its
own rows with a bare password_hash($plain, PASSWORD_DEFAULT) — no pepper — and its
accounts could not pass the framework's own password step-up. Measured on a fresh account
with the correct password:
Both false, so disabling two-factor became impossible for every account that application had, and the only way round it was the operator path — documented as "not the user's own action" — used for exactly the user's own action.
The one found beside it. The pepper was appended to the plaintext, and bcrypt stops at 72 bytes. With a 32-character suffix, everything a user typed past the 40th character was discarded: two long passwords sharing a 40-character prefix verified against each other. Nothing reported it, because both passwords worked.
Pramnos\Auth\PasswordHash is now the single place, and verify() returns the name of
the scheme that matched rather than a boolean — hmac, pepper, plain, md5, or null.
The preferred scheme is bcrypt over an HMAC-SHA-256 digest, which has no length ceiling;
plain is accepted because another writer may have created the row; md5 only when a
caller asks for it. User::setPassword(), User::verifyPassword() and DatabaseAuthDriver
all go through it, so the front door and a step-up in the middle of an account screen agree
about what a stored hash is.
Upgrading happens where the plaintext exists — at a successful sign-in — and how far it goes is the application's choice:
modern is the default and rewrites the pepper-suffix scheme above all, since that is the
one truncating passwords, plus md5 — which needs no second opt-in, because a row can only
be read as md5 when the application already said legacy_md5. It deliberately leaves a
plain password_hash() row alone: such a row may belong to another application sharing
the table, and rewriting it into a digest-based scheme would leave that application
unable to verify a password it wrote itself. Taking ownership of somebody else's rows is
not a default. all is how an application says the table is its own. Each rewrite records a password_hash_upgraded activity entry, so the migration is
something you watch finish rather than assume.
One knob, which needed saying because it was two: the login driver had its own boolean
auth.auto_upgrade. A project that turned rehashing off in rehash_on_login still had its
rows rewritten at the login, and one that turned off auto_upgrade still had them rewritten
by a step-up — whichever setting you thought you had configured, the other behaviour was
happening somewhere else. rehash_on_login decides now; auto_upgrade is honoured as the
older name.
One thing that is not code: where a screen asks for a password and a single-use code, check the password first. Reversed — which is the natural way to write it — a mistyped password consumes the code, and the user is told their password was wrong and then has to wait for a new one.
No language was ever selected¶
Language starts on a hardcoded 'english', and the only thing that ever changed it was
?lang=. That value was written into the session and the session was never read again,
so a choice held for exactly one page. A login has always written a language cookie:
unread. Every project scaffolded here ships a language key in its settings file: unread.
users.language is a column the guide describes as the account's preference: unread.
So every application served every request in English, and the reason it survived this long is that the failure is invisible. A missing key renders as itself, and the framework's keys are the English wording — so "no language was ever selected" and "this string is not translated" produce identical pages. No error, no empty string, nothing to grep for. It arrives as "the translations do not work".
Application::resolveLanguage() now consults them in order: ?lang= (remembered), the
area's own language, the session, the cookie, the language setting, default_language,
and last the first installed language rather than english — a project whose catalogues
are en.php and el.php has no english.php, so asking for it loaded nothing at all.
load()'s own fallback chain gained en for the same reason.
Two more things came out of it. Every candidate is validated against the installed
languages, which is a security fix as much as a correctness one: load() interpolates the
name into an include path and ?lang= reached it unfiltered. And a login now carries
users.language into the session — selected from the row it already reads, so no extra
query — which is what finally makes that column mean something.
Application::setLanguage() is the way to change it mid-request, for a picker that must
apply immediately; it refuses a language that is not installed rather than loading nothing,
because load() falling through to English looks exactly like success.
Beside it, t() — l()'s missing partner, which returns instead of echoing. l() is
right in a template and useless where a translation is a value: a document title, a flash
message, an array of labels. Those call sites had only
Factory::getLanguage()->_(…), long enough that most of them kept an English literal
instead — including the page title of every account, passkey, two-factor and device screen
the framework ships, all of which are translatable now.
The administration area may be in another language than the site¶
Falling out of the above: once a language is actually resolved, a site in one language gets an administration panel in the same one, and the panel's breadcrumb root turns up in the middle of an English screen.
An area is a place with its own audience — operators, often not the visitors' audience — so it may declare its own:
'admin' => [
'prefix' => 'admin',
'min_usertype' => 80,
'language' => 'en', // the site itself is 'el'
],
Ranked above the session on purpose. An area language a stale cookie can override is a suggestion rather than a configuration, and the panel would go on following whatever the front decided.
A second factor that needs nothing set up in advance¶
Two-factor authentication protected the accounts that had installed an authenticator app, which is a minority of any real user base. Everybody else had a password and nothing else, and there was no second factor that could be turned on for them — enrolling in TOTP means doing something before the day it matters.
Email is now a second factor, opt-in per application:
The default is ['totp'], so no existing installation changes. totp stays in the list
whether it is written or not: an application must not be able to switch off the method its
enrolled accounts already depend on by adding a configuration key — that would lock every
one of them out on deploy.
It never ranks above the app. An account with both is asked for the authenticator and offered mail as the fallback; the reverse would quietly downgrade everybody who had done the stronger thing. And the account's own switch lives behind the account's own password, on its own security screen — an operator cannot turn it on for somebody else, because that is adding a credential to another person's mailbox. The admin screen shows the state and nothing else.
What makes six digits safe enough is not the hashing — a million possibilities is nothing to a KDF, so the stored value is an HMAC keyed by the installation secret and the user id, which is enough that a leaked table hands out no live codes and a row copied between accounts is worthless. It is the three limits, all inside the service rather than left to callers: ten minutes, five attempts — after which the code is destroyed rather than merely refused, because a code left alive after the cap can be guessed at while its owner is still holding it — and single use. Asking again replaces the code, so "send it again" never leaves two live ones.
One decision worth naming: a code is not sent when the password is accepted. It is sent when the screen asks for it. Sending on success would mail an account that keeps mail as its fallback on every sign-in it never reads — and would send one for each of somebody else's failed password attempts, which turns a login form into a way to flood another person's inbox.
The step-up screen was the other half. It assumed an authenticator app: one heading, one box, one hint about an app. An account whose only factor was a mailed code got a box it had no way to fill and no way to ask for the code — so the screen now renders what the account actually has, in all three bundled themes, and the form names its factor because both codes are six digits and guessing would spend an email attempt every time somebody typed an app code.
A new device can be made to prove something, not just reported¶
New-sign-in alerts told the account's owner what had already happened. That is the weakest
useful response to a stolen password: by the time the mail is read, whoever had the
password is inside. auth_newsignin_action, beside the existing policy on the settings
screen, is what such a sign-in has to satisfy first:
| Value | Effect |
|---|---|
notify (default) |
today's behaviour — alert, and let it through |
authlink |
the login waits for a single-use link mailed to the account |
require_2fa |
a second factor, even for an account that would not normally be asked |
require_passkey |
a passkey — the only factor that cannot be phished or read out of a mailbox |
The constraint that shaped all of it: none of these may be a way to lock a user base
out. "Require a passkey" on a population that has none is not a security setting, it is
an outage with a checkbox. So every strict reading resolves to something the account can
actually do — the strongest factor it has, and a mailed code last, because a mailbox is the
one factor every account has. require_2fa therefore imposes a mailed code on an account
with no factor at all, ignoring that account's own email-factor switch: the demand belongs
to the site, and an account with nothing set up is precisely the one a stolen password
threatens most. An unrecognised value in the setting falls back to notify rather than to
the strictest reading, because a typo must not be the thing that locks everybody out.
A device with history is never questioned, so the cost lands on unrecognised browsers only.
The link needed a decision reversed and justified. NewSignInNotification refuses to carry
a link and documents why — a link in an unexpected security email is the shape of the
attack. This one carries one because it is expected: the person entered a password
seconds ago and is watching a page that says it is coming, and the link is worthless to
somebody who has the password but not the mailbox. It expires in fifteen minutes, works
once, and sendAuthLink() refuses when nothing is pending — so the endpoint cannot be
used to mail an arbitrary account a way in. It completes in a browser that never saw the
password leg, because people read mail on their phone and a flow that only worked in the
original browser would strand them with nothing to explain.
Two smaller things fell out of it. The link is mailed from beginStepUp() rather than from
the renderer: a renderer runs again on every refresh, so the link the person was holding
would be invalidated each time they reloaded. And the step-up decision briefly asked the
passkey service on every login — an extra query per sign-in for an answer nothing was
going to use — which is now asked only once something needs it.
Beside the policy, the settings screen now reports what the application actually
offers — which second factors exist, which features are on — read-only, with app/app.php
named as where they are set. An operator choosing "require a second factor" could not
otherwise tell whether the factors it refers to exist in this deployment, and "why is
nobody being asked for a code" had no answer anywhere on that screen.
A second factor an application can bring its own of¶
The framework shipped two second factors and both were written into the login by name. That is fine until an application needs a third — an SMS, a push to its own phone app, a hardware token, a corporate gateway — none of which belong in a framework, because each needs an account, credentials and a bill. Adding one meant forking the login.
SecondFactorInterface and SecondFactorRegistry: a factor is now an object that answers
five questions, and the flow asks the registry.
The login offers it, the step-up screen renders it, the audit log records which factor carried the sign-in, and the new-device policy can demand it. The two built-ins moved behind the interface as thin adaptors over the services that already existed — nothing was reimplemented, because two places deciding whether a code is valid is worse than a hard-coded list.
strength() is a number rather than an enum so that an adaptor can slot between the
built-ins without either being edited: the app is 60, a mailed code is 20, and an SMS
saying 40 lands where it belongs.
Three obligations are documented because the flow cannot enforce them. isEnrolledFor()
must be a promise that verification can succeed — an SMS adaptor with no number on file
answering true produces a step-up nobody can complete, which is a lockout wearing the
clothes of a security feature. It must not send anything, since it is called while
deciding what to offer, including on pages that are never submitted — an adaptor that sends
there texts somebody on every failed password attempt, at the application's expense. And
whatever it sends must expire, be single-use and be attempt-capped, because only the
adaptor knows what it issued.
Registering is deliberately not the same as enabling: auth.twofactor_methods still
decides, so a shared codebase can register several adaptors and a deployment offer one
without a code change. Registering under an existing name replaces it, which is how an
application routes the mailed code through its own provider.
Two things fell out of building it. The registry honours the application's list only when there is an application — a console command or a worker gets everything registered, since there is no configuration to honour and a factor is there because code put it there. And a test in this repo failed with nothing in it changed, because another test class left an application in the registry whose configuration filtered the factor out: tests now declare the methods they need, which is also what production does.
Seven account-security switches, every one off until asked for¶
auth.security in app.php, read through Pramnos\Auth\SecurityPolicy. Off by default
is the contract rather than caution: this framework is shared by applications that did not
ask for any of it, and several of these end sessions, refuse logins or send mail. Changing
that on an upgrade is an incident, not an improvement.
regenerate_session_on_login— session fixation. Nothing replaced the session id at login, so an id that was valid before authentication was still valid after: plant a cookie, wait for the victim to sign in, and it is an authenticated session. Opt-in becausesessions.sidrecordsmd5(session_id())and an application may key its own state on it.ip_rate_limit— the per-account lockout is no defence against the attack that actually happens. A list of leaked username/password pairs, one attempt each from one address, leaves every per-account counter at 1 and nothing ever locks. This counts per address, with its own window, and refuses for the remainder of it — fixed rather than a ladder, so a shared office NAT is slowed instead of banned for a day.notify_security_changes— a password, an address, a factor, a passkey. The half that matters is that an address change notifies the previous address as well: a stolen session changes the address first and then the password, and every notice after the first goes to the attacker. That mail is the only signal the owner ever gets.session_idle_timeoutandsession_absolute_timeout— different questions. Idle is "nobody is there"; absolute is "this has been valid long enough", which no amount of activity should extend. Enforced inSession::staticIsLogged()rather than in a middleware, because that is the function every "is somebody signed in" path goes through: a timeout in a middleware is a timeout the paths that skip it do not have.revoke_sessions_on_password_change— people change a password because they think somebody else has it. Leaving the other sessions alive means the other person keeps the account while the owner believes they have just taken it back, which manufactures false confidence. The current session is spared, or the change reads as a failure.require_second_factor_from_usertype— an administrator with a password and nothing else is the most valuable account in an installation, and leaving that to the preference of the person holding it means the answer is usually "no". Above the floor the step-up is unavoidable; for an account with nothing enrolled it resolves to a mailed code, so it cannot lock anybody out.
Two decisions worth recording. A session with no recorded start or activity is treated as starting now rather than as infinitely old — otherwise switching either timeout on signs out every existing session at once, which is how a security setting gets switched straight back off. And checking passwords against breach corpora is deliberately absent: it needs an outbound call per password change to a third party, which is a decision an application makes with its own privacy notice.