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:setupnever 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 perTestClientrequest, andresync --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
usersmigration 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 onauth_trusted_devices, because it changes what a password can do.auth_trusted_devices_exclude_adminskeeps 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.jsputstrust_deviceinto whichever form is submitted; a page that sends nothing — no JavaScript, or an application's ownlogin/login_2facopied 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, callsTrustedDevices::revokeAll(). Pramnos\Auth\TrustedDevices,authserver.trusted_devices(the cookie's token stored only as its lookup hash), and a Trusted devices card on/account/securityto forget one or all.LoginFlow::chooseTrust(),canTrustDevice(),trustDays(),trustChoice(); the flow's new seams aretrustedDevices()andpushApprovals(). A login let through by trust is recorded with the methodtrusted_device.
"Is it you trying to sign in?" — approving a sign-in from a trusted phone¶
Added¶
- The
pushsecond factor (Factors\PushApprovalSecondFactor, strength 70, offered first). Whether it counts towardsrequire_factor_enrolment_from_usertypeisauth_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 newtrusted_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 throughaccount/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 viewlogin/approvein 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 livesKNOWN_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) oralways. - 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_deniedand mailsSecurityChangeNotifier::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,TTL120,highurgency), andPushChannel::urgency()as a seam.Subscriptions::linkTrustedDevice().- The scaffolded service worker sends a payload's
data.ackreceipt, opensurlin its own window whendata.openis set, and resolvespush/*against its scope so a site in a folder works. Existing workers are not rewritten: copy the stub'sWeb pushblock.
Registration, trusted devices and the phone prompt in System Settings¶
Added¶
- Who may register —
auth_allow_registrationandauth_registration_domains— are fields in System Settings → Security, unlessapp.phpsays'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 makespramnos.pushsubscriptions— apramnos_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-generateon 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
mdup it is the vertical sidebar again; every entry, including the organisation-admin link, is unchanged. The current entry is marked witharia-current="page". - A description and its action stack below
smon 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-splitfor 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'sinsert('../partials/account_breadcrumb')was resolved against the application'ssrc/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.
EU cookie consent — banner, preferences dialog and Consent Mode v2¶
Added¶
- A cookie banner in every theme and every app style.
scaffolding/assets/js/pf-consent.jsis 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 thepf_consentcookie 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 apramnos:consentevent 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 defaulttodataLayer, thenconsent updatewith the visitor's choice, plus apramnos_consentevent that Tag Manager triggers can fire on. \Pramnos\Security\CookieConsent.tag(sURL)emits the script element with the translated configuration.allows()andgranted()read the choice from PHP.record()appends a signed-in visitor's choice toauthserver.user_consentsascookie:<category>@v<version>, with refusals included./cookieconsent.GETreturns the configuration for the SPA shell, which cannot read settings.POST /cookieconsent/recordwrites the consent trail and is CSRF-checked.- System Settings → Security → Cookie consent: on/off, categories, policy link and policy version, in all three themes.
initasks "Show an EU cookie consent banner? [Y/n]" (--cookie-consent=y|n). The default is yes. Answering no writes'cookie_consent' => falseintoapp/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).
cookieconsentis 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_tableandauthserver_organization_columnsaved 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 tableauthserver.(no name) in the resolver's query and a column""inCREATE TABLE authserver.roles. Every reader now goes throughRole::membershipTable()andRole::organizationColumn(), or the same rule inline in the migrations.- Tests:
SessionBearerValidationTestbuildsapplicationsandusertokensif absent, asTesting\Schemaintends — the migration tests drop every table they know as they finish, and a--filterrun ending there left the next run failing in its cleanup with "the database refused the query".MassMessageDispatcherTestno 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, ornullfor nobody.getCurrentUser()answersfalse, which the nullsafe operator does not short-circuit, sogetCurrentUser()?->useridthrewAttempt to read property "userid" on falsefrom every cron job and CLI command.getCurrentUser()keepsfalse: eight callers in the framework alone compare it with=== falseand would readnullas a signed-in user.
Fixed¶
- The sign-in screens have a way back to the site. The standalone layout
initgenerates (<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. TestClientresets the request identity before each request. A secondget()in one test inherited the caller the first request's middleware had sealed. A second/spasession exchange was refused as "authenticated by session-exchange, not by a session".- The footer
initgenerates carries the "Cookie settings" link. The footer is built byInit::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 forpf-consent.js. - The Theme Guide describes the back link. The Authentication Guide covers
User::current(), and the Testing Guide notes that everyTestClientrequest 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.
The cookie banner in production: a centred dialog, the Consent Mode default before anything, and versioned asset URLs¶
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 generatedhead.php,footer.phpandlogin.php, the human check, the message editor andCookieConsent::tag(). The SPA shell stamps the banner with its own$stamp.
Fixed¶
- A fix to
pf-consent.jsreached no browser. Cloudflare kept the unversioned file for the four hoursmax-age=14400allowed. The same was true of everypf-*.js,push.jsandstyle.cssthe scaffold loads. - The Cookie settings dialog opened in the top-left corner under Tailwind. A modal
<dialog>is centred by the browser's ownmargin: auto, Tailwind's preflight (* { margin: 0 }) removes it, and the banner's stylesheet set none. It setsmargin:autoitself now. - In a SPA, the Consent Mode default arrived after Tag Manager could run. It was pushed
only once
/cookieconsentanswered, 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. Apf_consentcookie 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 loadsgtm.jsthroughassetUrl().
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$dependenciesforauthserver,pramnosandapplications, as the migration runner does. Before, any test that ensured anauthserver.*table failed after a migration test had dropped the schema.RequestIdentityIsolationclears$unittesting_loggedbefore every test. That override madeSession::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.
PushLogTestbuilds the policy store,ApikeyCharacterizationTestbuildsusers,ApiLoginIssuesAWorkingTokenTestbuildsuserdetails, andSecurityAlertsReachPushTestopens its own connection. - Seeds match the production schema. Eleven
usertokensinserts in six files omittedcreated,deviceinfoorscope, which the migration makesNOT NULLwith no default. Sixusersinserts omittedusertype,sex,birthdateandmodified:User::setupDb()gives those a default, but the migration does not. - Tests that leaked state now clean up or set it up themselves.
NavRegistryTestleft a user signed in to the session.ApiLoginSecurityTestasserted "no session" without starting from one. TwoUsersControllerTestcases inserted users with fixed ids that a sibling test had already taken. spa-passkey.test.jsruns on Node 21 and later.globalThis.navigatorbecame 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 thatRequestIdentityIsolationclears$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()buildsusers,userdetailsandusertokensthroughTesting\Schema::table(), from their migrations. It predates the migration system and carried its ownCREATE TABLEstatements, one set per driver, and they had drifted from the migrations. Itsusertype,sex,birthdate,modifiedandusertokens.createdhad 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@deprecatedin favour ofSchema::table().usergroupsanduserstogroupskeep 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-columnapplicationsstub had leftApplicationServiceunable to insert anapikey.- Forty-nine
usersinserts in fourteen test files give the four columns explicitly, as registration does.SchemaMigrationsCharacterizationTestremoves the newfk_userdetails_useridbefore dropping itsusersstub. The scaffold-file provider inHelpersAssetUrlTestretries 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,birthdateandmodifieddefault to 0 in2020_01_01_000010_create_users_table. The reference production schema declares themNOT NULLwith no default, so any insert that omitted one was refused on both drivers, while 0 is what every such writer means.UsersTableDefaultsTestbuilds the table in a throwaway database on MySQL and PostgreSQL and reads the zeros back.- Only a new installation gets the defaults, because
CREATE TABLEdoes not run on a table that already exists. For an existing one, the Upgrade Guide has the check and theALTER TABLE … SET DEFAULT 0statement. 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
userscolumns without a default.