Skip to content

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) through resolveForOrganization(): ApiCrudController::authorize(), permissionFor() and permissionForObject(); Permissions::isAllowed(); and Gate::fallbackToPermissions(). A resolver that returns null changes 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() returns false instead of null. 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 by PermissionResolver. 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

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 an endpoint_hash filter for Log::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 through PushChannel, one copy per browser, each with its own receipt address, logged in the push log. acknowledge($token) and status($token) read the device's receipt.
  • pramnos.pushtests, created by a notifications migration, holds one receipt per test per device, accepted for an hour.
  • POST /push/test, POST /push/testack?token= and GET /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 by tag, and TestPush::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.stub spies on console.warn around the failure, asserts it was called with 'Omnibox request failed' and restores it in a finally. The warning is now tested instead of only tolerated. A project scaffolded before this keeps its copy; the change is the four lines around calls[1].reject(...) in frontend/__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 Api built with no name registers as api. The site keeps default. The configuration is still read from app.php.
  • BaseTestCase::setUp() makes the site the current application before every test, through the new Application::resetCurrentInstance(). A test that builds the API and then uses a bare TestClient still drives the API, and the next test starts from the site.
  • A TestClient makes 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() and resetCurrentInstance().

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 theme init writes does). They carry the application's <head> and footer. A theme without it keeps the self-contained page. This covers /mailinglist/subscribe, /mailinglist/confirm and /unsubscribe, which share StandalonePage.

Added

  • data-state and data-list on #pramnos-standalone: pending, awaiting-confirmation, confirmed or nothing-to-confirm. The JSON answer to the form carries state and list too.
  • mailinglist.confirmed (MailingList::EVENT_CONFIRMED), fired with the list and the address by MailingList::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 turns good at the target, and grid($tiles). States are GOOD, WARNING and CRITICAL.
  • pf-stat-tile / pf-stat-grid hooks, styled in all three scaffold themes from their own colours, with tabular digits. They can be renamed through component_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.