Skip to content

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 applications table
  • …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.txt and /llms.txt answered 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_reason and dead_at on 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_KEY is required. Other callers of Security\Encrypter may 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:

Field 'scope' doesn't have a default value

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: applications is created by one migration, gains systemuser from a second and a wider callback from 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 extend BaseTestCase, and a protected method they cannot reach is exactly how the second hand-rolled CREATE TABLE gets 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() omitted deviceinfo. usertokens.deviceinfo is TEXT NOT NULL and MySQL cannot give a TEXT column a default, so under strict mode the insert was refused and the framework's own /oauth/authorize could 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-token scope above.

Changed

  • Eleven fixture seeds now supply scope and deviceinfo, because production rows have them and rows that did not were rows the application could never produce.
  • One stub lacked token_lookup entirely — the column User\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

RuntimeException: No such file or directory

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 $dbRef and restored the clone. A clone is a different Database, with its connection parameters frozen at clone time and connected still 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.

  • UsersControllerTest took its connection from Factory::getDatabase() after loading its own settings. The factory returns whatever instance exists, built from whichever settings were loaded first in the run — so clearSettings() and loadSettings() 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.js excludes 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 — a DataTable a project has extended is the project's code.

Documentation

  • The generated docs/FRONTEND_TESTING.md says 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

ERROR:  syntax error at or near "NULL" at character 18
STATEMENT:  SET app.userid = NULL

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() through pg_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_name got the same treatment: it ends with a slice of REMOTE_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 with current_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.php closed two foreach (…): blocks with endif;, 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, because php-code-coverage parses 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:crud re-derived a primary key it already knew. The model asks the table through livePrimaryKey(); the generated test asked getSingularPrimaryKey(), which trims a trailing s — so for properties the model correctly got property_id while its own test asserted property_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 a Controller, and the template wrote new Thing() — ArgumentCountError before the first assertion. It now uses ServiceController::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() called prepare() on $this->pdo, which is declared and never assigned: getConnection() built a PDO and returned it without caching, so its own if ($this->pdo !== null) was unreachable. Assigning it by hand did not help either — getConnection() reads static::$dbConfig, also declared and never populated, and failed one layer down on buildDsn(): 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 -l over 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 userid is gone. The unique constraint is a btree on (userid, provider, account_id), and a btree serves any leading prefix of its columns, so WHERE 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. integer is int4, which stops at 19 January 2038 — SET expires_at = 2147483648 is already ERROR: 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 uses integer for 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 the init() that follows finds them already in place. The fix lives in src/, which means every project gets it on composer update without regenerating its tests/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 files init writes into a temporary project, runs TestEnvironment::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 written if (!defined('APP_PATH') …) return;, copied from BaseTestCase::setUp() where an application may already have booted. At bootstrap time none has: a scaffolded tests/bootstrap.php defines ROOT, DS, sURL and URL, 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/*.verified by 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

  • init wrote sixteen admin controllers and nothing that mounted them. Application::enterAdminAreaIfRequested() returns immediately when applicationInfo['admin'] is absent, and the generated app.php had no such block. Two things followed: the prefix stayed '', so adminUrl('Users') rendered /Users; and src/Admin/ was never put in controller scope, so <Ns>\Admin\Controllers\Users was 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 bare exit, 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 init writes 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 prefixed endpoint for a foreign-key picker — and line 116 hands that value straight to api.get(), so following the example produced the same 404.
  • lib/i18n.js is deliberately unchanged: it calls fetch() 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/login and /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 raw fetch() 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 web Passkey controller, 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/login returns, 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/login for any project with the auth feature, 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.js in every scaffolded SPA — the framework's WebAuthn source with ES exports appended (Pramnos\Auth\Passkey\PasskeyAsset::spaModule()), exactly as lib/debug.js is generated from the single toolbar source. Framework-owned; refreshed by project:resync --debug-panel, which now writes both.
  • loginWithPasskey(), loginWithPasskeyAutofill(), cancelPasskeyAutofill(), registerPasskey(), listPasskeys(), renamePasskey(), revokePasskey() and passkeySupported() in the generated lib/api.js. loginWithPasskey() stores the issued token like login() 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 through lib/api.js, the server-rendered pages keep the same-origin default. Nothing about the existing behaviour changes.
  • Pramnos\Auth\IssuesAccessTokens — the minting, lifted out of ApiAccount into a trait both controllers use. How somebody proved who they are differs; what follows never does: this application's key and lifetime, a row in usertokens, 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 stay protected: a public method on a controller is a routable action, and one taking an int first parameter is a route that exists and fatals rather than one that 404s. ControllerActionSignatureTest caught 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: that challenge: 'AQID' reaches credentials.get() as the three bytes it encodes, that the authenticator's ArrayBuffers go back as base64url, that user.id and excludeCredentials are converted on the enrolment ceremony, that the server's error code survives to the caller, and that nothing ever falls back to a bare fetch.
  • 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 for id, and a non-scalar value falling through rather than becoming the word Array).
  • tests/Unit/Auth/Passkey/PasskeyAssetTest.php — that the module carries the real ceremony, exports every name lib/api.js imports, forwards rather than reimplements, and imports under Node without a browser.
  • GeneratedSpaPathsTest gains the three-way agreement check — controller, client, generator — for all seven paths.

Documentation

  • docs/Pramnos_Authentication_Guide.md gains 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) and inputmode="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 catches TwoFactorRequired, 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 .svelte template 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.md gains 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 returns sURL untouched — deliberately not normalised, because the key is md5() 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 what SessionExchange and mcp:token needed 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 from api.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 whose sURL is the site plus api/ 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.js clears 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. anonymous is both the point and the recursion guard — the API only answers InvalidAccessToken to a request that presented a token.
  • DebugBarAsset::spaModule() guards the toolbar's boot. It ran window.__pramnosDebugBar = … and document.readyState at import, and lib/api.js imports it — so every test a scaffolded project ships for its API client died at ReferenceError: window is not defined, in a file nobody had touched, for a panel none of those tests use. An if around 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 way init does, writes the two framework-owned modules beside it, and drives the real thing against a stubbed fetch: the apiKey header, 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 authserver block. 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 when sitemap_url names one. The framework ships no sitemap generator, and a Sitemap: line pointing at a 404 teaches a crawler that the site is broken rather than that it has no sitemap.
    • [Documentation] is Api::baseUrl() + docs/ — <site>/api/docs/, where init writes 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.
  • MachineReadableTest gains the sitemap's both branches, a configured sitemap_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.