26 August 2026¶
19 changes:
- One set of controllers, two addresses
- The administrator that could not administer
- The admin links that left the admin area
- The logout that revoked nothing
- A webhook queue with no consumer
- Three endpoints that had never worked
- A column you could write and not read
- Every scaffolded project refused to test itself on macOS
- The test database that would not copy
- The recovery path was the crash
- The HTTP tests that were all testing the home page
- The discovery document that was not JSON
- The pages that rendered nothing
- The scopes the server advertised and refused
- Five actions that could never be called
- Four views indexing keys that were never there
- A password you can set from a shell
- Two language objects, and themes that could not be found
- The manifest that synced nothing
One set of controllers, two addresses¶
Every project that has wanted its admin screens under /admin has written the same
thing: a second set of controllers, or a prefix check inside each one, or a rewrite rule
per screen. None of it is necessary. The controllers are already the right ones — all that
separates /admin/Users from /Users is the prefix.
Added¶
- An administration area under a URL prefix, configured once:
// app/app.php
'admin' => [
'prefix' => 'admin',
'theme' => 'admin',
'min_usertype' => 80,
'default_controller' => 'Dashboard',
],
The prefix is removed before anything splits the path into controller and action, so
routing, actions, _option and the key/value tail all behave exactly as they do without
it. There is no second code path to keep in step, and no controller knows which address
it is being served at.
Inside the area the configured theme replaces the site theme, and the usertype floor is enforced before a controller is even resolved — so a screen inside the area is never constructed for somebody who may not be there. The floor does not replace each controller's own check; those still run, and several are stricter. It is what stops the area being browsable, so a screen that forgot its own check is not the only thing between an ordinary account and the dashboard.
The two refusals differ deliberately. A guest is sent to sign in carrying the address they asked for. A signed-in user below the floor is sent to the site root instead: showing them a login form they are already past reads as a broken session, and they retype their password rather than understanding they lack the privilege.
Pramnos\Http\AdminArea—isActive(),prefix(),url(). AdminNavItems now build their URLs through it, so they lead into the area from anywhere, including the public site header that shows the same section. With no area configuredurl()returns a plain application URL, which is what keeps the nav registration free of conditionals.
Two details are load-bearing enough to state.
The prefix must match a whole segment. /administration is not inside an area mounted
at admin. A str_starts_with check would put it there, restyle it, and hand routing a
mangled path — so the segment test has its own data-provider case per near-miss.
REQUEST_URI is never rewritten. Everything that sends somebody back where they were
reads it: a login redirect's return=, session tracking, a log line. A stripped one would
bring a refused administrator back to the public copy of the page they were trying to
reach.
Documentation¶
- Routing gains "An administration area under a prefix" —
the configuration, what changes inside the area, why the two refusals differ, and the one
ordering constraint (detection happens in
Application::__construct(), so a front controller that builds aRequestfirst will route the prefix as a controller name).
The administrator that could not administer¶
user:create --admin printed "created successfully (admin)" and produced an account
that could not open a single administrative page. It set usertype = 1; every
administrative screen in the framework requires 80 or 90.
Fixed¶
--adminnow creates the account at usertype 90. That is the tier the screens actually ask for: Users, Settings, Logs, Dashboard, Services, Organizations, Emails and Queue want 80 or more; Applications, Tokens, Permissions,/health/phpinfoand the dev panel want 90.
init has always created its own first administrator at 90, so the two paths disagreed
— and the broken one is the one init points at when it cannot create the account
itself ("Run manually: … user:create --admin"). Somebody following that instruction
got an account that signed in perfectly and was refused everywhere.
The success line now names the tier (usertype=90, administrator) rather than a bare
"admin", so what the command did is visible in its output instead of having to be
inferred.
Added¶
--usertype=N, for the tiers between an ordinary account and an administrator. It wins over--admin, because somebody who names a number has a number in mind.
A value that is not a non-negative whole number is refused, not coerced. (int) on
a typo yields 0, which would create an ordinary account, report success, and leave
nothing to distinguish it afterwards from one that was meant to be ordinary. There is a
data-provider case per way of getting it wrong: a word, a negative, a fraction, a
trailing character.
Documentation¶
- Console gains "The tier
--admingrants", with the per-screen minimums in a table and the reason the number matters.
The admin links that left the admin area¶
Mount the administration screens under /admin and every one of them works — until you
click something. A view that links with a bare sURL . 'Users' reaches the same
controller through the site layout: no sidebar, no admin chrome, a different page
around the same table. Every row action, "back" link and pagination control in the
bundled views did that.
Fixed¶
- 242 links across all three bundled themes now go through
adminUrl(). With an area mounted they stay inside it; with none configuredadminUrl('Users')is exactlysURL . 'Users', so one view serves both kinds of application and no view needs a conditional.
User-facing links are deliberately left bare. An administrator clicking "My account"
wants the public account page, not an admin-framed copy of it, so account, login,
register, Passkey and TwoFactorAuth still leave the area. There is a test for that
direction too — without it a blanket rewrite would have looked like a pass.
- Bootstrap classes had leaked into the Tailwind theme. Four tiles on the admin
dashboard carried
text-bg-primaryand friends, which Tailwind does not define: white text on a transparent surface, invisible on a light background, with nothing in any log to say so. Three buttons on the token-actions screen carriedbtn-outline-*and rendered with no border or colour.
Both are now Tailwind utilities, and there is a data-provider test that fails on any
Bootstrap-only class appearing in that theme again. The four empty <div > wrappers
around the tiles — leftovers of a Bootstrap grid column, doing nothing inside a CSS grid
— went with them.
Added¶
adminUrl(string $path = ''): string— a global helper overAdminArea::url(), because a view reads better for it and 242 call sites read a great deal better for it.
Documentation¶
- Routing gains "Links inside the area": the two forms side by side, why the bare one is wrong, and which links are meant to stay bare.
The logout that revoked nothing¶
POST /oauth/logout answered {"success": true} and left every token valid. It had
done so for as long as it has existed.
Fixed¶
- The endpoint revokes tokens again. Its lookup selected
usertokens.sid— a column that has never existed in that table. The query failed, the query builder swallowed the failure and returned nothing, and the endpoint took its token-not-found branch for every token it was given. It reported success and did nothing.
That is the worst shape a security bug can take. An application calling this on sign-out had every reason to believe the user was signed out; the tokens stayed valid until they expired on their own.
A session is now the token family: the access token and the refresh token issued
with it, linked by usertokens.parentToken — the column the refresh-token repository
writes precisely so that "revocation can cascade", as its own docblock says. Presenting
either one revokes both. A token issued to another device belongs to another family and
is left alone, which is what distinguishes this from signing out of everything.
The revocation is scoped to the owning user as well as to the family, so a crafted
parentToken cannot reach another account's tokens.
-
logoutwebsession=1does what the parameter says. It was accepted and ignored; it now ends the browser session too. Without it the browser session is deliberately left alone. -
The response says what happened.
tokens_revokedis in the body, so a caller can tell a real revocation from a token that was not found — which, given the above, is a distinction worth being able to make.
An unknown token still answers {"success": true}, and that is deliberate: in the spirit
of RFC 7009, an endpoint that distinguished a real token from an invented one would be an
oracle for which tokens exist.
Oauth::extractBearerToken() also became protected. Nothing outside the class could call
it before and nothing can now; widening it is what lets the endpoint's decisions be tested
without building a request, which is how a query against a column that does not exist went
unnoticed for so long.
Documentation¶
- Third-Party Integration gains "Signing out": both endpoints, what a token family is, and why an unknown token still succeeds.
A webhook queue with no consumer¶
WebhookService signs deliveries with HMAC-SHA256, retries them with exponential
back-off, and records every attempt. GDPR erasure, device deauthorization and
permission changes have all been queueing events into it. Nothing in the framework ever
called processQueue().
Added¶
auth:webhook-deliver, registered in the framework schedule to run every five minutes. That is where the retry back-off starts, so a slower cadence would not delay only the first attempt — it would delay every one of them.
The events had been written and had stayed pending forever. The failure is invisible
from both ends: the server logs a successful queue write, and the relying party has
nothing to notice the absence of. An application that registered an endpoint simply
never heard anything, and no error was raised anywhere.
The command is quiet on an empty queue — it runs 288 times a day, and a line per run
buries the ones that matter. A failed delivery exits 0: the event keeps its attempts
and its back-off, and a non-zero exit would make a scheduler treat an unreachable
relying party as a broken command. --purge=N drops settled events older than N days.
Pramnos\Auth\Controllers\Webhook— the way in, which did not exist. The tables were there and the delivery worked, and the only route to a row inoauth2_webhook_endpointswas anINSERTby hand.
POST /Webhook/register endpoint_url, webhook_type → { webhook_id, secret }
GET /Webhook/list → this client's endpoints
GET /Webhook/stats → delivery counts
POST /Webhook/test webhook_id → queue a ping
POST /Webhook/delete webhook_id → remove one
Every action authenticates with client credentials, and appid is taken from those
credentials rather than from the request — so there is no parameter pointing at another
application's configuration. An endpoint that is not yours answers 404 rather than 403,
because confirming that an id exists is exactly what somebody enumerating them wants.
The signing secret is returned once, by register, and never again: an endpoint
that hands out its own signing secret to anyone who can call it is not signing anything.
Registering the same event type again replaces the URL and issues a new secret, which is
what somebody does when they have lost it.
https:// is required. The event describes a person and is signed with a shared secret;
over plaintext both are readable by anything on the path.
POST /Webhook/test queues through the real pipeline rather than delivering inline — a
test that took a shortcut would only prove the shortcut works.
initscaffolds the thinWebhookcontroller for an authserver project, alongside the others.
Documentation¶
- Third-Party Integration gains "Registering an endpoint", "Verifying a delivery" and "If nothing arrives" — the last of which starts with checking that the schedule is running, since that was the failure.
- Console documents the command, its cadence, and why it
exits
0on a failed delivery.
Three endpoints that had never worked¶
grant_type=client_credentials with a client secret answered server_error.
Introspection answered {"active": false} for every access token this server had ever
issued. Revocation answered {"success": true} and revoked nothing. All three had the
same two causes, and all three were reachable from the first release that had them.
Fixed¶
- The secret-authenticated
client_credentialsgrant issues tokens. It wroteusertokens.userid = 0, and 0 is not a row inusers, so the foreign key refused the insert and the endpoint returned a 500. The ordinary form of the grant did not work at all.
The reason only one form of it worked is instructive: the JWT client assertion path carries its own thirty-line block that creates a per-application system account, so a token issued that way had a real owner. The League-driven path — the one everything else uses — had nothing.
That block is now Auth\Application::systemUserId(), called from both. An application
gets one machine account, created on first use, reused afterwards, usertype 1 so it
sits below every administrative threshold. It also stopped accepting systemuser values
of 0 or 1: those are the guest and system rows, and a column left holding either would
attribute an application's tokens to an identity shared with every other application
that had the same gap.
- Introspection finds the token. A token issued through the League server is a
JWT; what
persistNewAccessToken()stores is itsjti, the opaque identifier League generates. Both endpoints matched the presented value literally, so neither ever found an access token this server had issued.
For a resource server that trusts introspection, that is every request refused. The
lookup now tries the literal value first — so web-session and API tokens, which are
stored verbatim, behave exactly as before — and falls back to the jti inside a JWT.
The signature is deliberately not verified: the stored row is the authority on whether a
token is active, a jti is only useful to somebody who already holds the token it came
from, and requiring verification would make every token issued before a key rotation
introspect as dead while it was still valid.
- Revocation revokes. Same cause, and worse consequence: RFC 7009 makes the endpoint answer 200 whether or not anything matched, so an application revoking on sign-out was told it had worked every single time while the token stayed valid until it expired on its own. Nothing anywhere reported it.
Documentation¶
- Third-Party Integration gains "Client
credentials, and the account behind the token" — which is where the
sys_*username and the non-humansubin an introspection response come from.
A column you could write and not read¶
PostgreSQL folds an unquoted identifier to lower case. compileInsert() and
compileUpdate() have always quoted; the read paths never did. So a column named
parentToken could be written and never read back: SELECT … parentToken asked for
parenttoken, PostgreSQL said no such column, and the builder returned nothing.
Fixed¶
- A bare column name containing an upper-case letter is now quoted in
select(), everywherevariant,whereIn, the null checks,whereBetween,groupBy,orderByandhaving— with the dialect's own quoting character, so the same code works on both engines.
The reason this went unnoticed for so long is worth stating: a failed query and an empty result reach the caller identically. Nothing raises, nothing logs at the call site, and code written on top reads as "there were no matching rows". Three endpoints had been built on that silence and shipped broken.
The predicate is deliberately narrow. Only a bare identifier with an upper-case letter is quoted:
| Written | Emitted | Why |
|---|---|---|
parentToken |
"parentToken" |
Would otherwise fold |
tokenid |
tokenid |
Folding cannot affect it |
ut.parentToken |
unchanged | Qualified — the builder cannot tell an alias from a schema |
MAX(ut.lastused) AS x |
unchanged | An expression |
*, ut.*, "parentToken" |
unchanged | Not a bare name, or already quoted |
Leaving all-lower-case identifiers alone is the point: folding cannot affect them, so no existing generated SQL changes anywhere. The whole change is invisible except where a query was already failing. The suite — 11,206 tests across MySQL, PostgreSQL and TimescaleDB — is unchanged by it.
A qualified camelCase column is still yours to quote, or to avoid by selecting * and
reading the field from the result; the name survives there either way.
/oauth/logoutfinds the token it is handed. The last of the endpoints built on the silence above. It resolves the presented value the wayintrospectandrevokenow do — literal first, then thejtiinside a JWT — and reportstokens_revoked, counted before the update becauseupdate()answers a boolean and a caller reading that field would always have seen 0.
That field is not decoration: the endpoint answers success whether or not anything matched, so it is the only way to tell a real revocation from a token that was not found.
Tests¶
- Two
usertokensfixtures invented asidcolumn and omittedparentToken— exactly backwards from the real table, and precisely how a production query selectingsidpassed its tests for as long as it did. A fixture that disagrees with the migration is not a test of anything.
Documentation¶
- QueryBuilder gains "Column names with upper-case letters", with the table above and the portable way to read a qualified one.
Every scaffolded project refused to test itself on macOS¶
./dockertest held its lock with flock. On macOS flock does not exist, so
flock: command not found made the acquire fail and every run answered "another
./dockertest run is already in progress" — with no other run anywhere. The suite could
not be started at all.
Fixed¶
- The scaffolded
dockertestlocks with a directory.mkdiris atomic on Linux, macOS and WSL alike and succeeds only when the directory does not already exist; a PID file inside it lets a later run recognise a lock left behind by a hard-killed process.
The framework's own dockertest was fixed for exactly this reason, and kept
generating the broken version for every project it scaffolded. A developer following
the framework's own instruction to "always run tests via ./dockertest" was told, on
a supported platform, that they could not.
flock released itself when the process exited; a directory does not, so the release
is now an explicit trap '_release_lock' EXIT and every early exit path goes through
the same function. Without that the fix would have traded one platform's failure for
every platform's: the first run would leave a lock and the second would refuse.
There is a test that scaffolds a project, greps the generated script for flock, and
runs bash -n over it — because a script assembled from a heredoc full of escaped
dollars being written is a long way from it running.
- And it no longer assumes GNU
timeoutexists. Found immediately after the lock, one guard further on:timeoutis coreutils and macOS does not ship it either, so every daemon-hang guard exited 127 and the first one announced that Docker was not responding — while Docker was running perfectly.
A real timeout is preferred, then gtimeout from Homebrew coreutils, then a small
bash implementation covering the two call forms the script uses. It returns 124 on a
deadline like GNU timeout does, because the callers test for that code to tell a
wedged daemon from a command that simply failed.
Both halves were already fixed in the framework's own runner. Neither had reached the one it generates — which is the pattern worth noticing: a fix applied to the tool and not to its output is a fix that only the maintainer receives.
Documentation¶
- Testing gains "
./dockertestsays a run is already in progress": where the lock lives, what--forcedoes, and how to fix a project scaffolded before today — version control does not update its copy for you.
The test database that would not copy¶
A TimescaleDB project's suite failed before a single test ran, and only sometimes:
Database setup failed: SQLSTATE[55006]: Object in use: 7 ERROR: source database
"template1" is being accessed by other users
DETAIL: There is 1 other session using the database.
What was happening¶
TestEnvironment::setupPostgres() recreates the test database as a copy of
template1. That is deliberate: the TimescaleDB image installs the timescaledb
extension into template1, so a database copied from it has the extension and a
database copied from template0 does not.
PostgreSQL will not copy a template database while any session is attached to it.
The setup code terminated the sessions on the target database — the app, a stray
psql — which is the obvious hazard and was handled. Nothing terminated the
sessions on the template, because in plain PostgreSQL there are none.
TimescaleDB is not plain PostgreSQL. The extension runs one background-worker
scheduler per database, and it enumerates every database including template1.
That worker connects, idles, disconnects and reconnects on a schedule of its own.
Whether the copy succeeded came down to where in that cycle the suite happened to
start — which is why the failure looked random and why it never reproduced when
you ran the failing test on its own.
The fix¶
setupPostgres() now terminates template1's sessions as well as the target's,
and — because the scheduler can be back before the next statement runs — retries
the terminate and the copy together:
self::retryWhileTemplateBusy(function () use ($pdo, $dbName) {
$pdo->exec(
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity "
. "WHERE datname = 'template1' AND pid <> pg_backend_pid()"
);
$pdo->exec("CREATE DATABASE \"$dbName\" WITH TEMPLATE template1");
});
Ten attempts, 200 ms apart. Only SQLSTATE 55006 is retried: a wrong password or a missing role still fails on the first attempt, rather than two seconds and ten identical failures later.
The two halves matter equally. Terminating without retrying is a race the scheduler wins often enough to keep the flake. Retrying without terminating waits for a worker that has no reason to leave.
Notes¶
- Nothing to change in a project. The retry is inside
TestEnvironment, which every scaffoldedtests/bootstrap.phpalready calls. - MySQL is unaffected —
CREATE DATABASEthere copies no template. - Terminating a background worker is safe: TimescaleDB restarts its schedulers,
and
template1holds no state anyone is using.
Documentation¶
Pramnos_Testing_Guide.md— new section under the./dockertesttroubleshooting material, covering the symptom, whytemplate1and nottemplate0, and why the terminate and the copy are retried as a pair.
The recovery path was the crash¶
View::getTpl() has an else branch for "I could not find that template". On PHP 8, with
?format=json, that branch was a fatal.
Fixed¶
View::getTpl()no longer fatals when a template is missing and?format=jsonis asked for. Two lines that did not agree with each other:
public $model = false; // View.php:77
if (isset($this->model)) { // View.php:789
if (method_exists($this->model, 'getJsonList')) {
isset() answers not null, not not empty, so on a value of false it returns
true. The guard therefore passed for every view with no model, and
method_exists(false, …) is a TypeError on PHP 8:
Uncaught TypeError: method_exists(): Argument #1 ($object_or_class)
must be of type object|string, false given
The branch it sat in exists to recover: a few lines further on it logs "Cannot find
view template". So the handler for a missing template was the thing taking the page
down. Reported as FW-021 from a consuming application's home page, with the stack trace
from its php_error.log — not a reading of the source.
The guard is is_object() now. The default stays false rather than becoming null:
isset() would then work, but anything comparing === false would change meaning, and
the question being asked is "have I got an object" either way.
- The sibling sites the filing asked about were checked. There are 105
isset($this->…)insrc/, and only two on a plain property rather than an array index: this one, andConsole\MakeCommandBase::$output. The second is correct — a typed property with no default, whereisset()genuinely answers "uninitialized". So this was the only occurrence.
Tests¶
The regression test was verified backwards: the isset() guard was restored temporarily
and the test reproduced the reported TypeError at View.php:804. A regression test that
does not fail against the old code is worth nothing, and this one is a "does not throw"
assertion, where that is easy to get wrong without noticing.
The other two cover the reason the branch exists at all — a model that does expose
getJsonList() still gets to answer, and one that does not falls through — so the fix
cannot quietly become "skip the branch".
Documentation¶
- Framework Guide gains When a template is not
found, next to Template Files: what
getTpl()does, and the?format=jsonmodel hand-off that is otherwise indistinguishable from magic.
The HTTP tests that were all testing the home page¶
TestClient is the documented way to test an endpoint. For any project routing the
classic MVC way, it had been answering every request with the site's home page.
What was happening¶
TestClient::call() set up the request the way you would expect: REQUEST_METHOD,
REQUEST_URI, the headers, $_POST, the query string parsed into $_GET. Then it
built a Request and asked it for the controller.
Request derives the controller from $_GET['r'] and only from there, because
that is what the scaffolded .htaccess rewrites every URL into:
Nothing set it. So calcParams() never ran, getController() came back empty, and
the classic-MVC fallback ran $this->app->defaultController — the home page — for
every path you could ask for. Status 200, a full page of HTML, assertions passing.
That last part is what makes it worth a post. The failure had no symptom. A test
written to prove that /admin/users refuses a guest asked for /admin/users, got
the public home page, found no admin content in it, and passed. So did a test
asserting a 404 for a route that does not exist, and one checking that a
signed-out visitor cannot see somebody's profile. They were all describing the
home page.
Attribute-routed projects were fine — Router::dispatchSafe() reads the URI
directly, and that path ran first.
Five more, found on the way¶
A request to / served the previous request's controller. calcParams() only
runs when there is a path to route, so with no path the routing statics kept what
the last request had put there. In a one-request web process that cannot happen;
in a client making several calls it happens immediately. TestClient now calls
Request::resetInstance() per request — which also fixes Request::getInstance()
handing back the first request's object forever.
The administration area was decided once. Application::__construct() calls
the detection, which is correct for a process that serves one request and wrong for
anything else: the second request to /admin/... was not recognised as being
inside the area, so the prefix stayed in the route and the usertype floor did not
apply — and a first request to /admin left the admin theme selected for every
public page after it.
That is now Application::beginRequest(), which restores what the area overrides
(theme, default controller), resets AdminArea and detects again. The constructor
calls it, so a single-request process behaves exactly as before.
The area's usertype floor was never applied. It lives in Application::exec(),
and TestClient resolved the controller itself rather than going through exec().
So every /admin/... request in a test was served with no floor at all — and tests
written to prove the floor works passed, because the screens have their own
checks. The suite would have kept passing right up to the first screen that forgot
one, which is the entire reason the floor exists. allowAdminAreaRequest() is now
public and TestClient calls it where exec() does; a refusal is a pending
redirect, read back with the new Application::getRedirect().
No theme was loaded, so no response was a page. Application::exec() loads the
configured theme before running the controller; TestClient did not load one at
all. Responses were the controller's own output with no header, no navigation and
no footer, so nothing a test said about a page was true, and a theme that fails
to render was invisible to the whole suite.
Every response carried the ones before it. The document is per-request and
everything on it appends — content, and the header/head/foot that render()
adds the theme's to on each call. Response 2 arrived with response 1's page in
front of its own and its <head> twice; by the fifth request in one test the theme
had been concatenated five times and the run died on a 34 MB output buffer.
The content half of this went deeper than the instances and was not finished here —
see reset() left the page where the next request would find
it.
assertSee() passing on a page the test had already left is the quieter half of
the same bug. TestClient now resets the document per request.
Every 404 was a 500. Application::notFound() and showError() end the
request through close(), which throws under PRAMNOS_TESTING — as a bare
\Exception, so a not-found, a maintenance stop and a genuine fault were
indistinguishable and all three rendered as 500s. There was no way to assert that a
URL is not found. They now carry the status they decided on, in a typed
ApplicationClosedException.
close() itself is untouched: applications subclass Application and override
close($msg = ''), so a new parameter on it is a signature break in every one of
them — this framework's own suite has such a subclass, which is how that was
established rather than guessed. The status goes through a new
closeWithStatus().
And two that were never wired up¶
loginUser() signed nobody in. It set $_SESSION['auth'] and
$_SESSION['user_id']. Nothing reads either: Session::staticIsLogged() wants
logged and a uid above 1, and User::getCurrentUser() builds the user from
uid. So every test that called it exercised the signed-out path while reading as
though it covered the signed-in one — a test named "an administrator can open this
screen" was testing the guest redirect. It now sets the keys the framework reads
(keeping the old ones, which the session-tracking middleware copies to a cookie),
clears the cached identity, and has a logoutUser() counterpart, because a
process-wide session with no way back leaks a sign-in into every test after it.
The CSS selector assertions could not run downstream.
assertSelectorExists(), assertSelectorContains() and assertSelectorAttribute()
need symfony/dom-crawler, which is a dev dependency of this framework — and a
dependency's dev dependencies are not installed. Three documented assertions threw
Class "Symfony\Component\DomCrawler\Crawler" not found in every project that
tried them: a true message and a useless one, naming an internal class rather than
the two packages, and surfacing as an error in the test that used it, which reads
as a fault in the page under test. Scaffolded projects now get both in
require-dev, and the assertion says what to install. They stay out of require:
nothing in production parses HTML.
And PRAMNOS_TESTING reached only this framework. Application::close() calls
exit() unless it is defined. This framework's own bootstrap defined it; the
bootstrap it scaffolds for a project did not. Under PHPUnit an exit() is not a
failing test — the process stops mid-run, the summary never prints, and whatever
the dying page wrote lands in the terminal looking like output. One database fault
truncated a project's entire suite, leaving a maintenance page where the results
should have been. It is now defined in TestEnvironment::setup(), which every
project already calls, including the ones already written.
What to do¶
Nothing, to get the fix. But re-read any HTTP test written before this: it was passing against the home page, and it may not pass against the endpoint it names. That is not a regression in your code — it is the first time the test ran.
Documentation¶
Pramnos_Testing_Guide.md— a dated warning on the HTTP Testing section, and a new "One client, many requests" section onbeginRequest()and what is per-request.
The discovery document that was not JSON¶
/.well-known/openid-configuration was 173 KB. A correct one is about four.
What was happening¶
Every action on Pramnos\Auth\Controllers\Discovery ended the same way:
echo writes to the output stream. Returning from the action does not end the
request — the framework goes on to render the page it was always going to render,
and appends it to what the action already wrote. So the response was the discovery
document, correct and complete, followed by the site's home page: navigation,
footer, inline scripts, the lot.
Six endpoints did this: openid-configuration, jwks.json,
oauth-authorization-server, .well-known/health, /Discovery/serverConfig, and
the /config alias projects put on top of it. Only /status/check was correct,
because the health controller returns a Response object rather than echoing.
Why nobody noticed¶
The JSON is first.
Every way a person checks one of these looks perfect. curl | head. A browser's
raw view. The first screen of a log. The response opens with a well-formed
document, and the 170 KB of markup is past the bottom of the terminal.
The tests looked right for a sharper version of the same reason: they captured the output stream around the call, which held exactly the JSON — the page was appended after the action returned, outside the buffer they were reading. They were asserting the part that worked, using the mechanism that broke it.
Only a client that parses the whole body ever saw it, and what it saw was "malformed JSON from the identity provider" — which reads as a network problem, or their own bug, on their side of the wire.
The fix¶
Answer with the framework's raw document rather than the output stream:
\Pramnos\Framework\Factory::getDocument('raw')->setContent(
(string) json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
);
getDocument() makes the type it is asked for the default, so this is the
document the framework renders. The body is the JSON, and nothing follows it.
The OPTIONS preflight gets the same treatment with an empty body: a 204 that
rendered a page was the same bug wearing a different status code.
Tests¶
The new tests assert the shape of the whole response rather than the presence of JSON inside it — parses as JSON, contains no markup, and echoes nothing. The last one is the mechanism rather than the symptom: while a body is echoed, the framework is still going to render a page after it, and the next endpoint added here would have been written by copying the one above.
Notes¶
- Client-side workarounds — truncating at the first
}at column 0, or a regex — can be dropped, and should be: the shape they rely on is gone. - Any project overriding one of these actions and echoing its own body has the same problem in the override.
Documentation¶
Pramnos_AuthServer_Integration_Guide.md— new "If a discovery response will not parse" section under Discovery, with the date, the symptom and what changed.
The pages that rendered nothing¶
The OAuth2 consent screen did not exist. Neither did any page of the device-authorization flow. Six screens, all answering 200 with the right title and an empty body.
What was happening¶
View::display() returns the rendered markup. It does not echo it:
public function display($tpl = '', $render = false)
{
$this->getTpl($tpl, '', $render);
return $this->output;
}
A controller action that returns is fine — Application::exec() adds the returned
value to the document. These six did not return; they called display() as a
statement and threw the string away:
$view->display('authorize'); // Oauth::showConsentForm()
$view->display(); // Device — code entry
$view->display('confirmation'); // Device — approve
$view->display('success'); // Device — approved
$view->display('deny'); // Device — denied
$view->display('errormessage'); // Device — error
Every one of them is called from an action declared : void, so nothing
downstream picked the markup up either. The response was the theme, the title, and
nothing in between.
Why it lasted¶
Because every signal said the page had worked. Status 200. Correct <title>.
Layout, navigation, footer all rendered. Nothing in any log. The only thing missing
was the part the visitor had come for.
And these are the six screens least likely to be opened by whoever is working on the server. The consent form needs an untrusted client mid-authorization — a trusted one skips it entirely, and a developer's own client is trusted. The device pages have no link pointing at them from anywhere in the site: you reach them by typing a code off a television.
For the person affected it does not read as an authentication-server bug at all. A relying party's users see a blank page after clicking "sign in with…", and report it to the relying party.
The fix¶
The markup goes into the document:
Oauth::showErrorPage() had the mirror-image problem — it echoed, so its message
went out before the page the framework then rendered, producing a fragment
followed by a complete HTML document. Also now added to the document.
And what was behind one of them¶
With the device success page finally rendering, it fataled:
All three bundled views index that value — $this->deviceAuth['user_code'] — and
say array in their docblocks. The cast made rendering the page a TypeError.
Which had never happened, because the render was thrown away: the bug was
protected by the bug on top of it.
Notes¶
- Nothing to change in a project. If you had worked around the blank consent screen by marking every client trusted, you can stop.
- A project with its own copies of these views needs no change; they were always correct.
Documentation¶
Pramnos_Account_Guide.md— a note under the consent screen on where its markup goes, and why an action that renders must return or add.
The scopes the server advertised and refused¶
/.well-known/openid-configuration published twelve scopes. The token endpoint
accepted four, and only one of them was on the list.
What was happening¶
Two lists, and nothing connecting them.
Pramnos\Auth\Scopes is the framework's scope registry. The discovery document's
scopes_supported comes from it, so does the consent screen, so do the permission
checks. On a typical server it holds profile, email, phone, address,
user, openid, offline_access and the system:* scopes.
Pramnos\Auth\OAuth2\Repositories\ScopeRepository is what league/oauth2-server
asks when it validates a request. It carried its own list:
private array $scopes = [
'read' => 'Read access',
'write' => 'Write access',
'admin' => 'Admin access',
'user' => 'User profile access',
];
It had setScopes() and addScopes() for an application to replace them, and
nothing anywhere ever called either. So the server advertised twelve scopes and
accepted four, overlapping on user.
Eleven of the twelve were refused as invalid_scope, and one of the eleven was
openid — which means OpenID Connect did not work at all. A client that read the
discovery document and asked for exactly what it offered got a 400 on its first
request.
Why nobody noticed¶
Because each side is internally consistent, and nobody reads both.
Read the discovery document: the scopes are there, spelled correctly, in a
standards-shaped field. Read ScopeRepository: four scopes, coherent, documented
in the class docblock. Both files look right. The bug is the relationship between
them, which is not written down in either.
And it fails at integration time on somebody else's machine — the moment a new client first calls the token endpoint, which is exactly when an integrator assumes they have got their own request wrong.
The fix¶
ScopeRepository reads the registry:
Resolved on first use rather than in a property initialiser, so constructing the
repository does not force the registry to load. setScopes() and addScopes()
still work — and addScopes() now resolves before merging, or adding a scope
before anything had read the list would have replaced it instead of extending it.
The four original identifiers are kept. An integration built against read and
write predates the registry, and a scope that stops being accepted is an outage
on somebody else's server.
The test worth having¶
Not "the repository accepts openid" — a test per scope goes stale as the registry
grows. The test is the relationship:
foreach (array_keys(Scopes::getScopeDescriptions()) as $scope) {
$this->assertNotNull($repository->getScopeEntityByIdentifier($scope));
}
Every scope the server advertises, accepted. A scope added to the registry is covered the day it is added, and a second list appearing anywhere fails immediately.
Documentation¶
Pramnos_AuthServer_Integration_Guide.md— new "Which scopes you may ask for" section, with the dated note on what used to happen.
Five actions that could never be called¶
Every button on the services screen was a fatal error. So was the clear-log link. Not under some condition — always, since they were written.
What was happening¶
Controller::exec() dispatches every action identically:
$args is the request's arguments array. The bundled controllers are written for
that: an action takes mixed $id = null, ignores it, and reads the URL segment
with Request::staticGetOption().
Five did not:
public function stop(string $name = ''): void
public function start(string $name = ''): void
public function restart(string $name = ''): void
public function logs(string $name = ''): mixed // ServicesController
public function clearFile(string $file = '') // LogController
PHP is handed an array where a string is declared, and throws. There is no input
that makes this work. The four ServicesController actions are every control on
the services screen — stop, start, restart, view logs — so that screen listed
services and could do nothing to any of them.
Why it lasted¶
A fatal on click looks like a broken page, and a broken page on an admin screen that nobody opens on a normal day looks like nothing at all. The screen itself rendered fine, which is what anybody testing it would have checked.
It surfaced from a test that did nothing more than open every screen in an application and check for a 500 — which is also how it became clear that 52 of that project's 71 view templates had never been rendered by anything.
The fix¶
The five actions now match the convention:
public function logs(mixed $name = null): mixed
{
$name = (string) \Pramnos\Http\Request::staticGetOption();
// …
}
The test worth having¶
Not five tests. A structural one that walks every bundled controller and asserts that no public action declares a first parameter the dispatcher cannot fill:
$type = $method->getParameters()[0]->getType();
$this->assertContains($type->getName(), ['mixed', 'array', 'iterable']);
Reading the declaration is enough, and it needs no fixtures — whereas routing to every action of every controller would need one screen's worth of data each. The declaration is the thing that makes an action callable, so it is the thing to assert.
Also: a template nothing rendered¶
Found on the same sweep. scaffolding/themes/*/views/health/check.html.php was
published into every project, and Health::check() returns Response::json(...) —
it never touches a view. So the file sat next to health/health.html.php, named
after an action, looking exactly like the thing to edit if you wanted to change
what /health/check returns. Editing it changed nothing, silently.
Removed from all three bundled themes. A project that already published it can delete its copy.
Documentation¶
Pramnos_Routing_Guide.md— new "What a classic-MVC action must accept" section, with the right and wrong signature side by side.
Four views indexing keys that were never there¶
Every Retry and Delete link on the queue screen pointed at job 0. So did Edit and Delete on the permissions screen. Editing a permission created a new one. And the organization member forms posted to organization 0.
What was happening¶
Four bundled views, in all three themes, indexed a key their controller never returns:
| View | Indexed | Column the query selects |
|---|---|---|
queue/queue |
$job['id'] |
taskid |
permissions/permissions |
$p['id'] |
permissionid |
permissions/edit |
$p['id'] |
permissionid |
organizations/members |
$this->org['id'] |
organization_id |
(int) $job['id'] on a missing key is 0 plus a warning. So the pages rendered,
listed their rows correctly, and every action link on every row addressed record
zero — which does not exist, so clicking one did nothing at all. No error, no
message, nothing in a log except an Undefined array key notice that a production
error level hides.
permissions/edit had a second one on top. The form posted name="id" and
save() reads $_POST['permissionid'], so even with the value fixed the id would
not have arrived. Editing an existing permission therefore inserted a new one and
left the original alone.
Why it lasted¶
The empty state. A fresh database has no queue jobs, no permission grants and no organization members, so every one of these screens renders its "nothing here yet" branch — and that branch has no rows, no links, and no keys to get wrong.
It surfaced from a test that seeded one row into each list and re-rendered it. Four warnings, in four views, in one run.
And a third query that could not run¶
Found the same way, a day later. TimescaleInspector::getScheduledJobs() selected
last_run_started_at, last_successful_finish and last_run_status from
timescaledb_information.jobs. Those columns are in
timescaledb_information.job_stats — one row per job — and jobs describes only
the schedule.
So the statement was rejected, the catch turned it into an empty array, and the database dashboard's scheduled-jobs panel was blank on every server. Which reads as "no policies configured": the same answer a healthy server with no policies gives, to an operator who opened the page precisely to check whether the retention policy is still running.
Now a LEFT JOIN — left, because a job that has never run has no job_stats row
and is exactly the job worth seeing.
Also fixed: the queue screen never said why a job failed¶
QueueController selects error for every job. No column rendered it. So the
screen reported that a job had failed and withheld the only piece of information
anybody opens it for, with the answer already loaded.
The failure reason now renders under the job type, truncated with the full text in
a title.
Notes¶
- A project that published these views has its own copies, and they have the
same bug. Republish (
project:publish-views --group=queue,permissions,organizations --force) or apply the four key changes by hand. - The
emailsandservicesviews indexidtoo, and there it is correct: those rows really do have anid.
The test worth having¶
Seed one row into each list and render it. It costs one fixture per screen and it is the only way to reach the half of these pages that has anything in it — the empty state is what a test database gives you for free, and it is not the page anybody uses.
Documentation¶
Pramnos_Console_Guide.md— a republish note underproject:publish-views, with the four keys and why the empty state hid them.
A password you can set from a shell¶
user:create existed; nothing changed a password. Asked for, and it turns out to be four
writes rather than one.
Added¶
user:password <user>— sets a password with no email round trip. The argument is a username, an email address or a numeric user id, tried in that order;--byrestricts it, which settles the only real ambiguity of a numeric username.
php bin/pramnos user:password alice # prompts, hidden, twice
php bin/pramnos user:password alice --generate # prints one you can hand over
php bin/pramnos user:password 42 --by=userid --password='…'
It is four writes, and three of them are the ones a manual reset forgets. The hash
goes through the User model, so it is salted with md5(securitySalt . userid) — a raw
password_hash() would store one login could never match, and the account would simply
stop working with a correct-looking row in the database. Then:
- pending reset tokens are cleared, or a link mailed out ten minutes ago still works and the account has two valid passwords, one of them held by whoever received the mail;
- a brute-force lockout is lifted, because a locked-out account refuses the correct password with the same message as a wrong one — indistinguishable from "the reset did not work", and the first thing reported back;
- the change is recorded in the activity log, since a credential set from a shell leaves no other trace, which is the whole argument for having one.
Sessions are left signed in unless --revoke-sessions is passed. The ordinary reason
to run this is that somebody cannot get in; signing them out of every other device turns
one problem into several. The flag is for the other reason — a suspected compromise —
and the output names it, so the choice is visible rather than assumed.
The policy is the one the self-service form applies: eight characters, a digit, a symbol.
--generate produces one that passes and prints it. --force accepts one that would be
refused and says so in the scrollback.
A test that found a real defect¶
--force recorded policy_waived: true from the presence of the flag rather than from
whether anything was waived, so forcing a strong password left a false record of a security
decision in the audit log. The test asserting that --force is quiet when nothing was
waived is what caught it; the flag and the waiver are now computed once and separately.
Documentation¶
- Console Commands gains a
user:passwordsection: the four writes and why each is there, why sessions survive by default, and what--forcedoes and does not record.
Two language objects, and themes that could not be found¶
Four filings from a consuming application, and three of them are the same mistake wearing different clothes: a path the class itself does not agree about.
Fixed¶
Language::getInstance() can be an application's own subclass¶
Filed as FW-019, from an application running two language objects — its own with the strings loaded, the framework's without. Everything inside the framework that translates, seven call sites, translated from the empty one. It failed silently, because both objects return the key unchanged for a missing translation: "untranslated key" and "wrong instance" look identical.
getInstance() hardcoded new Language($lang). The filing asked for new static(), and
that does work — PHP 8.1+ shares a method's static locals with its inherited copies,
verified on 8.5.9. But which class you get depends on who asks first, and
Factory::getLanguage() is called from seven places inside the framework, so the order is
not the application's to control. That trades a certain bug for a non-deterministic one.
So the class is declared:
With nothing declared, \<namespace>\Language is tried — the convention
Application::resolveApplicationClass() already uses — and the base class is the
fallback. A declaration naming a missing class, or one that is not a Language, is
ignored rather than fatal. setInstance() covers the other question, here is the object
I already built, and resetInstance() exists because otherwise the first test in a run
decides the language for all of them.
onMissingString() — a hook on the miss path¶
The second half of FW-019, and not decorative: the reporting application uses it to record every untranslated key for a translation tool, and to serve a regional dialect kept as a secondary catalogue. Without it, that region silently reads the wrong dialect.
Whatever the hook returns is formatted with the caller's arguments, exactly as a
stored translation is. The legacy filter it replaces returned its result raw, so a supplied
'Καλώς ήρθες, %s' lost the argument — harmless there only because none of its languages
used a placeholder. Returning the key unchanged means "nothing to offer"; returning ''
means "show nothing", because identity against the key is the test rather than emptiness.
Language::load() reached its English fallback¶
FW-020, and worse than filed. load()'s fallbacks named ROOT/language while the
constructor resolves LANGPATH or app/language — and the English default existed only
under ROOT/language. So on the layout init generates, a missing language file did not
fall back to English: it returned false and the page rendered untranslated. Both are
searched across every candidate directory now, the requested language first and English
second.
getFlag() is deliberately not widened the same way. The filing asked for
$this->languagePath, which would return a URL for a file no browser can fetch —
app/language/ is not under the document root. It checks the two servable locations and
returns false for a flag sitting anywhere else, which is the truth about it.
Themes are looked for where they are¶
FW-023: getThemes() searched only ROOT/themes and returned an empty array silently
on the layout init creates — an empty theme picker with nothing in any log.
getThemeObjects() was worse, opening that directory with no existence check, warning,
getting false, and handing false to readdir().
Both search APP_PATH/themes then ROOT/themes now, and getThemeObjects() is built on
getThemes() rather than repeating the walk — which is what let the two drift.
An existing test was pinning the defect: it passed an explicit $path and asserted [],
commented "not a dir under ROOT/themes → filtered out". It was documenting that an
explicit path could never return anything.
A theme class is checked before it is included¶
FW-022: class_exists() came after the include, so it could not prevent the fatal it
was there to prevent — Cannot redeclare class when the class was already defined.
include_once would not have helped: it keys on the resolved path, so two routes to one
file redeclare anyway, and the reporting application has exactly that — a legacy loader
asking for lowercase theme.php while Composer loads Theme.php.
Documentation¶
- Internationalization Guide gains One language object, and how to make it yours, Catching a missing translation, and Where language files are looked for.
- Theme Guide gains Where themes are looked for, with the include-order note.
The manifest that synced nothing¶
An application pushes a capabilities manifest, the server answers
200 {"status":"synced"}, and stores nothing. The CI job goes green.
What was happening¶
The integration guide publishes the manifest as a map keyed by name, which is the natural JSON for it and what a client sends:
{
"resources": {
"invoices": {
"description": "Customer invoices",
"scopes": { "read": "View invoices", "write": "Edit invoices" }
}
},
"conditions": {
"location_id": { "value_type": "int[]", "description": "Restrict to locations" }
}
}
CapabilitiesSyncService normalised each section with array_values(). That
throws the keys away — so resources became one entry with a description and no
name, the loop's if ($name === '') continue; skipped it, and the response was:
Which is a success, with a zero next to it that nobody reads.
Scopes were worse than skipped. {"read": "View invoices"} has no array to
lose a key from; array_values() left the bare string "View invoices", and the
scope writer takes a string as the scope name. So a server that accepted the
manifest stored a scope called "View invoices" — and an application asking for
read matched nothing. A permission system keyed on prose.
The second half: Basic auth was refused¶
Found while testing the first. RFC 6749 §2.3.1 lets a client authenticate with
HTTP Basic, which is what a CI pipeline does. Apache running as a module decodes
that header into PHP_AUTH_USER / PHP_AUTH_PW and does not pass the raw
Authorization header through — and the usual E=HTTP_AUTHORIZATION rewrite
cannot help, because there is nothing left to copy.
extractClientCredentials() read only the raw header. So a correctly
authenticated client was told:
"Client credentials required" when they were supplied. It reads as a wrong secret, so the next thing anybody does is re-check the secret, and that never helps.
It now falls back to PHP_AUTH_USER / PHP_AUTH_PW, after the raw header so an
explicit one still wins. This affects every client-credentials endpoint, not
only the manifest one.
And the scaffolded www/api/.htaccess gets the Authorization passthrough of its
own: rewriting is per-directory, so the web root's copy of that rule does not
carry into a request rewritten under /api/.
And nothing could read it back¶
The write side existed on its own. An application could push its resources, scopes and condition keys, and no screen anywhere showed an operator what had arrived — which makes "central permission control" a place where data goes in. A grant names a resource, so the question "which names does this client publish" is the one this has to answer, and answering it meant querying four tables by hand.
CapabilitiesSyncService::describe() reads it back, and the client's own page
renders it: resources, their scopes, the condition keys, the manifest hash and when
it last arrived. Deactivated rows are listed struck through rather than hidden —
a grant may still refer to one, and that is precisely what somebody is looking for
when a permission has quietly stopped working.
What to check¶
If you have a pipeline pushing manifests, read the counts in the response. A green job proves the request was accepted, not that anything was stored. A server that has been syncing zero has no resources and no scopes recorded for that client, and any permission grant referring to them was made against rows that were never written.
Documentation¶
Pramnos_AuthServer_Integration_Guide.md— the accepted shapes, the two authentication forms, the response counts, and a dated note on what used to happen.