Skip to content

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\AdminAreaisActive(), prefix(), url(). Admin NavItems 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 configured url() 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 a Request first 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

  • --admin now 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/phpinfo and 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 --admin grants", with the per-screen minimums in a table and the reason the number matters.

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 configured adminUrl('Users') is exactly sURL . '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-primary and 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 carried btn-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 over AdminArea::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=1 does 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_revoked is 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 in oauth2_webhook_endpoints was an INSERT by 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.

  • init scaffolds the thin Webhook controller 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 0 on 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_credentials grant issues tokens. It wrote usertokens.userid = 0, and 0 is not a row in users, 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 its jti, 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-human sub in 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(), every where variant, whereIn, the null checks, whereBetween, groupBy, orderBy and having — 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/logout finds the token it is handed. The last of the endpoints built on the silence above. It resolves the presented value the way introspect and revoke now do — literal first, then the jti inside a JWT — and reports tokens_revoked, counted before the update because update() 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 usertokens fixtures invented a sid column and omitted parentToken — exactly backwards from the real table, and precisely how a production query selecting sid passed 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 dockertest locks with a directory. mkdir is 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 timeout exists. Found immediately after the lock, one guard further on: timeout is 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 "./dockertest says a run is already in progress": where the lock lives, what --force does, 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 scaffolded tests/bootstrap.php already calls.
  • MySQL is unaffected — CREATE DATABASE there copies no template.
  • Terminating a background worker is safe: TimescaleDB restarts its schedulers, and template1 holds no state anyone is using.

Documentation

  • Pramnos_Testing_Guide.md — new section under the ./dockertest troubleshooting material, covering the symptom, why template1 and not template0, 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=json is 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->…) in src/, and only two on a plain property rather than an array index: this one, and Console\MakeCommandBase::$output. The second is correct — a typed property with no default, where isset() 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=json model 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:

RewriteRule ^(.*)$ index.php?r=$1 [QSA,L]

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 on beginRequest() 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 json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
return;

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:

\Pramnos\Framework\Factory::getDocument()->addContent(
    (string) $view->display('authorize')
);

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:

$view->deviceAuth = (object) $deviceAuth;

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:

$this->scopes = array_merge(self::LEGACY_SCOPES, \Pramnos\Auth\Scopes::getScopeDescriptions());

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:

fn() => $this->$action($args)

$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 emails and services views index id too, and there it is correct: those rows really do have an id.

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 under project: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; --by restricts 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:password section: the four writes and why each is there, why sessions survive by default, and what --force does 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:

// app/app.php
'language_class' => '\MyApp\Language',

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:

{"status":"synced","resources":0,"scopes":0,"conditions":0,"deactivated":0}

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:

{"error":"invalid_client","error_description":"Client credentials required"}

"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.