3 October 2026¶
7 changes:
- Permission checks can be made in one organisation
- Every push notification has its own page in the administration
- A test notification says whether the device received it
- The scaffolded Omnibox test no longer prints a warning on every run
- An API test no longer makes the next test's requests go to the API
- Mailing-list and unsubscribe pages render in the theme, and a confirmation can be counted
StatTile: the figure-with-a-label every dashboard draws
Permission checks can be made in one organisation¶
A user can hold a different role in each organisation: a Client in one, an Editor in
another. The permission checks the framework makes never knew which organisation a request
was about. ApiCrudController and Permissions::isAllowed() (and through it Gate's
fallback) all called PermissionResolver::resolve(), which merges every role the user holds
in every organisation. The Client could edit wherever the Editor could.
For the generated CRUD endpoints, scoping alone would not have closed it. Their rule is that
"no grant at all" is allowed, so that a project with no grants keeps working. For a Client
role that grants only read, update was never refused.
Added¶
Pramnos\Auth\OrganizationScope::resolveWith(callable): the application says once how to find the request's organisation. With it set, these checks read the user's system-wide roles plus that organisation's (for a member) throughresolveForOrganization():ApiCrudController::authorize(),permissionFor()andpermissionForObject();Permissions::isAllowed(); andGate::fallbackToPermissions(). A resolver that returnsnullchanges nothing.- In an organisation, the generated CRUD endpoints refuse an action no role grants.
"No rule" and "the store can't be read" are refusals there, and
permissionForObject()returnsfalseinstead ofnull. Outside an organisation, nothing changes. ApiCrudController::permissionOrganizationId(): a controller can take its organisation from somewhere else, such as its URL.Permissions::isAllowedInOrganization($user, $orgId, …)for a check about a named organisation. Answers are cached per organisation.OrganizationPermissionResolverInterface, implemented byPermissionResolver. An application's own resolver needs it to answer in an organisation; without it, checks in one are refused and the error log names the class.- Every failure refuses (a throwing resolver, an answer that is not an id, an unreadable store) instead of falling back to the unscoped answer. The error log records each refusal with the resource and action.
Before you turn it on¶
Once an organisation is in scope, an endpoint action that no role grants is refused for everyone, the organisation's owner included. Give each role every action it needs before setting the resolver. The guide has the checklist and the diagnosis steps.
Documentation¶
- Authorization Guide — Checking permissions in an organisation, a new section with the worked example, what changes where, set-up, the checklist and every failure mode.
- The same guide's Multi-tenancy, And a warning about the default, API endpoints, Roles and Bridging the two sections, the Authentication Guide and the Legacy permissions migration point to it.
Every push notification has its own page in the administration¶
/admin/PushLog listed every attempt, but a row could not be opened. The question it gets
asked most, why did this person not see it, needs more than a row. It needs the device the
push went to and what else that device has received. An installation whose owner missed an
alert had a 201 in the log, so the push service had accepted it. Telling a phone setting
apart from a dead subscription then took reading the database.
Added¶
/admin/PushLog/view/{id}shows the outcome with what it means and what to check next, the full message, the device (browser, subscribed, last delivered, failures), and the latest pushes to that device. Every list row links to it. All three scaffold themes.Log::find(),Log::outcome()(kind, label and explanation, shared by the list and the detail page), and anendpoint_hashfilter forLog::recent().Subscriptions::describe($userId, $hash)returns the browser, dates and failure count, never the endpoint or keys.
Fixed¶
- The plain-CSS theme's outcome badges (
pf-ok,pf-warn,pf-bad) had no styles.
See Push Guide — One push in full.
A test notification says whether the device received it¶
A push log 201 means the push service accepted a message, not that a phone showed it. "I got
nothing" could be either, and nothing could tell which without reading the database. An
installation whose owner missed an alert found the 201, and still could not say whether the
problem was the delivery or the phone.
Added¶
Pramnos\Push\TestPush:send($userId, ?$endpointHash)sends a fixed test notification throughPushChannel, one copy per browser, each with its own receipt address, logged in the push log.acknowledge($token)andstatus($token)read the device's receipt.pramnos.pushtests, created by anotificationsmigration, holds one receipt per test per device, accepted for an hour.POST /push/test,POST /push/testack?token=andGET /push/teststatus?token=give an application's own device screen a Send a test notification button.- The administration's push page (
/admin/PushLog/view/{id}) has Send a test to this device. A test's page says whether the device received it, is still waiting, or never confirmed. All three themes. Log::recent()filters bytag, andTestPush::tokenOf()reads a test's token from its push log row.
The scaffolded service worker already posts data.ack, so no project needs to change its
sw.js.
See Push Guide — Testing a device.
The scaffolded Omnibox test no longer prints a warning on every run¶
The SPA's Omnibox test for a failed request rejected the search with an Error, which the
component logs with console.warn. The warning was printed in the middle of every green
front-end run, the kind of noise everybody learns to scroll past.
Fixed¶
spa-omnibox.test.js.stubspies onconsole.warnaround the failure, asserts it was called with'Omnibox request failed'and restores it in afinally. The warning is now tested instead of only tolerated. A project scaffolded before this keeps its copy; the change is the four lines aroundcalls[1].reject(...)infrontend/__tests__/Omnibox.test.js.
An API test no longer makes the next test's requests go to the API¶
A suite expected /Users to be a 404 on the site and got a 200 with an empty body, but only in
a full run. A test before it had built the API. The API registered itself as default, the
site's slot, and became the current application. Every later bare new TestClient(), and
BaseTestCase's own getInstance(), then got the API.
Fixed¶
- An
Apibuilt with no name registers asapi. The site keepsdefault. The configuration is still read fromapp.php. BaseTestCase::setUp()makes the site the current application before every test, through the newApplication::resetCurrentInstance(). A test that builds the API and then uses a bareTestClientstill drives the API, and the next test starts from the site.- A
TestClientmakes its application current for each request and puts the previous one back, so code the request runs that asks for "the application" gets the one handling it.
Added¶
Application::instanceNamed($name)(without building one),makeCurrentInstance(),restoreCurrentInstance()andresetCurrentInstance().
A behaviour change; see the Application registry row in the Upgrade Guide. Guide: Testing Guide — The site and the API in one suite.
Mailing-list and unsubscribe pages render in the theme, and a confirmation can be counted¶
The pages for subscribe, confirm and unsubscribe were bare documents, with no theme, footer, cookie banner or application scripts. The confirmed double opt-in, the moment newsletter analytics is about, happened on a page Tag Manager could not be on, and the visitor had no footer to go back through.
Changed¶
- The pages render in the theme's standalone layout,
<theme>/login.php, when the theme has one (every themeinitwrites does). They carry the application's<head>and footer. A theme without it keeps the self-contained page. This covers/mailinglist/subscribe,/mailinglist/confirmand/unsubscribe, which shareStandalonePage.
Added¶
data-stateanddata-liston#pramnos-standalone:pending,awaiting-confirmation,confirmedornothing-to-confirm. The JSON answer to the form carriesstateandlisttoo.mailinglist.confirmed(MailingList::EVENT_CONFIRMED), fired with the list and the address byMailingList::confirm(), once per address.
See Email Guide — Where these pages render, and counting a confirmation.
StatTile: the figure-with-a-label every dashboard draws¶
Every application that added an administration dashboard hand-wrote the same card for a figure with a label. One application had eleven of them on one screen and the same markup on another, and the copies drifted: spacing, digits that jump as a number changes, dark mode.
Added¶
Pramnos\Html\StatTile:render($label, $value, $note, $link, $state),progress($label, $current, $target, $note, $link)with a<progress>bar that turnsgoodat the target, andgrid($tiles). States areGOOD,WARNINGandCRITICAL.pf-stat-tile/pf-stat-gridhooks, styled in all three scaffold themes from their own colours, with tabular digits. They can be renamed throughcomponent_classes(stat_tile,stat_grid).
A project scaffolded before this copies the Stat tiles block of its theme's style.css
from the framework. See
HTML Components Guide — StatTile.