25 September 2026¶
4 changes:
- A second cache of the request meant a declared-public endpoint answered 403 in tests
- With no mail configured, nothing asks for a mailed code any more
- The authenticator app names the site, not Pramnos
- Queued mail is sent: mail:flush is on the framework schedule
A second cache of the request meant a declared-public endpoint answered 403 in tests¶
Reported the day after public_api_paths shipped, and measured on both sides: every
request to a declared-public endpoint answered 403 APIKeyMissing under PHPUnit, while
the same endpoint answered correctly on the live server.
Cause. Factory::getRequest() cached its answer in a function static:
public static function &getRequest()
{
static $instance = null;
if (!is_object($instance)) {
$instance = \Pramnos\Http\Request::getInstance();
}
return $instance;
}
Request::resetInstance() clears Request::$instance and the derived statics, and
documents itself as doing exactly that. It cannot clear a function static in another class.
So after a reset there were two request objects — a fresh one, and the bootstrap's still
held by the factory — and the second was the one Api::exec() handed to the middleware
pipeline.
Almost nothing noticed, because routing reads $_GET['r']. ApiAuthMiddleware did:
isPublicPath() reads the request's own URI and takes an empty path as no match —
correctly, and by design, so a caller that cannot tell takes the closed branch. Under a test
runner the bootstrap's request was built with no REQUEST_URI at all, so its own URI was
'' for ever and every public path was closed.
The quiet part is the expensive part. An application testing an endpoint it declared open gets a 403 it cannot explain, and the natural next move is to decide the declaration is wrong and widen it — a security change made to fix a harness artefact.
Fixed¶
Factory::resetRequest(), called byRequest::resetInstance(). The cache moves from a function static to a class one, which is the whole of what was needed: the same lifetime, now reachable.- The first attempt was to delete the cache entirely — return
Request::getInstance()and be done, one fact with one owner. ThreeSessionTestcases went red, and they were right to:getRequest()returns by reference, and that is an API rather than an accident. A test substitutes a mock with$r = &Factory::getRequest(); $r = $mock;, which works only while the reference points at something outliving the call. The report's own suggestion was the better one. - Two assertions: the reported symptom end to end (a declared-public endpoint reached
through
Api::exec()with no API key anywhere), and the invariant underneath it (the factory follows a reset, and is still a singleton within one request). Both fail with the static restored, the first with the exact403 APIKeyMissingbody.
Documentation¶
- Testing Guide — beside the section on declaring a request in a test, because the reasoning generalises: a second cache of the same fact disagrees exactly when somebody resets.
- Upgrade Guide — a row for the only case this reaches, a test that relied on the factory keeping the previous object.
With no mail configured, nothing asks for a mailed code any more¶
Found on a first deployment: the first administrator signed in, was asked for a six-digit
code by email, and the server had no mail configured yet. There was no way in. The account
held no factor, require_second_factor_from_usertype demanded one, and the only thing that
satisfies that demand for an account with nothing was the mailed code. The code was stored,
the send failed, and the page kept waiting for digits that did not exist anywhere.
Every one of the places that made the demand was written on the premise that a mailbox is the one factor every account has. That is true of the account and false of an installation that cannot send.
Fixed¶
Email::isConfigured()— new; true whensmtp_hostis set. The transport is SMTP and nothing else, so this is whether sending can happen at all.EmailSecondFactor::isAvailable()is false without it. The email factor is not offered, and an account that enabled it is treated as not having it.LoginFlow— the privileged-account floor demands nothing of an account with no factor while there is no mail. The password is let through;require_factor_enrolment_from_usertypeis what holds the account at the setup screen.NewSignInAlert::requiredFor()—authlinkresolves asrequire_2fadoes, andrequire_2fa's mailed-code fallback becomes the passkey, or nothing.
An account that holds an authenticator or a passkey is asked for it exactly as before: losing mail removes the one factor that depends on it, not the others.
See the Upgrade Guide for what this means on an installation that runs without SMTP.
The authenticator app names the site, not Pramnos¶
Scanning the setup QR code added an entry called Pramnos to the authenticator app, on
every installation. TwoFactorAuthService::startSetup() takes the issuer as its third argument
and defaults it to the framework's name, and the TwoFactorAuth controller never passed one.
Fixed¶
TwoFactorAuth::issuer()— new, protected.auth_brand_name, thensitename, then the application'sname, thenPramnos: the order the sign-in card uses.setup()passes it.
An account that already scanned a code keeps its entry as it is — the issuer is a label in the app, and nothing the server does renames it. Rename the entry by hand, or turn two-factor off and enrol again.
Queued mail is sent: mail:flush is on the framework schedule¶
Found on a first deployment: a security notice ("a second sign-in step was added to your
account") was recorded in mails as queued and never left. Email::queue() and every
queueable notification write their row and return; mail:flush is what sends it, and nothing
scheduled mail:flush. The command, its guide section and its tests existed; its caller did not.
Fixed¶
mail:flushruns every minute fromFrameworkSchedule, besidespool:drain. An empty outbox is one indexed query.
On the first run after upgrading, rows younger than mail.outbox.deadline (24 hours by default)
are sent, and older ones fail with their reason instead of arriving late. See the Upgrade Guide.