16 September 2026¶
18 changes:
- An OAuth2 client, beside the server the framework already was
- No grant could issue a refresh token on MySQL
- Test fixtures stopped inventing their own
applicationstable - …and their own
usertokens, which was hiding a second one - A test failed with "No such file or directory" because of what another test restored
- A SPA's coverage number was mostly the framework's debug panel
- Every anonymous request emitted a PostgreSQL syntax error, 430,000 a day
- Four defects in what the scaffolder generates, one of which blocked all coverage
- The oauthconnections table sheds an index it never needed, and gains 2038
- A scaffolded project's tests ran against the development database
- …and the fix for it was a no-op, twice over
- A scaffolded project's admin screens all answered 404, and the dev panel could end a test run reporting success
- Every generated SPA screen fetched a doubly-prefixed URL, and a test asserted it
- A SPA could not offer the one sign-in that needs no password
- …and a user with two-factor authentication could not sign into one at all
- The session exchange signed with a key the API does not verify with
- One dead token turned the whole application into "the backend is down"
/robots.txtand/llms.txtanswered 404, and then pointed at three more
An OAuth2 client, beside the server the framework already was¶
Pramnos\Auth\OAuth2 was entirely the server half: league/oauth2-server issuing
tokens to other applications, usertokens recording what it had issued. An application
that needed to sign in to somebody else's platform — Google, Meta, TikTok, an internal
identity provider — had to write the whole conversation itself: the redirect, the state
check, the code exchange, storage with expiry, refresh before expiry, and the per-provider
deviations.
Nothing bundled covered it. league/oauth2-server issues tokens, it does not obtain them;
web-token/jwt-framework verifies a JWT and knows nothing about the authorization-code
flow; Pramnos\Http\Client is a transport, not a grant.
Added¶
Pramnos\Auth\OAuth2\Client, in four pieces:
| Class | What it does |
|---|---|
Provider |
One platform as configuration — endpoints, credentials, scopes, and the places it is not quite OAuth2 |
OAuthClient |
The conversation. No database, no session |
Connection |
One stored authorisation, and whether it is still usable |
ConnectionStore |
Where connections live, and the one place a refresh is written down |
oauth:refresh, a scheduled command. Refreshing when a token is used keeps a busy connection alive and does nothing for an idle one — and the idle one is what dies, because several providers expire the refresh token if it is never exercised. Instagram's lasts sixty days.- A dead connection says so.
status,dead_reasonanddead_aton the row, set when a refresh fails terminally. A feed that goes quiet is otherwise indistinguishable from a feed with nothing new in it, which is how a month of missing data gets noticed a month late. A transient failure — a timeout, a 503 — leaves the connection alone; getting that distinction backwards makes every user re-authorise over somebody else's bad afternoon. - Tokens are encrypted at rest, and
APP_KEYis required. Other callers ofSecurity\Encryptermay degrade to plaintext with a warning. This one refuses: a third-party refresh token is a credential for an account this application does not own and cannot revoke. - A new table,
oauthconnections, keyed on (user, provider, account) — because one user may connect two pages or two channels on one platform, and a key of (user, provider) loses the first the moment the second arrives.
The per-provider deviations are options rather than subclasses, and each one has a test
named after the platform that motivated it: credentials in the body under renamed fields
(TikTok's client_key), a refresh that is a GET to its own endpoint under its own grant
name (Instagram), comma-separated scopes, PKCE.
What was deliberately not built. No league/oauth2-client: it is the sibling package of
the server already in use, but it brings Guzzle, and this framework has deliberately avoided
both Guzzle and symfony/http-client while shipping its own hardened client. The
authorization-code flow is two POSTs and some URL building.
And no abstraction for a provider's second step — Instagram exchanges its short-lived token for a long-lived one at another endpoint under another grant. That is not a setting, and inventing a shape for it against providers this repository cannot call would be guessing. An application performs that exchange and stores the result.
Tested against a real OAuth2 server, not a fixture¶
Every other test of the client asserts against a fake response — a payload this repository wrote, describing what it believes a provider sends. That proves the client is self-consistent and nothing more: a response shape that is subtly wrong is wrong in the test and in the code at once.
So FullAuthorizationCodeFlowTest points the client at the framework's own authorization
server and completes a genuine flow — authorize, consent, real authorization code, real
token exchange, real signed JWT, real refresh — with real rows in usertokens and the
framework's own resource server validating the tokens it gets back. Client::fake() replaces
the socket only; the server is not simulated.
It earned its place twice over: it found the refresh-token defect below, and it found a trap
of its own — UserEntity has no constructor, so new UserEntity($id) is accepted by PHP and
discards the argument. The authorization code is then written against user 0, and the only
thing that objects is a foreign key several layers down, with a message that names neither
the entity nor the user.
Documentation¶
- A new guide, OAuth2 Client (signing in elsewhere), and a line in the nav next to the server half so the two are not confused for each other.
No grant could issue a refresh token on MySQL¶
Found by the end-to-end test above, at the last step of a flow that had otherwise entirely succeeded — the code was valid, the client authenticated, the access token was signed and persisted, and then:
RefreshTokenRepository::persistNewRefreshToken() omitted the scope column.
usertokens.scope is TEXT NOT NULL, and MySQL cannot give a TEXT column a default —
so under strict mode the insert was refused outright. Every grant that issues a refresh
token goes through that method: the authorization-code grant and the password grant alike.
Fixed¶
- The refresh token carries the parent access token's scope, which the method was
already one column away from having —
loadAccessTokenRow()now selects it. That is also the correct value rather than a convenient one: RFC 6749 §6 says a refreshed token may not exceed the scope of the original grant, so the column is that ceiling.
Note: a test that rebuilds schema, and what it may not touch¶
The end-to-end test above needs applications in its canonical shape — the callback
column is the registered redirect URI the server compares the client's against, character
for character — and several tests create that table with hand-rolled DDL and leave it
behind. So it drops and rebuilds it from its own migration, which is the pattern
OAuth2ClientSecretRequiredTest already established.
An earlier version rebuilt usertokens the same way, and that is not the same thing.
usertokens carries a foreign key to applications, so dropping the child left the
constraint dangling and thirty-eight tests with nothing to do with OAuth2 failed
afterwards — account changes, permissions, token actions — each pointing at its own
tables and none at the test that caused it.
Rebuilding the parent is survivable because it is put back in the same breath. Rebuilding
the child is not. The rule is now in the Testing Guide, with the information_schema query
that answers "does anything point at this table" before you drop it.
The key pair moved to setUpBeforeClass() at the same time, which is what the guide's own
performance section says to do with an expensive read-only fixture. Measured after:
16,075 tests in 3:28, against 3:46 for the suite before any of this.
Documentation¶
- The Testing Guide has a new section, "A test that rebuilds schema, without breaking the ones after it".
Test fixtures stopped inventing their own applications table¶
Eight test classes wrote their own CREATE TABLE applications, and no two of them agreed.
Four declared redirect_uri — a column no migration has ever created. Production
registers the URI in callback, and only a view aliases it under the other name, so those
tests set a field nothing consulted, took the "no redirect URI registered" branch and
passed. A fifth carried the same wrong assumption in an array literal. OauthTest had
already found and fixed it in its own fixture, with a comment explaining the trap, while
four siblings still carried it.
Other symptoms of the same drift, all in comments the tests already had: one fixture
"breaks OauthTest when it relies on CREATE TABLE IF NOT EXISTS"; another notes its
apisecret is NOT NULL where the migration makes it nullable.
The cost argument for hand-rolling did not survive measurement:
| per call | |
|---|---|
hand-rolled DROP + CREATE |
9.56 ms |
canonical migrations, drop + up() |
12.18 ms |
| canonical migrations, table already present | 0.23 ms |
A migration's up() returns immediately when its table exists, so the second class to ask
pays 0.23 ms rather than building its own 9.56 ms copy — eight classes building one table
between them instead of eight.
Added¶
Pramnos\Framework\Testing\Schema—Schema::table('applications', $db)builds a table from the migrations that build it in production. It takes a name rather than a list, because the list is what gets written wrong:applicationsis created by one migration, gainssystemuserfrom a second and a widercallbackfrom a third, and the first attempt at this named only two — which surfaced immediately as a test that assigns a system user failing.BaseTestCase::runMigrations()now delegates to it, so there is one implementation. The tests that need it most are the ones that do not extendBaseTestCase, and a protected method they cannot reach is exactly how the second hand-rolledCREATE TABLEgets written.
Changed¶
- Eight fixtures converted. 16,075 tests in 3:22, against 3:22 before — no measurable cost, and one fewer way for a test to pass against a schema production does not have.
Documentation¶
- The Testing Guide leads with it, measurements included, and names the two cases where a hand-rolled table is still right: a migration's own test, which must build the pre-migration shape, and a characterization test pinning an older installation's.
…and their own usertokens, which was hiding a second one¶
Five more test classes wrote their own CREATE TABLE usertokens. Same treatment, same
recipe — Schema::table('usertokens', $db) — and the same kind of thing fell out.
Fixed¶
Oauth::generateAuthCode()omitteddeviceinfo.usertokens.deviceinfoisTEXT NOT NULLand MySQL cannot give a TEXT column a default, so under strict mode the insert was refused and the framework's own/oauth/authorizecould not issue an authorization code at all. Every other writer of that table already passed''. The fixtures it was tested against declared the column nullable, so nothing said so — which is the second production defect this table's hand-rolled copies were hiding, after the refresh-tokenscopeabove.
Changed¶
- Eleven fixture seeds now supply
scopeanddeviceinfo, because production rows have them and rows that did not were rows the application could never produce. - One stub lacked
token_lookupentirely — the columnUser\Token::storageFor()writes on every insert — so every write against it would have failed the moment the fixture was used for a real token.
16,075 tests in 3:21, unchanged.
Still hand-rolled, on purpose¶
A migration's own test has to build the pre-migration shape to prove the migration changes it, and a characterization test pins what an older installation looked like. Both are about the schema rather than about a feature, and both are named in the Testing Guide so the next reader does not "fix" them.
A test failed with "No such file or directory" because of what another test restored¶
UsersControllerTest died in setUpBeforeClass() with
which is mysqli falling through to a unix socket. It depended on what ran before it: green alone, red in company. Two separate causes, and both are the same shape — a test handing the next one a connection that looks alive and is not.
Fixed¶
-
Five classes parked the singleton as
clone $dbRefand restored the clone. A clone is a differentDatabase, with its connection parameters frozen at clone time andconnectedstill true. Everything that ran afterwards and asked for the singleton got that. They now park the object itself — nothing was being protected by the copy, since the original is parked rather than mutated. -
UsersControllerTesttook its connection fromFactory::getDatabase()after loading its own settings. The factory returns whatever instance exists, built from whichever settings were loaded first in the run — soclearSettings()andloadSettings()reached nothing and the class got a stale object. Three other classes had each worked this out and written the same two lines; this one had not.
Added¶
Pramnos\Framework\Testing\Connection::fresh()— drops the singleton and returns an instance built from the settings just loaded. The three hand-written copies now call it, so there is one implementation and a fourth class has something to find.
16,075 tests in 3:16, and UsersControllerTest contributes its full 59 again rather
than aborting.
A SPA's coverage number was mostly the framework's debug panel¶
frontend/lib/debug.js sat inside the scaffolded Vitest coverage.include. It is
4,039 lines, generated from the framework's own toolbar source rather than scaffolded
as a starting point, shipped unconditionally into every SPA, and the generated CLAUDE.md
tells the reader not to rewrite it. The framework tests it in its own JavaScript suite,
where it is owned.
So it decided the figure. A project reported 47.8% with it and 81.5% without, on the same tests. What follows from a number like that is not a better test suite — it is somebody deleting the threshold.
Fixed¶
spa-vitest.config.jsexcludes it, beside the test files it already excluded. It is the only file under the source directory with this property: everything else there is rendered from a stub as a starting point the project owns and edits, the shared components included. Those stay measured, which is the point — aDataTablea project has extended is the project's code.
Documentation¶
- The generated
docs/FRONTEND_TESTING.mdsays what the number counts and why the one exclusion is there, so the next reader does not put it back.
Reported as a proposal rather than a bug, and correct on every point including the scope — which is worth saying, because the usual failure of a report from one project is that the same defect is sitting in three files it never looked at.
Every anonymous request emitted a PostgreSQL syntax error, 430,000 a day¶
SET takes a value, DEFAULT, or nothing — SET x = NULL is not PostgreSQL grammar. The
framework emitted it for every request by an unauthenticated user, and nobody had ever
seen one: the call is @-suppressed and its false return is not read, so the caller's
try/catch cannot fire. The only record was the server's own log.
Measured on the installation that reported it: 1,619,446 lines — 48% of a 376 MB
PostgreSQL log, about 430,000 occurrences a day over three and a half days. Turning on
log_min_duration_statement to investigate anything meant reading a log that was half
this.
Two bugs, stacked¶
The fallback was correct and unreachable. setTrackingInfo() builds
'app.userid' => $userId ?? 'guest' — and then merges the caller's data over the
defaults. Api::execute() passes $userdata['userid'] = $currentUser?->userid ?? null, an
explicit null for every anonymous request, which went straight back over the guest that
had just been computed for it. The kind of thing that reads fine in a diff and is wrong in
composition.
And the null branch was invalid SQL. So the variable the whole mechanism exists for was
never set on exactly the traffic somebody would be reading the log to chase:
current_setting('app.userid', true) answered null for every login attempt, every public
endpoint, and every request whose token had just expired.
Fixed¶
- Nulls are dropped before the merge, so the fallback finally runs: an anonymous
request records
guest. A null for any other key is skipped rather than written — a variable that was never set reads as null, which is a different answer from the empty string and the honest one for "there was no tenant". set_config()throughpg_query_params(), which removes the invalid branch and takes the variable's name as a value. It was interpolated into the statement unescaped while being built from array keys the caller supplies — application-controlled rather than user-controlled, so a hazard rather than a hole, and free to close while the line was being edited.application_namegot the same treatment: it ends with a slice ofREMOTE_ADDR.
One correction to the report, which proposed SET x TO DEFAULT on the grounds that
current_setting() would then read null. It does not: once a custom variable has been
referenced in a session its default is the empty string, so TO DEFAULT and
set_config(name, NULL, false) both give ''. Only never setting it at all reads as null
— which is why the answer here is a meaningful guest rather than either.
Added¶
tests/Integration/Database/TrackingInfoPostgreSQLTest.php— five tests that read the variables back from the server withcurrent_setting(). Asserting on the SQL string would have passed against the broken version; the server is the only thing that knows whether the statement was grammar. Confirmed to fail against the old code.
Documentation¶
- The Database API Guide gains "Who ran this query: the PostgreSQL session variables" —
what is set, how to read it out of
pg_stat_activity, and that anonymous is a value rather than an absence.
Four defects in what the scaffolder generates, one of which blocked all coverage¶
Fixed¶
-
The account profile view did not parse.
src/Views/account/profile.html.phpclosed twoforeach (…):blocks withendif;, in all three UI systems, so every scaffolded project with the account feature had a page that could not render. It was not found by opening the page: no coverage report could be produced for the project at all, becausephp-code-coverageparses every file in the source filter and dies on the first one it cannot. A broken view takes the measurement of everything else with it. -
create:crudre-derived a primary key it already knew. The model asks the table throughlivePrimaryKey(); the generated test askedgetSingularPrimaryKey(), which trims a trailings— so forpropertiesthe model correctly gotproperty_idwhile its own test assertedproperty_exists($model, 'propertieid'). Two halves of one generation, three statements apart, disagreeing about the one name they both had to know. The test now takes the key the model was just given; the wizard path keeps the convention, because there the table does not exist yet. -
The generated model test could not construct its model.
Model::__construct()requires aController, and the template wrotenew Thing()—ArgumentCountErrorbefore the first assertion. It now usesServiceController::shared(), which is what the Application Styles guide already documents for a model outside an MVC request, and what a worker or an MCP tool uses. -
BaseTestCase's database assertions had never run.assertDatabaseHas()calledprepare()on$this->pdo, which is declared and never assigned:getConnection()built a PDO and returned it without caching, so its ownif ($this->pdo !== null)was unreachable. Assigning it by hand did not help either —getConnection()readsstatic::$dbConfig, also declared and never populated, and failed one layer down onbuildDsn(): Argument #2 ($host) must be of type string, null given. Both are offered to every application in the Testing Guide.
getConnection() now assigns what it builds and falls back to the settings the suite has
already loaded; $dbConfig stays a property, so a test that wants another connection can
still set one.
Added¶
tests/Unit/Console/GeneratedViewsParseTest.php—php -lover every PHP file the scaffold writes, in all three UI systems. This is the one class of defect a template test catches outright and nothing was looking for it. It names the file, the line and the message, and it fails against the old template.tests/Integration/Framework/BaseTestCaseDatabaseAssertionsTest.php— the documented assertions against a real table, in both directions, plus that the connection really is cached on the property the assertions read. A test that calls a documented assertion should not be the thing that finds out it has never worked.
Not taken¶
The report's better suggestion for the model — give it a constructor defaulting to
ServiceController::shared(), since a model is wanted from workers as often as from
controllers — changes the signature of every generated model rather than one template. It is
a fair argument and a larger decision than this fix.
The oauthconnections table sheds an index it never needed, and gains 2038¶
Two review notes on the migration added earlier today, both right, and the table exists on no deployed installation — so it is corrected in place rather than by a follow-up.
Fixed¶
- The standalone index on
useridis gone. The unique constraint is a btree on(userid, provider, account_id), and a btree serves any leading prefix of its columns, soWHERE userid = ?was always going to use it. Reproduced on PostgreSQL after dropping the redundant one:
EXPLAIN SELECT * FROM oauthconnections WHERE userid = 42;
Index Scan using oauthconnections_userid_provider_account_id_unique
Index Cond: (userid = 42)
Every predicate ConnectionStore issues was audited against the remaining indexes:
(userid, provider, account_id) exactly, userid [+ provider] as a prefix,
(status, expires_at) by its own index, id by the primary key. The second index was
maintained on every write and read by nothing.
- The unix timestamps are
bigInteger.integerisint4, which stops at 19 January 2038 —SET expires_at = 2147483648is alreadyERROR: integer out of range. An expiry column is where that bites first, because it holds a moment in the future rather than one in the past. The rest of the framework usesintegerfor unix timestamps and changing that is a larger conversation; on a table being created it costs nothing to be right.
Unchanged, and why¶
The table stays in the public schema. authserver.* is the schema for the
authorization server's own tables, and it exists so one database can be shared between
an auth server and a resource server — everything in it is visible to both. These
connections are the client half: tokens this application holds on behalf of its own
users, and they belong with usertokens and usersettings rather than in a namespace
designed to be shared.
A scaffolded project's tests ran against the development database¶
Two halves that never met, and neither was wrong on its own.
TestEnvironment::setup() read app/config/testsettings.php, built <db>_test from
template1 — and stopped. It never told anything to use it; it contains no reference to
Settings at all, and reads the file directly only for the credentials it needs to create
the database.
BaseTestCase::setUp() then called Application::init() with no argument, so
Settings::getInstance() fell back to app/config/settings.php: the development
connection. Every test in every scaffolded project read and wrote the developer's working
data, while the database built for them sat empty and used by nothing.
What made it dangerous rather than untidy is the advice around it. The Testing Guide
recommends factories, seeders and Model::truncate() in tearDown() — all written for a
disposable database. A suite that follows that advice against development data deletes it,
and the first symptom is an empty screen rather than a red test.
The framework's own suite could not have caught it. Its bootstrap defines APP_PATH as
tests/fixtures/app and loads that fixture's settings directly, so it has never taken the
scaffolded path — the two halves had never been run together by the repository that ships
them.
Fixed¶
TestEnvironment::setup()loads the settings it used.Settings::getInstance()keeps the first instance, so theinit()that follows finds them already in place. The fix lives insrc/, which means every project gets it oncomposer updatewithout regenerating itstests/bootstrap.php.- The database is created when absent, not dropped and rebuilt every run. Dropping was free while nothing used it. Now that the suite does, keeping it is what makes "only the new migrations" possible: the ledger lives in the database, so the first run migrates everything and each run after it migrates what was added since.
- The schema is brought up to date once per run, guarded on the application existing and reporting rather than throwing — a schema that could not be migrated should fail the tests that need those tables, with an error naming one, instead of replacing a hundred specific failures with one message about migrations.
Also¶
BaseTestCase::getConnection() claimed in its own error message that
TestEnvironment::setup() loads the settings. It did not, and the message now says so
explicitly — it was written earlier the same day, from the same wrong assumption that made
this defect survive.
Added¶
tests/Integration/Framework/ScaffoldedSuiteUsesTheTestDatabaseTest.php— renders the settings filesinitwrites into a temporary project, runsTestEnvironment::setup()against them, and asserts the settings afterwards name the test database. Plus that a second run keeps the database rather than dropping it. Confirmed to fail against the old code.
What this is not¶
It is not a consuming application's own bootstrap, which solved the same problem by hand and goes further: a
10 MB schema dump imported once into a template database and copied per run (0.5 s against
7 s), a fingerprint so the template rebuilds when the dump changes, a cross-process lock,
and synchronous_commit = off on the test database alone (500 commits: 1187 ms → 41 ms).
Those are worth having and they are that project's shape, not every project's. What has
been absorbed here is the part with no alternative: pointing the suite at the right database
and keeping its schema current.
…and the fix for it was a no-op, twice over¶
Both reported within the hour by the project that had reported the original, and both are consequences of the fix rather than of the defect.
Fixed¶
- The schema step required
APP_PATH, which no scaffolded bootstrap defines. The guard was writtenif (!defined('APP_PATH') …) return;, copied fromBaseTestCase::setUp()where an application may already have booted. At bootstrap time none has: a scaffoldedtests/bootstrap.phpdefinesROOT,DS,sURLandURL, and nothing else. So the step returned immediately and said nothing, and the suite pointed at a test database that stayed empty — the same outcome as before by a different route. Measured in the reporting project, same commit either way: 0 tables without it, 26 with.
It is derived now, ROOT . /app, which is what Application::readApplicationConfig()
already falls back to for the same reason.
It survived review because this repository's own bootstrap defines APP_PATH — the
same blind spot that hid the original defect, one layer further in.
- The migration fingerprint cache was not keyed by database. The fingerprint says
"these migration files have been verified"; it never said against what. One checkout used
to mean one database in practice, so the omission cost nothing. It now means two, and a
verification performed against development suppressed the check for test: the database
stayed empty, every test needing a table failed with "the table does not exist", and
nothing anywhere mentioned migrations. The cure was deleting
var/migrations/*.verifiedby hand, which nobody reaches from the failure.
The connection — type, host, port, database — is now part of the APCu key and the marker file's name. The copy stored inside the database is untouched: it is per-database by construction, since it lives in the one it describes.
The first attempt folded the connection into the md5(ROOT) prefix, which
forgetVerifiedMigrations() deletes by — so forgetting silently stopped working.
AutoMigrationFingerprintTest caught it, and the connection went one level deeper
instead, leaving "forget everything for this checkout" meaning what it says.
Added¶
- Three tests that the cache distinguishes two databases in one checkout, that the host is part of it and not only the name, and that the same connection still hits the fast path — a key that varied for any other reason would make every run re-verify.
- A test that the schema step resolves its path without
APP_PATH, asserting on the derivation rather than on a migration run, because the suite that would run it is the one that cannot see the problem.
A scaffolded project's admin screens all answered 404, and the dev panel could end a test run reporting success¶
Fixed¶
initwrote sixteen admin controllers and nothing that mounted them.Application::enterAdminAreaIfRequested()returns immediately whenapplicationInfo['admin']is absent, and the generatedapp.phphad no such block. Two things followed: the prefix stayed'', soadminUrl('Users')rendered/Users; andsrc/Admin/was never put in controller scope, so<Ns>\Admin\Controllers\Userswas not a class anybody could route to.
So every administration screen in a fresh project answered 404 — while the menu
listed them all, because the navigation is built from the registry rather than from
routes. The area looked complete and nothing in it opened. init writes the block now,
with min_usertype 80: the lowest any bundled admin controller requires, since a gate
above the lowest would lock out the screens that only need it.
DevPanelController::terminate()was a bareexit, called from fifteen places. A test requesting any devpanel URL stopped the PHPUnit process: no summary, no failure count, exit status 0 — which every CI reads as a pass. Found by a sweep over a project's screens that stopped mid-list, visible only because it was appending to a file as it went.
It honours PRAMNOS_TESTING now and throws ApplicationClosedException, the same type
Application::close() already throws for the same reason — whose own comment says
"without this a single database fault silently truncates the whole suite". That seam
existed; this path simply never reached close().
Documentation¶
- The Routing Guide's administration-area section says
initwrites the block, and spells out what removing it costs: not just the routes, but the controllers leaving scope, with the menu still listing what no longer opens.
Every generated SPA screen fetched a doubly-prefixed URL, and a test asserted it¶
lib/api.js owns the API prefix. It reads apiPrefix from window.__PRAMNOS__ — so a
deployment can move the API — and adds it in fetch(apiPrefix + url). The CRUD screen
template carried it as well, so every screen create:crud generated for a SPA fetched
/api/1.0/api/1.0/<entity> and answered Could not load: HTTP 404 on its first load,
which is the first thing anybody clicks after generating one.
Two tests looked straight at it and saw nothing. The screen's own generated test
asserts the path handed to a mocked client, so the mock is exactly where the prefix
stops. And MakeCrudSpaTest asserted const RESOURCE = '/api/1.0/widget' — it pinned the
defect as the expected value.
The buildless stack's screen stub had it right all along, so the two stacks disagreed, which is the evidence for which one was wrong.
Fixed¶
- The CRUD screen stub, and the blank screen stub, which had the same bug and was not in the report.
Field.svelte's doc comment, which showed a prefixedendpointfor a foreign-key picker — and line 116 hands that value straight toapi.get(), so following the example produced the same 404.lib/i18n.jsis deliberately unchanged: it callsfetch()directly, so nothing else would add the prefix. A rule of "no prefix anywhere" would have broken it while fixing the screens, which is why the new guard is about who hands a path to the client rather than about the string.
…and a two-factor user could not sign in to a scaffolded SPA at all¶
ApiAccount has always registered the action — addaction(['login', 'login2fa',
'logout']) — and the shipped client has always called it: loginTwoFactor() posts to
/account/login2fa. Only the route was missing. The password was accepted, the API
answered two_factor_required, the client asked for the code as designed, and posting it
hit a 404.
Three places have to agree on that name — the controller, the client and the generator — and the generator was the one that did not.
Fixed¶
- The route is generated beside
/account/loginand/account/logout, and documented in the generated OpenAPI, which covered the other two and not this one. An endpoint that is routed and undocumented is the next person's afternoon.
Added¶
tests/Unit/Console/GeneratedSpaPathsTest.php— sweeps every stub for a path handed to the client with the prefix already on it, asserts that a rawfetch()still carries one, and pins the three-way agreement between controller, client and generator for the two-factor route.
A SPA could not offer the one sign-in that needs no password¶
Every piece of passkey sign-in has shipped for a while: the ceremony endpoints
(Pramnos\Auth\Controllers\Passkey), the WebAuthn browser client (pf-webauthn.js), the
credential store, conditional mediation in the username autofill. All of it reachable from a
server-rendered page, and none of it from a SPA — nothing routed the ceremony under the API,
and /passkey/login established a session, which is not a thing a bearer-token client can
use.
So a scaffolded SPA had the strongest authentication method the framework offers, fully
implemented, and no way to reach it. The obvious fix from inside a project is to write a
small WebAuthn client for the SPA. It is never small. It is base64url in both directions,
user.id and excludeCredentials on one ceremony and allowCredentials on the other, and
the precise field names webauthn-lib deserialises — and when one of them is wrong the
server says authentication_failed and names nothing.
Added¶
Pramnos\Auth\Controllers\ApiPasskey— the webPasskeycontroller, subclassed rather than reimplemented. Two things differ and they are the whole class: a verified assertion is answered with a bearer token (the same envelope/account/loginreturns, so a client stores it with the code it already has) instead of a session, and request fields are read from a JSON body as well as a form. The ceremony is inherited, unchanged.- The seven
/passkey/*routes, generated beside/account/loginfor any project with theauthfeature, and documented in the generated OpenAPI — both halves of each ceremony, because half a ceremony fails on the second call, after the authenticator has already asked somebody for a fingerprint. lib/webauthn.jsin every scaffolded SPA — the framework's WebAuthn source with ES exports appended (Pramnos\Auth\Passkey\PasskeyAsset::spaModule()), exactly aslib/debug.jsis generated from the single toolbar source. Framework-owned; refreshed byproject:resync --debug-panel, which now writes both.loginWithPasskey(),loginWithPasskeyAutofill(),cancelPasskeyAutofill(),registerPasskey(),listPasskeys(),renamePasskey(),revokePasskey()andpasskeySupported()in the generatedlib/api.js.loginWithPasskey()stores the issued token likelogin()does, so everything after it is an ordinary authenticated call.PramnosWebAuthn.setTransport()— the one seam that made the above possible without a second implementation. The ceremony's POSTs go through an injected function; the SPA's carries the API key and the bearer token throughlib/api.js, the server-rendered pages keep the same-origin default. Nothing about the existing behaviour changes.Pramnos\Auth\IssuesAccessTokens— the minting, lifted out ofApiAccountinto a trait both controllers use. How somebody proved who they are differs; what follows never does: this application's key and lifetime, a row inusertokens, the identity sealed onto the request, one envelope. A trait rather than a service because every one of these is a seam an application may already have overridden on its controller subclass — and because they must stayprotected: a public method on a controller is a routable action, and one taking anintfirst parameter is a route that exists and fatals rather than one that 404s.ControllerActionSignatureTestcaught exactly that, which is the second time this week a guard has been worth more than the change it stopped.Passkey::loginResponse(int $userId)— the protected seam the subclass overrides. The web controller's behaviour is unchanged: establish the session, answer{status, user_id}.
Why the ceremony is not copied¶
lib/webauthn.js is generated from the same bytes the server-rendered login page is served.
That is the same decision the debug panel made, for the reason its own comment records: two
renderers drifted, and the same bug then had to be fixed twice. A WebAuthn ceremony is a
worse candidate still — its failures are opaque, so a drifted copy does not look broken, it
looks like a passkey that stopped working.
The cookie still travels, and it is worth being explicit about why: a ceremony is two calls,
and the challenge issued by the first is held server-side so only the session that started
one can finish it. That correlation is what credentials: 'same-origin' carries.
Authentication is still the token; /passkey/login seals the request's identity and
establishes no login session.
Tests¶
tests/js/spa-passkey.test.js— drives the generated module, through an injected transport, against a stubbed authenticator: thatchallenge: 'AQID'reachescredentials.get()as the three bytes it encodes, that the authenticator's ArrayBuffers go back as base64url, thatuser.idandexcludeCredentialsare converted on the enrolment ceremony, that the server's error code survives to the caller, and that nothing ever falls back to a barefetch.tests/Unit/Auth/Controllers/ApiPasskeyControllerTest.php— the token envelope, that no session is established however the assertion turns out, that a failed assertion mints nothing, and the JSON-body reading (including a JSON number forid, and a non-scalar value falling through rather than becoming the wordArray).tests/Unit/Auth/Passkey/PasskeyAssetTest.php— that the module carries the real ceremony, exports every namelib/api.jsimports, forwards rather than reimplements, and imports under Node without a browser.GeneratedSpaPathsTestgains the three-way agreement check — controller, client, generator — for all seven paths.
Documentation¶
docs/Pramnos_Authentication_Guide.mdgains Passkeys from a SPA: what ships, the four lines a sign-in screen needs, why the cookie is not the credential, and how a project scaffolded before this gets the module.
A user with two-factor authentication could not sign into a scaffolded SPA¶
The route was added earlier today and the client function has always existed. The screen
was the third place, and it was the one that did not know: login() throws
TwoFactorRequired rather than returning, and neither scaffolded sign-in screen caught
it. So an account with 2FA was told "Could not sign in" — its password having been
correct — with no code field and no way forward from that screen at all.
Three places have to agree for a sign-in to work: the controller, the client and the screen. Two of them did.
Fixed¶
- Both sign-in screens — Svelte and build-less — complete the second leg. The password form is replaced by the code form rather than extended: the pending login lives server-side, so the password is neither needed nor wanted from there on, and a form still showing it invites somebody to retype it into a request that ignores it. A wrong code leaves the login pending, so "try again" means the code, not the password.
- The code field carries
autocomplete="one-time-code"(what lets a phone offer the code from the message it just received) andinputmode="numeric"(the numeric keypad, without rejecting an alphanumeric backup code), plus a way back to a different account.
Added¶
GeneratedSpaPathsTest::testBothSignInScreensCompleteEveryLoginTheClientSupports()— for each stack, that the screen catchesTwoFactorRequired, posts the code, asks for it with the right autocomplete token, and offers the passkey button conditionally. Asserted per stack, because a project gets one of them and the other one's user never finds out.GeneratedSpaPathsTest::testEveryGeneratedSvelteTemplateClosesItsBlocks()— every{#if},{#each},{#await},{#key}and{#snippet}in a generated.sveltetemplate is closed, counted per kind so an{#if}closed with{/each}does not balance out. A Svelte template is compiled, not parsed leniently, so one unclosed block is not a broken screen — it is an application that does not build, and the first person to find out is whoever received the scaffold.
Documentation¶
docs/Pramnos_Authentication_Guide.mdgains What the scaffolded SPA sign-in screen does: the three paths it ships, and why the second one is a dead end when it is missing rather than a feature that is.
The session exchange signed with a key the API does not verify with¶
sURL is dirname(SCRIPT_NAME). That is one line of getUrl() and it means the constant
is not one string: the API front controller (www/api/index.php) sees
https://host/api/ and every other request sees https://host/. The API's signing key is
md5(sURL . $version), so a token minted anywhere except inside an API request was signed
with a key the API does not verify with — refused as 403 InvalidAccessToken, which names
neither the key nor the mistake.
It is visible in the tokens: the iss claim of an exchanged token is https://host/ and of
an API login token https://host/api/.
How it presents is worse than the bug. A SPA asks the exchange for a token, presents it, is refused, concludes the token is dead and asks for another — and the exchange mints another identical one. A redirect loop at browser speed against your own server, from the page somebody opens when they are already having a bad day.
deriveAuthenticationKey() was extracted precisely so the exchange could reach the same
value, and its own comment says a token signed with one key and verified with another "fails
as an authentication error, arbitrarily far from the mistake". The lookup was fixed; the input
was not.
Fixed¶
Api::baseUrl()is the input now: the API's own base in every context. Inside an API request it returnssURLuntouched — deliberately not normalised, because the key ismd5()of that string and one tidied-up character would sign out every client holding a token. Outside one it appends the API's directory, which is whatSessionExchangeandmcp:tokenneeded and never had.Api::version()extracted from the key derivation, because a URL inside the version prefix needs the same answer and two resolutions that drift are two answers to "which API is this".- A project that serves its API from somewhere other than
www/api/says so with'api' => ['directory' => '…']. Deliberately not guessed fromapi.prefix: the prefix is a route namespace and the directory is a place on disk, and guessing one from the other would swap a visible failure for a silent one.
Added¶
tests/Unit/Pramnos/Application/ApiAuthenticationKeyTest.php— both halves. That the key inside an API request is exactly what it always was, and that an MVC request derives what an API request whosesURLis the site plusapi/derives. The pair that did not match, written as the invariant rather than as two values.
One dead token turned the whole application into "the backend is down"¶
The API refuses a request carrying a bad token before it looks at the route, which is
correct. The consequence is not: one expired, revoked or minted-before-a-redeploy token makes
/status — which needs no authentication at all — fail alongside everything else, for the
whole of the first load. The screen says the backend is down, and it is not.
The client already cleared the refused token. That fixes the next request; the one the screen is waiting for was still lost.
Fixed¶
- The generated
lib/api.jsclears the token and answers the call, retrying once anonymously. Safe for any method, including a POST: that refusal happens before dispatch, so nothing ran and there is nothing to run twice.anonymousis both the point and the recursion guard — the API only answersInvalidAccessTokento a request that presented a token. DebugBarAsset::spaModule()guards the toolbar's boot. It ranwindow.__pramnosDebugBar = …anddocument.readyStateat import, andlib/api.jsimports it — so every test a scaffolded project ships for its API client died atReferenceError: window is not defined, in a file nobody had touched, for a panel none of those tests use. Anifaround the IIFE, with the source inside it unchanged: the same bytes are inlined into server-rendered pages, where the guard is dead weight and the point of generating the file is that there is one source to fix.
Added¶
tests/js/spa-api-client.test.js— the framework runs the client it ships, for the first time. It renders the stub the wayinitdoes, writes the two framework-owned modules beside it, and drives the real thing against a stubbedfetch: theapiKeyheader, the retry, that the retry does not turn a protected endpoint into a public one, that an anonymous call is not retried, the two-factor throw, 204, and dropped query parameters. Every test that touched this file before was in a generated project against a mocked client — which is exactly where a contract bug hides, because the mock agrees with whatever the client does.
/robots.txt and /llms.txt answered 404, and then pointed at three more¶
www/.htaccess has always rewritten both to index.php?r=MachineReadable/…, and no
MachineReadable controller was ever generated for the project to route to. Every other
framework controller the scaffold uses gets a thin wrapper in src/Controllers/; this one
did not, so both addresses were 404 in every scaffolded project.
The framework class's own doc-block says a missing robots.txt "does not mean 'no policy' — it means the policy is whatever the visiting crawler decides". That is precisely the state it shipped in.
Fixed¶
- The wrapper is scaffolded, like
Health, in every application. - The rewrites moved out of the
authserverblock. What a crawler may read is not a question about issuing tokens; every site answers those two addresses. - The three addresses the files advertise are derived rather than written down, because a
machine-readable file exists to be read by something that will not look around for the
right URL — a wrong address is worse than an absent one:
Sitemap:is emitted only when a file is actually served at/sitemap.xml, or whensitemap_urlnames one. The framework ships no sitemap generator, and aSitemap:line pointing at a 404 teaches a crawler that the site is broken rather than that it has no sitemap.[Documentation]isApi::baseUrl()+docs/—<site>/api/docs/, whereinitwrites them. It said<site>/docs, which is nothing.MCP endpoint:is inside the API's prefix —<site>/api/1.0/mcp, where the scaffolded route puts it. It was announced at the site root, and that is the one link telling a model the site has tools at all.
Added¶
InitCommandUnitTest::testRobotsAndLlmsAreRewrittenToAControllerThatExists()— asserted as the pair, because that is the shape of the defect: two halves written by the same generator, one of them missing, and nothing comparing them.MachineReadableTestgains the sitemap's both branches, a configuredsitemap_url, and the two derived addresses.
Documentation¶
docs/Pramnos_Authentication_Guide.md— The key it signs with is the API's, not its own front controller's, under the session-exchange section.docs/Pramnos_AuthServer_Integration_Guide.md— Every project gets both, and the addresses in them are real, with the derivation of each of the three.- Three rows in
docs/Pramnos_Upgrade_Guide.md.