Skip to content

30 September 2026

12 changes:

  • "Don't ask again on this device" — trusted devices
  • "Is it you trying to sign in?" — approving a sign-in from a trusted phone
  • Registration, trusted devices and the phone prompt in System Settings
  • push:setup never found the subscriptions table
  • The account screens on a phone
  • A scaffold view's partials were lost beside an application's own view directory
  • EU cookie consent — banner, preferences dialog and Consent Mode v2
  • Four findings from glideday: a way back from the sign-in screens, User::current(), a fresh caller per TestClient request, and resync --js --all
  • The cookie banner in production: a centred dialog, the Consent Mode default before anything, and versioned asset URLs
  • The suite passes in any order and under any --filter
  • User::setupDb() builds the user tables from their migrations
  • The users migration gives four columns a default of 0
  • A blank organisation setting named a table authserver. and a column ""

"Don't ask again on this device" — trusted devices

Added

  • A box on the two-step page, ticked by default, as Google ticks it. Finishing a second factor with it ticked trusts the browser for auth_trusted_device_days (30); a password sign-in from it then skips the second step. Off until an administrator turns on auth_trusted_devices, because it changes what a password can do. auth_trusted_devices_exclude_admins keeps administrators on the second step — including browsers they trusted before, which also stop receiving and answering phone prompts.
  • Only a box the page sent counts. pf-auth.js puts trust_device into whichever form is submitted; a page that sends nothing — no JavaScript, or an application's own login/login_2fa copied before the box — trusts nothing. An application with its own copy adds the box to offer it.
  • A new password forgets every trusted device: Account::updatePassword(), where the change and the reset both arrive, calls TrustedDevices::revokeAll().
  • Pramnos\Auth\TrustedDevices, authserver.trusted_devices (the cookie's token stored only as its lookup hash), and a Trusted devices card on /account/security to forget one or all.
  • LoginFlow::chooseTrust(), canTrustDevice(), trustDays(), trustChoice(); the flow's new seams are trustedDevices() and pushApprovals(). A login let through by trust is recorded with the method trusted_device.

"Is it you trying to sign in?" — approving a sign-in from a trusted phone

Added

  • The push second factor (Factors\PushApprovalSecondFactor, strength 70, offered first). Whether it counts towards require_factor_enrolment_from_usertype is auth_push_counts_for_enrolment: strong (default) — only when a phone that would be asked was trusted with an authenticator, passkey or adaptor ≥ 40, recorded in the new trusted_devices.trusted_via (TrustedDevices::trustVia(), FactorEnrolment::methodIsStrong()); always; never. An account with a trusted device that receives notifications is asked on it as the step-up begins. The waiting page follows it through account/pushstatus: the phone's worker sends a receipt (push/ack) and the page says "delivered"; after 20 seconds without one it opens "Try another way". Approved, the page finishes the sign-in by itself.
  • Number matching unless the attempt is plainly the account's own: the waiting page shows a number, the phone picks it out of three on the page the notification opens (account/approve, new view login/approve in all three themes), and the wrong one is a refusal; the notification's only button is "No, it's not me". A browser the account trusted before (TrustedDevices::known() — the cookie now lives KNOWN_DAYS, 365, past the trust) signing in with nothing unusual gets a plain Yes/No instead, on the page and the notification (push/respond). What merely looks familiar — User-Agent, country — earns nothing. auth_push_number_matching: risk (default) or always.
  • Bounded and bound: only trusted devices are asked; only a browser signed in as the account and carrying its trust cookie can answer; the first answer stands, settled by the row; only the waiting browser's session can use the approval, once, within 60 seconds of the answer; 120 seconds to answer an ask, three asks in ten minutes. Pramnos\Auth\PushApprovals, authserver.push_approvals, pushsubscriptions.trusted_device_id.
  • "No, it's not me" records signin_denied and mails SecurityChangeNotifier::SIGNIN_DENIED, which says the password is known to somebody else rather than "if you made this change".
  • Never the only way: every step of it keeps "Try another way", and switching the prompt on lets the mailed code through the factor list, so an account whose only other factor would be nothing is offered one.
  • ApprovalPushChannel (only the subscriptions it is given, TTL 120, high urgency), and PushChannel::urgency() as a seam. Subscriptions::linkTrustedDevice().
  • The scaffolded service worker sends a payload's data.ack receipt, opens url in its own window when data.open is set, and resolves push/* against its scope so a site in a folder works. Existing workers are not rewritten: copy the stub's Web push block.

Registration, trusted devices and the phone prompt in System Settings

Added

  • Who may register — auth_allow_registration and auth_registration_domains — are fields in System Settings → Security, unless app.php says 'auth' => ['registration_admin_editable' => false], which shows them read-only (RegistrationPolicy::editableInAdmin(), RegistrationPolicy::ALLOW_SETTING).
  • The trusted-device and phone-prompt settings beside them — including when the phone must pick a number and whether the phone counts for required enrolment — with a note when push is not set up. Every field follows the screen's rule: a form that does not carry it leaves it as it was.

push:setup never found the subscriptions table

Fixed

  • It looked for pushsubscriptions, and the migration makes pramnos.pushsubscriptions — a pramnos_ prefix on MySQL, a schema on PostgreSQL. So on every installation the first step reported the table missing, ran the migration, found nothing to migrate, and stopped with "the table still is not there", before the key pair. Found on glideday's production, where the table had existed for two weeks; push:vapid-generate on its own was the way round it.

The account screens on a phone

Measured on the tailwind theme at 390px: the page fitted the screen and read badly. The same rules now ship in all three scaffold themes, each in its own idiom — see The account screens at phone width.

Changed

  • The account menu is one row that scrolls sideways on a phone instead of a tall card that pushed every account page's content a screen down. From md up it is the vertical sidebar again; every entry, including the organisation-admin link, is unchanged. The current entry is marked with aria-current="page".
  • A description and its action stack below sm on the security page (2FA, passkeys, password, trusted devices, sessions), the authorised-applications list and the passkey page, instead of both being squeezed into one row. plain-css gains .pf-row-split for it.
  • The dashboard heading wraps beside the 2FA badge, and the badge keeps its text on one line — "⚠ 2FA Inactive" used to spill out of the badge's fixed height.
  • Below 640px the search box shrinks and its results span the screen, and breadcrumb trails stay on one line that scrolls, with long crumbs cut by an ellipsis.

A project that copied one of these views into app/views keeps its old layout: diff it against the bundled one.

A scaffold view's partials were lost beside an application's own view directory

Fixed

  • The security page had no breadcrumb and no account menu on an application with a src/Views/OAuth2/ of its own. It held one override (delete_account), so every other OAuth2 view came from the scaffold, and the scaffold's insert('../partials/account_breadcrumb') was resolved against the application's src/Views/ — which has no partials. Both came out empty, with a line in the framework log and nothing on screen. View::resolveTemplatePath() now also looks beside the file being drawn, after every place it looked before, so an application's own partial still wins.
  • The Trusted devices card was hidden when there were none, so a page other screens send people to "see your trusted devices" had nothing called that on it. It is shown whenever the site offers trusted devices ($this->trustedDevicesOffered), and says how a device becomes one; "Forget all" appears only when there is something to forget.

Added

  • A cookie banner in every theme and every app style. scaffolding/assets/js/pf-consent.js is one script for Bootstrap, Tailwind and plain CSS, and for MVC, SPA and hybrid projects. It shows a banner with Accept all and Reject all at equal weight, a Customise dialog with a switch per category (preferences, analytics, marketing; strictly necessary is always on), and keeps the choice in the pf_consent cookie for 180 days. Changing the policy version asks everybody again.
  • Gated scripts. <script type="text/plain" data-consent="analytics" src="…"> runs only once its category is allowed. PramnosConsent.onGrant(), .has(), .open() and a pramnos:consent event are available for code that is already loaded. Withdrawing a category reloads the page, because a script that already ran cannot be stopped any other way.
  • Google Consent Mode v2. On load the script pushes an all-denied consent default to dataLayer, then consent update with the visitor's choice, plus a pramnos_consent event that Tag Manager triggers can fire on.
  • \Pramnos\Security\CookieConsent. tag(sURL) emits the script element with the translated configuration. allows() and granted() read the choice from PHP. record() appends a signed-in visitor's choice to authserver.user_consents as cookie:<category>@v<version>, with refusals included.
  • /cookieconsent. GET returns the configuration for the SPA shell, which cannot read settings. POST /cookieconsent/record writes the consent trail and is CSRF-checked.
  • System Settings → Security → Cookie consent: on/off, categories, policy link and policy version, in all three themes.
  • init asks "Show an EU cookie consent banner? [Y/n]" (--cookie-consent=y|n). The default is yes. Answering no writes 'cookie_consent' => false into app/app.php, which turns the feature off whatever the settings say, and leaves the script out of the SPA shell, so no request is made either. The settings screen then shows a note instead of the fields.
  • Theme footers, the generated login layout and the SPA shell load the tag and carry a "Cookie settings" link (a floating button where a page has none). cookieconsent is added to a SPA's front-controller routes.

The banner is decided in the browser, so the page cache keeps working: the tag is the same for every visitor, is an external file with no inline script, and the server never sets the cookie. An existing project adopts it with project:resync --js --all plus one line in its footer. See the guide for this and for the SPA shell.

Documentation

  • New Cookie Consent Guide: turning it on, settings, gating scripts, GA4, Tag Manager with Consent Mode, the Meta Pixel, CSP hosts, the PHP API, translation and styling.

A blank organisation setting named a table authserver. and a column ""

Fixed

  • authserver_organization_table and authserver_organization_column saved blank are the defaults everywhere. Three readers already treated "" that way; PermissionResolver, the roles migration, the RBAC functions migration and the deny-rule migration did not, so a blank setting — saved empty, or "restored" to "" by code that found it absent — became the table authserver. (no name) in the resolver's query and a column "" in CREATE TABLE authserver.roles. Every reader now goes through Role::membershipTable() and Role::organizationColumn(), or the same rule inline in the migrations.
  • Tests: SessionBearerValidationTest builds applications and usertokens if absent, as Testing\Schema intends — the migration tests drop every table they know as they finish, and a --filter run ending there left the next run failing in its cleanup with "the database refused the query". MassMessageDispatcherTest no longer writes its probe setting to the settings table, where the restore left "" behind for later runs to load.

Four findings from glideday: a way back from the sign-in screens, User::current(), a fresh caller per TestClient request, and resync --js --all

Added

  • User::current() returns the signed-in user, or null for nobody. getCurrentUser() answers false, which the nullsafe operator does not short-circuit, so getCurrentUser()?->userid threw Attempt to read property "userid" on false from every cron job and CLI command. getCurrentUser() keeps false: eight callers in the framework alone compare it with === false and would read null as a signed-in user.

Fixed

  • The sign-in screens have a way back to the site. The standalone layout init generates (<theme>/login.php) has no header, so /login, /register, forgot-password and the second factor had no link out but the browser's back button. It now carries "← " at the top left, positioned so the centred card does not move, in all three UI systems.
  • TestClient resets the request identity before each request. A second get() in one test inherited the caller the first request's middleware had sealed. A second /spa session exchange was refused as "authenticated by session-exchange, not by a session".
  • The footer init generates carries the "Cookie settings" link. The footer is built by Init::buildThemeFooter(), not copied from the theme directory, so a new project loaded the banner but fell back to the floating button to reopen it.

Documentation

  • The Cookie Consent guide says project:resync --js --all. Without --all, resync skips a file the project does not have yet, which is exactly the case for pf-consent.js.
  • The Theme Guide describes the back link. The Authentication Guide covers User::current(), and the Testing Guide notes that every TestClient request starts with no caller.

An existing project keeps its own login.php and footer.php. To take the back link or the cookie link, copy the line from a freshly generated theme.

Three findings from glideday's first deployment of the banner.

Added

  • assetUrl('assets/js/pf-auth.js') returns the file's URL with ?v=<mtime>. Every framework asset the scaffold loads now goes through it: 42 tags across the three themes' views, the generated head.php, footer.php and login.php, the human check, the message editor and CookieConsent::tag(). The SPA shell stamps the banner with its own $stamp.

Fixed

  • A fix to pf-consent.js reached no browser. Cloudflare kept the unversioned file for the four hours max-age=14400 allowed. The same was true of every pf-*.js, push.js and style.css the scaffold loads.
  • The Cookie settings dialog opened in the top-left corner under Tailwind. A modal <dialog> is centred by the browser's own margin: auto, Tailwind's preflight (* { margin: 0 }) removes it, and the banner's stylesheet set none. It sets margin:auto itself now.
  • In a SPA, the Consent Mode default arrived after Tag Manager could run. It was pushed only once /cookieconsent answered, so an ungated container fired its tags with no consent state, and none at all if the fetch failed. The default is all-denied whatever the settings say, so it is now pushed the moment the script runs. A pf_consent cookie that is not valid percent-encoding no longer throws before that; it counts as no choice.

Documentation

  • The Theme Guide has a section on assetUrl(). The Cookie Consent guide says the container goes after the consent script in the SPA shell too, and loads gtm.js through assetUrl().

An existing project's own copies of views, head.php and footer.php keep their plain URLs. Changing sURL . 'assets/…' to assetUrl('assets/…') is the whole edit.

The suite passes in any order and under any --filter

The full suite passed, but filtered subsets did not. --filter Login, Security, Account, Api and Push each failed somewhere, and some failed only right after another subset had run. The Docker databases keep their state between runs. The migration tests drop the tables and schemas they know about, and several tests built their own looser copy of a shared table. So whichever test ran next found a different database each time.

Fixed

  • Testing\Schema::ensure() creates a table's PostgreSQL schema first. It now honours a migration's $dependencies for authserver, pramnos and applications, as the migration runner does. Before, any test that ensured an authserver.* table failed after a migration test had dropped the schema.
  • RequestIdentityIsolation clears $unittesting_logged before every test. That override made Session::staticIsLogged() answer "signed in" whatever the session said, and two test classes set it without clearing it.
  • Tests that relied on another test's tables now build their own. PushLogTest builds the policy store, ApikeyCharacterizationTest builds users, ApiLoginIssuesAWorkingTokenTest builds userdetails, and SecurityAlertsReachPushTest opens its own connection.
  • Seeds match the production schema. Eleven usertokens inserts in six files omitted created, deviceinfo or scope, which the migration makes NOT NULL with no default. Six users inserts omitted usertype, sex, birthdate and modified: User::setupDb() gives those a default, but the migration does not.
  • Tests that leaked state now clean up or set it up themselves. NavRegistryTest left a user signed in to the session. ApiLoginSecurityTest asserted "no session" without starting from one. Two UsersControllerTest cases inserted users with fixed ids that a sibling test had already taken.
  • spa-passkey.test.js runs on Node 21 and later. globalThis.navigator became a getter-only accessor there, and assigning to it threw before any test ran.

Documentation

  • The Testing Guide says that Schema::ensure() creates the schema, that a test must ensure every table the code reads, and that RequestIdentityIsolation clears $unittesting_logged.

users has two definitions that disagree: User::setupDb() and the migration. Making them agree is a schema change for existing installations, so it is left for a separate decision.

User::setupDb() builds the user tables from their migrations

Fixed

  • User::setupDb() builds users, userdetails and usertokens through Testing\Schema::table(), from their migrations. It predates the migration system and carried its own CREATE TABLE statements, one set per driver, and they had drifted from the migrations. Its usertype, sex, birthdate, modified and usertokens.created had defaults the migrations did not give, so tests built on it passed seeds that a real installation refuses. Whether they passed also depended on which of the two shapes an earlier test had left. It is test support only: nothing in the framework or in the known consuming projects calls it. It is marked @deprecated in favour of Schema::table(). usergroups and userstogroups keep their DDL there, because no migration creates them yet. The Guest row goes through the query builder.
  • Schema::table() replaces a stub instead of keeping it. A table found without the column every real copy has is dropped and rebuilt. A migration test's two-column applications stub had left ApplicationService unable to insert an apikey.
  • Forty-nine users inserts in fourteen test files give the four columns explicitly, as registration does. SchemaMigrationsCharacterizationTest removes the new fk_userdetails_userid before dropping its users stub. The scaffold-file provider in HelpersAssetUrlTest retries once: Docker Desktop's file sharing on macOS can answer "No such file or directory" for an existing directory just after files change on the host.

Documentation

  • The Testing Guide covers stub replacement and what setupDb() now is.

The users migration gives four columns a default of 0

Changed

  • usertype, sex, birthdate and modified default to 0 in 2020_01_01_000010_create_users_table. The reference production schema declares them NOT NULL with no default, so any insert that omitted one was refused on both drivers, while 0 is what every such writer means. UsersTableDefaultsTest builds the table in a throwaway database on MySQL and PostgreSQL and reads the zeros back.
  • Only a new installation gets the defaults, because CREATE TABLE does not run on a table that already exists. For an existing one, the Upgrade Guide has the check and the ALTER TABLE … SET DEFAULT 0 statement. It is the same on both drivers, safe to run twice, and does not rewrite the table.

Documentation

  • Upgrade Guide: a row under Since v1.2 — unreleased and the section users columns without a default.