Skip to content

29 September 2026

54 changes:

  • Claude.ai and ChatGPT can connect to the MCP endpoint
  • The authorization endpoint's codes could not be redeemed, and tokens name the resource they are for
  • A client that cannot keep a secret needs a registered redirect URI
  • A token from /oauth/token never authenticated an API call
  • Invitations: the four gaps glideday found moving onto them
  • /oauth/userinfo refused the token /oauth/token had just issued
  • The deploy webhook answered twice when it answered first
  • A new continuous aggregate froze its open bucket
  • A rollup can be compressed
  • Http\Client can download straight to a file
  • Opt-in mailing lists: a newsletter, with or without an account
  • The mailing-list form and its confirmation page
  • Opt-in lists on the account screen and the preferences page
  • Sending to a mailing list from /admin/MassMessages
  • A deactivated role went on granting its permissions
  • Saving an organisation deactivated it
  • Admin actions could be triggered from another site — addWriteAction(), starting with Users and Privacy
  • Every other admin screen's write actions are declared too
  • A deny and an allow at the same priority decided differently on MySQL and PostgreSQL
  • permissions_changed for every change, and a Permissions form with every column
  • Every framework admin screen opens at usertype 98; roles open the rest
  • /api/internal/permissions answers for one organisation
  • /admin/MailingLists: who is on each opt-in list
  • The compose form follows the chosen mailing list
  • Who opened and clicked one mass message
  • An account's email choices on its administration page
  • A WYSIWYG editor for messages, chosen per application
  • A server without its signing key minted tokens anybody could forge
  • The message editor's headings and lists look like headings and lists; push is written plain
  • An application can be marked trusted without SQL
  • A user's page lists the roles they hold
  • A client's Allowed Scopes are enforced
  • OpenID Connect: an ID token for openid, and a discovery document that says what works
  • Token lifetimes are set per server and per client
  • permissions_changed reaches only the applications it concerns
  • A migration's new column is saved at once, on PostgreSQL too
  • An organisation's own administrator
  • An array setting is not sent to the settings table
  • Redirects the framework issues can be seen by a test
  • The suite runs clean: no failure, warning or deprecation, and it fails on the next one
  • Two administrators can be given different administration areas
  • Registration by invitation, within a domain list, and with a confirmed address
  • A subset of the suite runs as clean as the whole of it
  • A cached query answered for another database behind the same Redis
  • A mass message filtered by organization reached nobody
  • The suite passes on Linux, not only on a case-insensitive disk
  • RateLimitMiddleware can count an API key, a user, or a tier
  • An application can send webhooks about its own events
  • An API key is held to the scopes its application was granted
  • Something writes application_stats
  • Applications can be managed from an application's own screen
  • The MySQL test container keeps its tables in one tablespace
  • TestClient sends a PUT's data the way PHP receives it
  • The scaffolded API client says whose contract it speaks

Claude.ai and ChatGPT can connect to the MCP endpoint

The tools, the scopes, PKCE and the consent screen all existed, and no assistant outside the machine could reach any of them. glideday measured the chain against production and found it broken in five places, each of which stopped a remote connector before the next one mattered.

Fixed

  • The endpoint asked for an API key first. init scaffolds POST /mcp into the API routes, and ApiAuthMiddleware answered 403 APIKeyMissing before the controller ran. A connector cannot send an apiKey header, so it never reached the 401 that starts OAuth discovery. Listing the path in public_api_paths removed the key check and bearer validation, and the controller then found nobody for every call.

    Two changes. On a declared-public path, a bearer token that verifies now identifies the caller, and one that does not leaves the call anonymous rather than refused — the endpoint is open, so the anonymous answer is its decision. And Api puts /<version>/mcp on the public list itself, so nothing has to be declared.

  • WWW-Authenticate pointed under the API. It was built from sURL, which inside the API is https://host/api/, so it named /api/.well-known/oauth-protected-resource — a 403.

  • The resource metadata named the site, not the endpoint. "resource": "https://host", and the path-suffixed form RFC 9728 §3.1 defines was a 404. MCP clients check that resource is the URL they were given. The 401 now names https://host/.well-known/oauth-protected-resource/api/1.0/mcp, built from the site's origin, and Discovery answers that address with the endpoint as its resource. A suffix that is not a plain path answers 404 rather than being echoed into a public document.
  • Discovery omitted what a public client looks for. oauth-authorization-server lacked code_challenge_methods_supported, although S256 was implemented, and had no none among the auth methods. Both are there now.
  • registration_endpoint was the sign-up page. A JSON POST to it answered 200 with HTML.

Added

  • Dynamic client registration, RFC 7591 — POST /oauth/register, opt-in with 'oauth_dynamic_registration' => true in app.php. init writes that line for an authserver project, and the discovery documents publish the endpoint only when it is on.

    It admits public clients only: token_endpoint_auth_method must be none (the RFC's default, client_secret_basic, is refused), grants are the code grant and refresh, and callbacks are https or loopback http. A confidential client is refused because it could use client_credentials, which needs no person — open registration would have been open issuance of machine tokens. Twenty registrations an hour per address.

  • The scaffolded .htaccess routes /.well-known/oauth-protected-resource/<path> to Discovery with the suffix.

What breaks

Three rows in the Upgrade Guide. An open API endpoint called with a token now sees that token's user instead of the website session; an unrelated /mcp route in an application's API is now reachable without a key; and a project scaffolded before today needs the registration switch and one rewrite rule before a remote client can connect — both are spelled out there.

Tests

A public client — no secret, PKCE, is_confidential = 0 — completes the code exchange against the real League server, which is the claim none in the discovery document makes. Registration writes its row on MySQL and PostgreSQL/TimescaleDB, and both validateCredentials() and the League client repository read the row as public. Every refusal is a unit test.

Documentation

  • MCP Guide — the connection chain step by step, what an older project has to add, and the subdirectory case.
  • AuthServer Integration Guide — dynamic client registration: the request, what is admitted and why, the error shapes; the suffixed resource document; the complete rewrite block.
  • API Guide — what a bearer token does on a public_api_paths route.
  • Upgrade Guide — the three rows above.

The authorization endpoint's codes could not be redeemed, and tokens name the resource they are for

Fixed

  • No code from /oauth/authorize could be exchanged, ever. The controller wrote a 64-character hex code into usertokens itself; /oauth/token hands every code to League's AuthCodeGrant, which decrypts it, and refused each one with Cannot decrypt the authorization code — after the person had approved the consent screen. It had been so since the controller was added. Every test of the flow drove League's own authorize step and never the controller's, so both halves were tested and the seam between them was not.

    The controller still validates the request itself — client, redirect URI, scopes, PKCE format — and then has League issue the code, so what it decides about who gets one is unchanged. This is what the previous entry's MCP connection would have hit at its last step, and what any relying party of an authorization server built on the framework hits today.

Added

  • Resource indicators, RFC 8707. resource on a token request adds that URI to the access token's aud, beside the client id; a resource that is not on this site's origin is invalid_target. The MCP endpoint then refuses a token bound only to another resource, as the MCP authorization spec requires. A token with no URL in aud — every token requested without resource, and every token issued before today — is accepted, so no configured connection breaks.

Tests

The regression test issues a code through the controller and redeems it through League with a PKCE verifier and a resource, then reads the audience off the signed token. The audience check is covered for a token bound to the endpoint, to another resource, to another host and to none.

Documentation

  • AuthServer Integration Guide — binding a token to one resource.
  • MCP Guide — which tokens the endpoint takes.
  • Upgrade Guide — two rows: codes are now League's, and resource is read.

A client that cannot keep a secret needs a registered redirect URI

Fixed

  • /oauth/authorize accepted any redirect_uri for a public client with nothing registered. For a confidential client that is harmless — a code at the wrong address is useless without the secret — and registration stays optional for it. For a public client, or one with no secret stored, the registration is the only thing tying the code to the client: a link carrying the client's public id and somebody else's redirect_uri, opened by a signed-in user who had approved the app before, delivered a code with no consent screen, and its holder exchanged it with no secret. PKCE does not help, because the link's author chooses the challenge. RFC 6749 §3.1.2.2 makes registration a MUST for these clients, and the endpoint now refuses without one.

    "Cannot keep a secret" means is_confidential = 0, or an empty apisecret — which the token endpoint accepts with no secret at all, whatever the Client Type says. It is one function, Auth\Application::needsARegisteredCallback(), used by the endpoint and by the screens.

Added

  • A warning on the application's page and edit form, in all three bundled themes, for a client in that state, with a link to the guide. The Client Type and Redirect URI fields carry a hover explanation of the difference and the same link.

Documentation

  • AuthServer Integration Guide — a new section, When a redirect URI is required: confidential and public in practice, the two conditions, the attack the registration stops, what the refusal looks like and how to fix it. The Public clients section no longer says the admin screen issues a public client no secret; it issues every new client one.
  • Upgrade Guide — a row, with the query that finds the affected clients.

Redirects the framework issues can be seen by a test

An application's test of the authorization-code flow approved the consent screen and got back a 302 with no Location — the one header that carried the code. Oauth redirected with a bare header(), which PHP does not keep on the CLI, so no test of the framework or of an application could read where the endpoint sent anybody; the framework's own flow test drove League directly for that reason.

Fixed

  • Oauth (the code, and access_denied), Emailclick and FormRequest::failWith() go through Application::redirect(). The destination is recorded in getRedirect(), which TestClient answers as Location. Oauth sets it and calls redirect() with no argument, because redirect($url) writes the URL to the development redirects log and this URL carries the code.
  • failWith() stopped the test run. It sent the header and called exit, which ends the PHPUnit process with status 0. It now ends the request the way everything else does, and has a test.

Added

  • RedirectsAreRecordedTest fails on a header('Location: …') anywhere in src/ outside Application::redirect(). The one other exception, RequireFactorEnrolmentMiddleware, is listed with its reason: middleware stops a request by returning null.

Documentation

  • Routing Guide — A redirect is $this->redirect(), not header() and terminate().
  • Upgrade Guide — a row: tests that expected …Oauth::terminate() called catch ApplicationClosedException instead.

The suite runs clean: no failure, warning or deprecation, and it fails on the next one

A full run was green with two PHP warnings and two deprecations in it, and a filtered run could error on a class that passed on its own. Each had been there long enough to read as noise.

Fixed

  • $_SERVER leaked between tests. Fourteen test classes started with $_SERVER = [] and none restored it. OauthRefreshCommandTest then warned on Symfony Console's missing PHP_SELF, and the session-tracking tests on a SERVER_PORT with no SERVER_NAME.
  • Request::getURL(false) read SERVER_NAME bare when a port was set. The branch without a port already treated it as empty.
  • UserCoverageTest errored after certain classes. It loaded the fixture settings and then took whatever Database singleton existed; one built before any settings pointed at localhost, and mysqli answered "No such file or directory" looking for a socket. It rebuilds the connection once, in setUpBeforeClass(), as the other database tests do.
  • PasswordResetTokenTest::testATokenExpiringNowIsStillAccepted failed across a second boundary — storing at time() and consuming a moment later are two readings of the clock. It starts early in a second.

Added

  • ServerGlobalIsolation, a PHPUnit extension beside the other three: $_SERVER as it was after the bootstrap script, restored before every test. pramnos init registers it.
  • failOnWarning and failOnDeprecation in the framework's phpunit.xml, so the next one fails the run instead of joining the noise. ignoreIndirectDeprecations keeps out one dependency's deprecation in another — league/oauth2-server calling a league/uri method version 7 deprecated — which is not ours to fix.

Documentation

  • Testing Guide — Isolating process-wide state lists the fourth extension.

A token from /oauth/token never authenticated an API call

Fixed

  • The last step of the code flow. AccessTokenRepository stores an access token by its identifier — its jti — and User::loadByToken() looks a token up by its whole text, so a token from /oauth/token was verified, matched no row, and left the call anonymous. A remote MCP client got through registration, sign-in, consent and the token exchange, and was refused with 401 on its first call. glideday found it against production-equivalent code, with the cause and the fix; Oauth::resolveStoredTokenValue() already tried the jti second, which is why introspection and revocation worked while authentication did not.

    ApiAuthMiddleware and UnifiedAuthMiddleware now look a token up by its jti when the literal lookup finds nothing, reading the jti from the claims JWT::decode() has just verified. The fallback is not in loadByToken(): Gdpr::resolveActor() calls it with an unverified bearer, and there a forged JWT carrying a real jti would have been enough.

Tests

The test the earlier ones lacked: a token from the full code flow, sent to the real ApiAuthMiddleware and UnifiedAuthMiddleware, reaches the endpoint as the approving user. The earlier end-to-end tests asked League's resource server, which never reads usertokens — that is how this passed. It fails with the fix removed.

Documentation

  • AuthServer Integration Guide and API Guide — how a bearer from /oauth/token is found.
  • Upgrade Guide — one row.

Two administrators can be given different administration areas

Every administration screen was opened by usertype: 80 for most, 90 for Roles, Permissions, Applications and Tokens. Two administrators at 90 saw the same area, and there was no way to give one of them Users and Logs and the other Roles and Tokens. The menu could carry a permission, but silence there meant "shown", so a role could never be what opened a screen.

Added

  • admin_access: usertype (the default, unchanged) or permissions. Under permissions each screen opens for whoever holds its ability — named after its menu item, admin.users — granted directly or through a role; nothing granted means closed. admin_superuser_usertype, 98 by default, opens everything, so switching modes cannot lock the last administrator out.
  • Pramnos\Auth\AdminAccess — allows(), abilities(), grantsFor(), setGrants(), landingFor().
  • An "Administration screens" panel on a user's page and on a role's page, in all three themes, with what the user can open in the end beside their direct grants. Editable by the superuser or a holder of admin.permissions, and only for the screens the editor can open.
  • Controller::$adminAbility, checked in exec() before any action — inside the administration area, where there is one, so a public page built on a screen outside it keeps answering — and $adminPublicActions for the ones that answer without it. Permissions::clearCache(), and NavRegistry::all().

  • admin_access = mixed: the floor, with decisions on top — an allow opens a screen below its floor, a deny closes it above. With nothing decided it is usertype, so it can be switched on a running installation. The panel offers Default / Allow / Deny per screen; AdminAccess::setDecisions(), decisionsFor() and mode(). A deny wins in every mode.

Fixed

  • The log viewer checked nothing itself and relied on the area's floor alone. It is now behind admin.logs like every other screen.

Documentation

  • Authorization Guide — Administration screens: by usertype or by permission.

Registration by invitation, within a domain list, and with a confirmed address

Registration was on or off for everybody. An installation that wanted only its own staff had nothing between the two, and a self-registered account was created validated = 1 with nobody having checked the address — so the ID token told every relying party email_verified: true. The Authentication Guide showed a require_email_verification key that nothing read.

Added

  • Invitations — Pramnos\Auth\Invitations and authserver.invitations. A link opens registration for one address while it is closed; used once, atomically; 48 hours (auth_invitation_ttl_hours); the token stored only as Token::lookup(); one live invitation per address. Accepting joins the organisation, gives the role and fires invitation.accepted with the row. An Invitations screen (admin.invitations, 98 under usertype) in all three themes; pramnos init scaffolds its wrapper.
  • auth_registration_domains limits open registration to whole domains.
  • Address confirmation — Pramnos\Auth\EmailVerification, auth_registration_verify_email, always on under a domain list. Sign-in answers the new LoginFlowResult::EMAIL_UNVERIFIED (after the password, so it reveals nothing) and mails a fresh link at most every ten minutes; the API login answers 403 email_unverified. One row in the Upgrade Guide.
  • Mail templates auth.invitation and auth.verify_email; Pramnos\Auth\RegistrationPolicy.

Documentation

  • Authentication Guide — Registration: open, by domain, by invitation; the config example no longer shows a key nothing reads.
  • Account Guide — what an invited person and one asked to confirm see.

A subset of the suite runs as clean as the whole of it

The full run was green, and a --filter run over the same tests was not: classes errored with "the database refused the query", "No such file or directory" or a sequence collision, and which ones depended on what ran before them. Two of the causes were in the framework.

Fixed

  • add_unique_constraint_to_settings_table failed a second run on MySQL — "Duplicate key name 'uq_settings_name'" — because MySQL has no ADD INDEX IF NOT EXISTS. It returns when the index is already there, as a migration re-applied by Testing\Schema expects.
  • A changelog row written without the service provider stored the array raw. The JSON encoders for changes, details and context were registered in ChangelogServiceProvider::boot(), so ChangelogWriter used on its own — or after WriteSpool::reset(), or on the synchronous path when the spool is unavailable — handed an array to the insert. ChangelogWriter::registerEncoders() now runs before every append, and Model::logEvent() calls it too.

Tests

  • Fixtures that left shared tables in another shape, or gone: SessionTest built a four-column users and kept it; UsersControllerTest dropped users and usertokens on the way out; tests that only needed a setting in memory wrote it to settings. Each suite that needs users or settings now ensures its production shape (Testing\Schema::table('settings') is new), and the PostgreSQL registration test brings the applications sequence level with the table.
  • A unit test that relied on there being no database now makes one that cannot connect, and a source scan reads src/Pramnos rather than the fixtures other suites generate into src/.

Invitations: the four gaps glideday found moving onto them

glideday moved its beta invitations onto Pramnos\Auth\Invitations and filed what it had to work around.

Added

  • InvitationsController::inviteOptions() — what an application adds to an invitation made on the screen, metadata usually, read from its own fields in its own copy of the view. It is merged under the screen's options, so it cannot change who invited, the organisation, the role or the note.
  • Invitations::notification() — the seam the mail is built through, so an application can return it withPreheader(…) or replace it. InvitationNotification gained withPreheader() and mailPreheader(), which the mail channel already reads.
  • {days} for the auth.invitation template, beside {hours}: a 21-day link read "504 hours". The built-in text says days whenever the lifetime is a whole number of them.

Fixed

  • Mail categories added after the first seeding never got an editor row. seed_system_mail_templates ran once, on 26 September, before auth.invitation and auth.verify_email existed, so on those installations Administration → Mail templates did not list them. The seeding is now SystemMailTemplates::seedMissingRows(), a dated migration calls it for the two, and a test pins the list of built-in categories so the next one cannot be added without its migration.
  • Erasing an account left its invitations. The unaccepted ones it sent held other people's addresses attributed to a user who no longer existed, and the one it came from held its own. Account::eraseUserData() now calls Invitations::forgetUser().

Documentation

  • Authentication Guide — attaching data from the screen, the mail's variables and preheader, and what an erase does to invitations.
  • Notifications Guide — the two categories in the table, and why a new category needs a migration.
  • Upgrade Guide — one row, for the erase.

/oauth/userinfo refused the token /oauth/token had just issued

Fixed

  • The same jti gap, in the endpoint the earlier fix missed. userinfo() looked the token up by its whole text and answered "Token expired or invalid" for every token from the code flow, so neither OIDC way of reading the signed-in user's identity worked. msdauthserver found it running the full flow; it is a sibling caller of the lookup fixed earlier today in the two middlewares, and it should have been fixed with them.

    This endpoint verifies nothing else about the token, so the jti is taken from League's resource server — which checks the signature, the expiry and the revocation — rather than read off the token. A token with a real jti and a broken signature still gets 401.

Tests

A code-flow token with openid gets 200 and the user's sub; the same token with its signature altered gets 401. The first assertion fails with the fix removed.

The deploy webhook answered twice when it answered first

Fixed

  • With respondFirst() on — the default — flushResponse() sends 202 and releases the caller, and the run still ended in respond(), which set a status and two headers on a response already sent. Three warnings per deploy in production's PHP log; glideday counted
  • The deploy worked, and the warnings were noise in the log somebody reads when it does not. respond() now writes nothing once the answer has been flushed, and the exit it ends with is a seam of its own (finish()), so a test runs the real respond() after a flush.

Documentation

  • Deploy Webhook Guide — nothing more is written once the answer is out.

A new continuous aggregate froze its open bucket

Fixed

  • createContinuousAggregate() materialised the open bucket. It ran CREATE MATERIALIZED VIEW … WITH (timescaledb.continuous …) with no data clause, and PostgreSQL's default is WITH DATA: every bucket was materialised, the open one included, which moved a real-time aggregate's watermark to that bucket's end. Rows arriving later in it were invisible until it closed, and the refresh policy could not help because it refreshes only closed buckets. glideday lost a day of site statistics that way — 37 views with a browser showed as none.

    It is now created WITH NO DATA and refreshed up to now(). TimescaleDB materialises only buckets wholly inside the window, so the closed ones are filled in and the open one stays live, with no need to know the bucket width. Over an integer time column the first refresh is refused; the aggregate is created empty and the migrations log says so.

Tests

On TimescaleDB: rows in a closed and in the open hour, the aggregate created, one more row arriving in the open hour — the open hour counts two. With the old statement it counts one, which is the reported symptom reproduced. And an integer-time aggregate is created although its first refresh is refused.

Documentation

  • Schema Builder Guide — what is materialised when an aggregate is created, and why.
  • Upgrade Guide — one row.

A rollup can be compressed

Added

  • compress_after in ContinuousAggregateRegistry. HypertableRegistry could compress a hypertable, but a rollup meant to outlive its raw rows was left uncompressed for ever; glideday ran the two statements by hand in its own migration. apply() now enables compression on the aggregate and adds a compression policy, each confirmed from the catalogue, both guarded. A compress_after that does not reach further back than start_offset is refused, because a refresh would have to rewrite compressed chunks. Off TimescaleDB it is ignored.
  • SchemaBuilder: enableContinuousAggregateCompression(), isContinuousAggregateCompressionEnabled(), hasContinuousAggregateCompressionPolicy() and isLongerInterval(). The policy lookup shares its join with hasContinuousAggregatePolicy(), which matches the job on either pairing TimescaleDB has reported across versions.

Changed

  • ContinuousAggregateRegistry::apply() no longer returns early when the refresh policy already exists, so a declared compression is applied to a rollup that was already refreshing. For a declaration without compress_after the result is what it was.

Documentation

  • Hypertable Guide — compressing a rollup.

Http\Client can download straight to a file

Added

  • sink(string $path) writes a 2xx body to a file as it arrives instead of holding it in memory, and decodeGzip() inflates a .gz file on the way in. glideday fetches a ~60 MB gzip database monthly (~130 MB inflated), which would have sat in memory twice; it used PHP's own copy('compress.zlib://…') instead. An error's body is kept as body() and the file is not touched, so a 404 page never stands in for the download; maxResponseBytes() applies to what is written; a retry rewrites rather than appends; a faked response goes to the file too.

Tests

Against a forked socket server: a megabyte in many chunks, a gzip file inflated, a 404 kept in memory with no file created, the ceiling, an empty success, a file that cannot be written and a body that is not gzip; and the same through fakes.

Documentation

  • HTTP Client Guide — downloading to a file.

Opt-in mailing lists: a newsletter, with or without an account

glideday needs a newsletter — a form on the landing page, a choice on each account, a checkbox when a WordPress site is paired — and the owner put the list in the framework. This is its first part: the store, the rules, and the mail type that obeys them.

Added

  • new MailType(…, list: 'newsletter', optIn: true). A send of an opt-in type goes only to a confirmed subscriber; to anybody else it is refused and recorded like a send to an opted-out address. MailTypes::allows() agrees, and MailTypes::byList() finds a type by its list.
  • Pramnos\Email\MailingList over a new pramnos.mailing_list_subscribers table: double opt-in with a signed, expiring confirmation link that leads to a button, at most one more mail an hour for a pending address, the consent sentence, source, language and address kept per row, and subscribe() answering its caller without letting a form reveal who is subscribed.
  • Leaving by any route ends the subscription: Unsubscribe::optOut() marks the row. Confirming clears an opt-out of that list only, never of all.
  • The rows are exported under Mailing lists and erased with the account, by account and by address. Unsubscribe::recordListConsent() writes the consent trail for a caller that changes a subscription another way.

Fixed

  • emailoptouts was not on the personal-data denial list, though it is nothing but addresses. It is now, beside the new table.

Documentation

  • Email Guide — opt-in mailing lists.
  • Upgrade Guide — one row.

The mailing-list form and its confirmation page

Added

  • POST /mailinglist/subscribe — a framework controller, answering in every application with no wrapper. It stores the registered type's description as the consent, never text from the form; refuses a GET, a stale form token, an undeclared list and a malformed address before writing anything; answers every valid request the same way; is limited to ten requests an hour per address; and speaks JSON to a script.
  • /mailinglist/confirm — the confirmation link opens a page with a button, and the button confirms, because mail scanners open every link in a message.
  • init sends mailinglist to PHP in a single-page application.

Changed

  • Unsubscribe's self-contained page and plain-text answer moved into a StandalonePage trait that both controllers use. page() and respond() keep their signatures, so an override still applies.

Documentation

  • Email Guide — the form and the confirmation page.

Opt-in lists on the account screen and the preferences page

Added

  • Account → Privacy lists every opt-in type as a checkbox, off until joined. A verified address is subscribed at once, an unverified one is sent the confirmation, and a pending list says so; unticking unsubscribes. In all three themes.
  • /unsubscribe's preferences page treats an opt-in type as on only for a confirmed subscriber, and turning it back on there subscribes directly — the link is signed for the address.
  • MailingList::statusOf().

Documentation

  • Email Guide and Account Guide — the two screens.

Sending to a mailing list from /admin/MassMessages

The last part of the mailing lists: an operator can send to a list.

Added

  • An audience of a list's confirmed subscribers (mailing_list in the criteria), with the same preview. One recipient per account; a pending address is nobody's recipient.
  • Recipients by address: massmessagerecipients gains email and language, and MassMessageDispatcher::queueAddresses() writes them, userid 0 when there is no account.
  • deliverToAddress() sends each in its own language as the list's MailType, so the unsubscribe header and link come with it, and skips a subscriber who left after queueing.
  • The compose form offers the opt-in lists, in all three themes. A list can only be sent an email.

Tests

The audience, the queue and the delivery against MySQL; the screen's send, including the address rows and the refusal of a non-email message to a list, against both backends.

Documentation

  • Email Guide — sending to a mailing list.
  • Upgrade Guide — one row: run migrate.

A deactivated role went on granting its permissions

Fixed

  • PermissionResolver never read roles.is_active. It checked whether each assignment was active, not whether the role was, so a role an administrator deactivated went on granting everything it held to everybody assigned it — while the Roles screen said an inactive role grants nothing. Only PostgreSQL's get_user_effective_permissions() checked it, and PHP never calls that. Every check goes through the resolver, so Permissions::isAllowed() and the administration screens granted by permission were affected the same way. Found by msdauthserver while documenting its RBAC.

    A role marked inactive is now dropped. An assignment to a role id with no row in authserver.roles keeps the answer it had; that is a separate decision.

Documentation

  • Authorization Guide — what deactivating does, precisely.
  • Upgrade Guide — one row, with the query that finds the roles affected.

Saving an organisation deactivated it

Fixed

  • None of the bundled organisation edit forms had is_active or org_type, and OrganizationsController::save() read the missing checkbox as "off": every edit of an organisation deactivated it and cleared its type. Found by msdauthserver. The forms now carry both (the checkbox behind a hidden 0, so unticking means inactive), and save() changes each only when the form sends it — so an application's older copy of the view no longer clobbers them.

Admin actions could be triggered from another site — addWriteAction(), starting with Users and Privacy

A review of every action in the framework's administration screens that changes state found 48 of 58 with no token check: 27 of them took a plain GET link (delete, lock, revoke, reset a password, disable a second factor…), and another 21 a POST nobody verified — in 7 of those the view even sent a token the controller never read. Any page a signed-in administrator opened could do the same with an <img src> or a hidden form. msdauthserver's review of its RBAC surfaced the first of them.

Added

  • Controller::addWriteAction([...]) — declared once in a constructor; exec() refuses those actions before they run unless they arrive as a POST with a token this session issued (either of the framework's two, as a field or as X-CSRF-Token). A page is sent back with a message, a script gets 403 JSON. No action needs a check of its own.
  • Icon::postButton() and Icon::postControl() — a one-button POST form with the session's token, looking like the link or button it replaces, working without JavaScript.

Fixed

  • Users: delete, lock, unlock, resetpassword, unlocklogin, disabletwofactor, revokepasskey, signinalerts, savesetting, deletesetting, grantpermission, revokepermission, sendnotification, the token actions and save are write actions; every link to them in the three themes and in the user list is a form now.
  • Account → Privacy had no token anywhere — the form sent none and the screen checked none — and it now also joins mailing lists. Both halves are in place.

The remaining screens follow in the next entries.

Documentation

  • Security Guide — controller actions that change something. Framework Guide — a pointer.
  • Upgrade Guide — one row.

Every other admin screen's write actions are declared too

The rest of the 48 from the entry above. Each controller declares its actions with addWriteAction(), and every link to them in the three themes is a one-button form carrying the token.

Fixed

  • Dashboard clearcache · Logs clear, clearFile · Organizations save, delete, addmember, removemember · Services stop, start, restart · Settings saveSystem, save, delete · Account revokeapplication · Applications save, delete, rotate, webhook, webhookrotate, webhookdelete · Permissions save, delete · Roles save, delete, addmember, removemember · Tokens revoke, revokeall, deactivate, delete · MailTemplates save, delete, test · MassMessages save, send, delete · Queue retry, retryall, delete, clear.
  • Logs → Rotate shows its confirmation on GET and rotates only on a verified POST.
  • Passkeys: renaming or revoking one from the account screen requires a verified write or a same-origin fetch() (X-Requested-With: XMLHttpRequest, which another site cannot send without a CORS preflight). pf-auth.js also sends X-CSRF-Token. The API controller is unaffected.
  • The POST forms of these screens (settings, applications, permissions, tokens, organisations…) carry the token; most sent none. The Queue screen's Clear button was a link inside the filter form and is a form of its own after it.
  • The cache screen's fetch() sends Accept: application/json and X-CSRF-Token.

Added

  • tests/Unit/Application/WriteActionViewsContractTest.php reads the declarations out of the controllers and fails on any bundled view that links to one, or posts to one without the token.

Documentation

  • Security Guide — the inline guard for an action that is a page on GET, and the contract test. The Upgrade Guide row from the entry above covers these screens too.

A deny and an allow at the same priority decided differently on MySQL and PostgreSQL

msdauthserver's RBAC review found that PostgreSQL added 1000 to a deny's priority on every insert and every update, and MySQL did nothing. So a deny and an allow at the form's default 100 were a deny on one database and an allow on the other, and a deny row grew by 1000 each time anything on it was edited. The same review found that the organisation-membership trigger refused the UPDATE that takes a role away from somebody who had already left the organisation.

Fixed

  • One rule, in the resolver and the view: the higher priority decides, and a deny wins a tie. PermissionResolver and authserver.effective_permissions compare with >=; the stored priority is what was written, on both databases.
  • Migration unify_authserver_deny_rule drops PostgreSQL's set_permission_priority trigger, brings the deny rows it inflated back with priority % 1000, recreates the view on both databases, and lets check_user_org_membership() through when the row is being made inactive. down() restores all three.

Documentation

  • Authorization Guide — how an allow and a deny are decided; revoking an organisation's role after the member left. Upgrade Guide — one row, with the queries that find the pairs whose decision changes.

permissions_changed for every change, and a Permissions form with every column

The same review: permissions_changed — the webhook an application's permission cache relies on — was sent only from the Permissions screen. Giving somebody a role, deactivating a role, the Administration screens panel and the user page's grants changed what people could do and told nobody. A role's event was queued for user 0, which the events table's foreign key refuses. And the Permissions form had no priority, audience, expiry or conditions, while it offered a "Group" subject the resolver never reads.

Added

  • WebhookService::permissionsChanged($subjectType, $subjectId, $context) — one best-effort call, made by Permissions, AdminAccess, Role (save, delete, assign, revoke), and the Roles, Users, Organisations and Permissions screens. A role's event has no user (NULL) and names the role in the body.
  • The Permissions form: Priority, Application ID, Expires, Conditions (JSON). save() writes each only when the form sends it, so an older copy of the view leaves them alone.

Fixed

  • save() refuses a subject other than user or role, conditions that are not a JSON object, and an expiry that is not a date. "Group" is gone from the three themes' forms.
  • Permissions/assign is a write action: a POST with the token.
  • WebhookService::queueEvent() returns 0 instead of warning when the endpoints table is absent.

Documentation

  • Authorization Guide — the Permissions screen, and which writes send permissions_changed. AuthServer Integration Guide — the body a subscriber actually receives, and what to do for a role. Upgrade Guide — two rows.

Every framework admin screen opens at usertype 98; roles open the rest

The framework's screens opened at 80 or 90 — a number each controller and each menu item declared for itself — and UserTypes' capability map, which /admin/Users/types renders, was a third list that agreed with neither: it gave 90 no admin.permissions while that screen opened at 90, and there was no usertype band at 80 at all, so the account most screens opened to was labelled "Simple User". The screens reach every account, setting and token; by default only a superuser should open them, and anybody else should hold a role for the screens they need.

Changed

  • AdminAccess::defaultUsertype() — 98, or the admin_default_usertype setting — is the floor of every framework screen and its menu item. Controller applies it to $requiredUserType at construction when the property is still the framework's declaration at its declared value, so an application that redeclared or set it keeps its own. A screen with an ability and no floor of its own (Health, Logs) is at the default rather than at 0.
  • The area lets in somebody a grant opens a screen to, under admin_access = mixed or permissions, even below its min_usertype. init writes min_usertype => 98.
  • UserTypes::capabilityMap() reads the admin.* capabilities from the registered screens at their floors, admin.area from the area and devpanel from the panel. DEFAULT_CAPABILITIES keeps root's *, the machine account and account.self.

Documentation

  • Authentication Guide — the usertype table and floors, and roles as the way to give part of the area. Authorization Guide — the same under admin_access. Upgrade Guide — two rows, with the query that finds the administrators the change refuses.

/api/internal/permissions answers for one organisation

PermissionResolver::resolveForOrganization() — an organisation's role counts only within it, and only while the user is a member — had no caller in the framework: the endpoint resource servers use asked resolve(), so a role defined for organisation 5 answered for organisation 3.

Added

  • organization_id on GET /api/internal/permissions asks the scoped question. A resolver an application wrote to PermissionResolverInterface alone, without the method, refuses it with 400 rather than answering the unscoped one.

Documentation

  • AuthServer Integration Guide — the endpoint as it is: HTTP Basic client credentials, the flat permissions list, the tie rule, organization_id. Authorization Guide — who calls resolveForOrganization().

/admin/MailingLists: who is on each opt-in list

The opt-in lists had a public form, a confirmation page, the account screen and a way to send to them, and nowhere an administrator could see who was on one. GlideDay relayed the owner's list of what the screen should do.

Added

  • /admin/MailingLists (MailingListsController, ability admin.mailinglists): every list with its counts per state; one list's subscribers, paged, searchable by address and filtered by state; Send confirmation again for a pending row and Unsubscribe for any row still on the list, both POST with the token; Export confirmed (CSV). Views in the three themes, a menu item under System, and the wrapper init writes.
  • MailingList::lists(), counts(), subscribers(), subscriber(), resendConfirmation(), unsubscribeSubscriber(), confirmedExport(). Unsubscribing a row goes through unsubscribe(), so the opt-out and the consent event are written as for any other way of leaving. Resending is not held to the hourly limit that guards the public form.

Documentation

  • Email Guide — the screen, its actions, and the wrapper a project scaffolded before it adds.

The compose form follows the chosen mailing list

The second half of the owner's list. Choosing a mailing list on /admin/MassMessages/edit left the account criteria, a channel select that offered push, and an unsubscribe list that could name something else — all of which either did nothing or sent the reader's unsubscribe to the wrong list.

Changed

  • MassMessagesController::save(): a message addressed to a list is stored as an email, its unsubscribe list is the list itself, and its criteria are the list alone.
  • The form, in the three themes: choosing a list hides the account criteria and the opt-out filter, fixes the channel to email and fills the unsubscribe list in read-only — server-rendered for a saved draft, so nothing flashes before the script runs. Track opens and clicks is ticked on a new message when tracking is switched on.

Documentation

  • Email Guide — Sending to a mailing list.

Who opened and clicked one mass message

Tracking recorded opens and clicks per message, and the email history showed them one message at a time; nothing added them up for a campaign, or said who opened it. The owner asked for the statistics and the lists.

Added

  • Tracking::campaign(), campaignRecipients(), campaignLinks() — a campaign's summary counted by people (opened by a person, fetched only by a provider, clicked), its recipients narrowed to opened, clicked or unopened, and the links followed with how many people followed each.
  • /admin/MassMessages/tracking/{id} and an Opens and clicks card on each message's page, in the three themes.
  • Email::$moduleinfo, written to mails.moduleinfo; the dispatcher sets it to the message's id (MassMessageDispatcher::MAIL_MODULE is the module). That is how a campaign's mail is found.

Documentation

  • Email Guide — One campaign's opens and clicks. Upgrade Guide — one row, for a subclass that declares $moduleinfo with a type.

An account's email choices on its administration page

What an account receives — which optional mail it turned off, which lists it joined — was visible only to the person, on their own privacy and preferences pages. An administrator asked "why do they not get the newsletter?" had no screen to look at.

Added

  • An Email panel on /admin/users/view/{id}, in the three themes: every optional kind of mail and every opt-in list with its state, and whether the address left everything. Off, on at the person's request, and clearing "left everything", through users/emailpreference/{id} (POST with the token, logged). On for an opt-in list sends the confirmation mail rather than subscribing.
  • Unsubscribe::clearOptOut($email, $list) — clears exactly one list's opt-out and records the consent. optIn() also clears all, which is not what an administrator turning one kind of mail back on was asked for.

Documentation

  • Email Guide — On an account's administration page.

A WYSIWYG editor for messages, chosen per application

A mass message was written as raw markup in a textarea, and the message to one account as plain text. The owner asked for an editor, chosen by the application, with the one-account message on the framework's own.

Added

  • pf-editor.js — a dependency-free editor for <textarea data-pf-editor="builtin">: bold, italic, headings, lists, link, image by https address, clear formatting, and the HTML. Pasting inserts text. Shipped with the other pf-*.js.
  • Pramnos\Html\MessageEditor — mode() reads messaging.editor from app.php (builtin by default, tinymce, none) and scripts() prints what a mode needs; TinyMCE takes its src and license_key from messaging.tinymce.
  • Pramnos\Html\SafeHtml::clean() — an allowlist sanitizer for editor markup.

Changed

  • The mass-message form uses the configured editor; Users → Send always uses builtin, and its body is sent as HTML cleaned by SafeHtml instead of being stripped of markup.

Documentation

  • Email Guide — The editor a message is written in; the one-account message's body. Upgrade Guide — one row.

A server without its signing key minted tokens anybody could forge

Found while mapping the OAuth server for the per-client work. The JWT-assertion client_credentials path signs its access token with the server's RSA key — and when the key file was missing it fell back to HS256 with the client id as the secret. The client id is public, so a token signed with it is one anybody can produce.

Fixed

  • No signing key, no token: 500 server_error, and the log says where the key was looked for. The path is OAuth2ServerFactory::defaultPrivateKeyPath(), the same one the other grants and the JWKS use (Oauth::signingKeyPath() for an override).
  • /session's bearer check had the same fallback on the other side — HS256 keyed with the application's client id when the key pair was absent. It now verifies with the server's public key or not at all, and still only for a stored, live token of an application that exists (Session::publicKeyPath() for an override; the query is on the builder now).

Documentation

  • AuthServer Integration Guide — the JWT client assertion and its token. Upgrade Guide — one row, with how to revoke tokens a keyless server issued.

The message editor's headings and lists look like headings and lists; push is written plain

The owner tried the editor: headings went in and looked like body text. A theme's CSS reset — Tailwind's preflight — sets every heading to the body size and takes the bullets off lists, so the markup was there and invisible. And the editor stayed on with the channel set to push, which carries text only.

Fixed

  • MessageEditor::scripts('builtin') prints a stylesheet scoped to .pf-editor-area for headings, paragraphs, lists, quotes, links and images; it gets the page's CSP nonce like any inline <style>.
  • Headings are applied with the bracketed formatBlock value, which Firefox and Safari need, and Enter starts a <p> rather than a <div>.
  • On the mass-message form, choosing push turns the body back into the bare field (TinyMCE hides too) and email or internal brings the editor back. PfEditor.plain() is the call.

Documentation

  • Email Guide — The editor a message is written in.

An application can be marked trusted without SQL

msdauthserver's list, first item. applications.trusted skips the consent screen, and nothing could set it: the edit form had no field and save() did not write it.

Added

  • A Trusted switch on /admin/Applications/edit, beside Client Type, in the three themes — a hidden 0 behind the checkbox, so unticking saves.
  • ApplicationsController::save() writes trusted only when the form sends it.

Documentation

  • AuthServer Integration Guide — where the switch is, and what it costs.

A user's page lists the roles they hold

msdauthserver's second item. The user page showed organisations and never roles, so "what does this person hold" was answered on each role's page, one role at a time.

Added

  • A Roles panel on /admin/users/view/{id}, in the three themes: role (linked), organisation or system-wide, granted, expires, and a mark on one that does not count.
  • Role::heldBy($userId) — the active assignments, with counts false for a deactivated role or an expired assignment; revoked ones are left out.

Documentation

  • Authorization Guide — Roles: the screens.

A client's Allowed Scopes are enforced

msdauthserver's third item. The application screen saved Allowed Scopes and nothing read it: /oauth/authorize and the token endpoint checked only the server's registry, Scopes::areApplicationScopesGranted() had no callers, and ScopeRepository::finalizeScopes() returned whatever was asked. The JWT-assertion grant did not check scopes at all.

Fixed

  • Application::scopesBeyond($allowed, $requested) — the requested scopes the list does not include; an empty list restricts nothing.
  • Checked at /oauth/authorize before consent; in ScopeRepository::finalizeScopes() and in AccessTokenRepository::getNewToken() — League skips the first for a refresh, and every grant issues through the second; and in the JWT-assertion grant, with the registry check it lacked. ClientEntity carries the list (setAllowedScopes() / getAllowedScopes()).
  • The edit form says what the field does, and what empty means.

Documentation

  • AuthServer Integration Guide — Allowed Scopes. Upgrade Guide — one row, with how to find the clients it would refuse.

OpenID Connect: an ID token for openid, and a discovery document that says what works

msdauthserver's fourth item. With openid granted, /oauth/token returned an access token and no ID token, while the discovery document advertised ID-token response types, the claims an ID token carries, the implicit grant and back-channel logout — none of which the server did.

Added

  • IdTokenResponse — the bearer response plus an id_token when openid was granted and the token has a user: RS256 with the access tokens' key and the JWKS kid, iss = the discovery issuer, sub, aud, iat, exp, nonce, auth_time on a code, and the scope claims.
  • UserClaims::for($userId, $scopes) — the claims builder /oauth/userinfo and the ID token share, so the two cannot disagree.
  • The nonce on /oauth/authorize, carried with the code in the new usertokens.oidc_context (migration add_oidc_context_to_usertokens, with auth_time), and read back at redemption through OAuth2ServerFactory::redeemingCode().

Fixed

  • Discovery: response_types_supported is code, response_modes_supported query, no implicit grant, back-channel logout false, userinfo_signing_alg_values_supported none, and claims_supported lists what the ID token and userinfo actually carry.

Documentation

  • AuthServer Integration Guide — The ID token. Upgrade Guide — one row.

Token lifetimes are set per server and per client

msdauthserver's fifth item. An hour for an access token, ten minutes for a code and a month for a refresh token were written into the factory, for every client, and could be changed only by subclassing it.

Added

  • TokenLifetimes — oauth.access_token_ttl, refresh_token_ttl and auth_code_ttl in app.php, the old values as defaults; a client's own value first; clamped to sane bounds.
  • Per-client lifetimes — migration add_token_lifetimes_to_applications, two fields on the application's OAuth2 tab, written by save() only when sent. Applied in AccessTokenRepository / RefreshTokenRepository as the token is persisted, so exp, expires_in and the stored row agree. The JWT-assertion grant uses the same rule.

Documentation

  • AuthServer Integration Guide — Token lifetimes. Upgrade Guide — one row.

permissions_changed reaches only the applications it concerns

msdauthserver's sixth item. Every change was queued for every subscribed endpoint of every application, so each application learned when anybody's access changed anywhere — including people who had never signed in to it, and permissions that belonged to another application.

Changed

  • WebhookService::queuePermissionsChanged() — a permission with an app_id goes to that application; otherwise to the applications the user, or each holder of the role, has an unexpired token for or has consented to. permissionsChanged() and PermissionsController go through it, the latter with the permission's app_id on save and delete.
  • WebhookService::queueEventForApplications($type, $userId, $payload, $appIds) — any event, to the named applications' endpoints.

Documentation

  • AuthServer Integration Guide — who receives it. Authorization Guide — the same. Upgrade Guide — one row.

A migration's new column is saved at once, on PostgreSQL too

GlideDay's finding: a migration added properties.languages, and for up to an hour every model save left it NULL. Model cached the column list under schema_columns_ + its qualified name — public.properties on PostgreSQL — while the flush a migration runs built its key from the bare name and the connection, so the flush reported success and the stale list stayed. cache:clear did not reach it either.

Fixed

  • Database::columnCacheKey($table) — the one key: prefix resolved, the connection's own schema dropped, scoped to the database. getColumns(), forgetColumns() and every column cache in Model use it.
  • forgetColumns() / forgetAllColumns() also clear Model::$columnCache, the list a model keeps for the rest of the process.
  • cache:clear clears the database's query cache (CacheClear::clearQueryCache()) as well as the application's.

Documentation

  • Migration Guide — the section rewritten to what happens now. Console Guide — what cache:clear clears.

An organisation's own administrator

msdauthserver's seventh item, designed with the owner first: somebody who runs one organisation — members, invitations into it, its roles — without the administration area, and never with more than they hold.

Added

  • OrganizationAdmin — a manager is whoever is allowed organization / manage / its id, directly or through one of its roles (a superuser manages all). add() makes an account a member at once or sends an address an invitation, optionally with a role; remove(); giveRole() / takeRole() for the organisation's own roles only, and only those that allow nothing the manager is not allowed (mayGive()); withdraw() an invitation. Each change is logged on the account and sent as permissions_changed.
  • /organization (Application\Controllers\Organization, no wrapper needed): the organisations you manage, and each one's members, roles and invitations, in the three themes; the same addresses answer JSON. Writes are POST with the token.
  • An Organisations link in the account sidebar, for somebody who manages one.
  • The page's messages, errors and title go through t(), so an application's language file translates them; the JSON answers keep the English sentence, to match on.

Documentation

  • Authorization Guide — An organisation's own administrator.

An array setting is not sent to the settings table

Settings::setSetting() wrote whatever it was given to settings.value, which is text: setting the cache block — an array — while the settings had a database was "Array to string conversion" and a refused query. Which tests hit it depended on the order they ran in.

Fixed

  • An array or object setting is kept in memory only; the table receives scalars and NULL, as it always could store. Configuration of that shape lives in settings.php.

A cached query answered for another database behind the same Redis

myliveradio found a panel token resolving to the previous test run's account. A cached query was stored under the table prefix alone — empty on most installations — which Database::cacheRead() assigned over the installation prefix Cache had been configured with. So the key named no database: two databases on one Redis running the same statement shared one entry, settings and userlist included. The same fault the column cache had.

Fixed

  • The SQL cache prefix is the installation prefix, the table prefix and the connection's database and schema. cacheStore(), cacheRead(), cacheExpire() and cacheflush() build it in one place, Database::sqlCache(), so a flush on one database leaves another's alone.
  • Entries written under the old key become unreachable and expire on their own.

Documentation

  • Cache Guide — Whose rows a cached query is.
  • Upgrade Guide — a row for the key change.

A mass message filtered by organization reached nobody

MassMessageAudience read the members of an organization by user_id. The membership table the framework's migration creates has userid, so the query failed, the failure was caught as "no such table", and every organization filter resolved to an empty audience. The test had created its own table with user_id, so it tested an invented shape and passed.

Fixed

  • The organizations filter reads userid, as the organizations screen, Role and PermissionResolver already do.
  • The test builds the membership table in the migration's shape, under its own name through authserver_organization_table.

Documentation

  • Email Guide — the audience table names the membership table and its columns.
  • Upgrade Guide — a row: organization-filtered sends now reach their members.

The suite passes on Linux, not only on a case-insensitive disk

On a Linux checkout the suite failed 10 errors and 29 failures that did not happen on macOS or Windows. Four causes:

  • Seven fixture files were in the wrong directory. The suite has two fixture roots: tests/fixtures holds files reached by path, and tests/Fixtures holds PSR-4 classes. On a case-insensitive disk the two are one directory, so the fixture application's migrations and the files OutboundFetchTest serves were committed under tests/Fixtures and worked there. On Linux they were somewhere nothing looked.
  • Tables built from assumptions, not migrations. AccountExportTest hand-rolled sessions without the columns its own seed names, so it passed only when another test had built the table first.
  • Drops that a foreign key refuses. Four tests dropped applications with the checks on, and failed once a migrated table referencing it existed.
  • A stale container image. The running image predated apc.enable_cli=1, so the APCu tests were skipped. Rebuilding the image fixes it; there is nothing to change in the repo.

Fixed

  • The fixtures moved to tests/fixtures. NoFixtureDirectoryDiffersOnlyByCaseTest fails the next time two directories differ only by case, and the Linux run is where it can see that.
  • sessions comes from CreateSessionsTable, and the drops of applications turn the foreign-key checks off, as ApikeyCharacterizationTest and OauthTest already did.

RateLimitMiddleware can count an API key, a user, or a tier

myliveradio wants to limit its public API per key. The middleware built its bucket from md5($ip) inside handle(), so two keys behind one NAT shared a budget, one key from many addresses had none, and a subclass wanting any other identity had to re-implement handle(), including the choice between the atomic and sliding-window paths.

Added

  • RateLimitMiddleware's new last constructor argument, identify, returns what to count: a string, null for the address, or [$identity, $maxRequests, $perSeconds] for an identity with its own limit. Identities are kept apart from address buckets. A malformed answer throws. Nothing changes for a limiter constructed without it.

Documentation

  • Security Guide — Rate limiting a route: RateLimitMiddleware. It had been documented only in the frozen v1.2 notes.

An application can send webhooks about its own events

myliveradio wants third parties to subscribe to a station went on the air and the track changed. webhook_type carried a CHECK over the framework's eight events, and WebhookService repeated the list, so an application's event was refused by one or the other. Adding a type meant a framework migration, and the framework cannot know what a radio station is.

Added

  • Pramnos\Auth\WebhookEvents::register(), shaped like SystemMailTemplates::register(): registered types are offered, accepted and delivered like the built-in ones. The built-in types win a collision.
  • WebhookService::queueEventForApplications(), added today for permissions_changed, takes an application's own types too, so an event can be delivered only to the applications it concerns.

Changed

  • Migration open_webhook_event_types drops the CHECK on oauth2_webhook_endpoints.webhook_type, found by name on both databases. Validation moved to WebhookService::saveEndpoint(), which throws InvalidArgumentException for a type nobody registered, and to /Webhook/register and the administration form, which read WebhookEvents::names(). WebhookService::EVENT_TYPES remains the framework's eight.

Documentation

  • AuthServer Integration Guide — Sending events of your own.

An API key is held to the scopes its application was granted

myliveradio found that applications.scope was saved and never read. Api::checkApiKey() accepted any active key, and ApiAuthMiddleware did not look further, so a key issued read-only could write.

Added

  • Pramnos\Http\Middleware\ApiKeyScopeMiddleware: a route declares the scopes it needs, and a key whose application's Allowed Scopes leave one out gets 403 insufficient_scope. It uses the token endpoint's rule, Application::scopesBeyond(), so matching is exact and an empty field restricts nothing. A key with no application row is refused. The site's own key and a signed-in page's session are not checked.
  • #[Route(..., apiKeyScopes: [...])] attaches the check to a discovered route.

Documentation

  • API Guide — What a key may do: ApiKeyScopeMiddleware.

Something writes application_stats

The framework ships applications.application_stats, its hypertable, compression policy and hourly and daily aggregates, but nothing in src/ inserted a row. On myliveradio's install it held 0 rows, and «usage per key» was each application's to build.

Added

  • Pramnos\Http\Middleware\ApplicationStatsMiddleware adds each API request to its application's minute with one upsert: counts, status buckets, bytes, and response times. It records rate-limit refusals and failures. It is opt-in and best-effort, and it skips requests with no application behind them.

Documentation

  • API Guide — Usage per application: ApplicationStatsMiddleware.

Applications can be managed from an application's own screen

Creating an application, rotating its secret and listing its tokens lived only in ApplicationsController, the framework admin's HTML screens. myliveradio has its own Svelte panel and does not mount that area, so every one of those operations would have had to be written again, including the ones that are security decisions.

Added

  • Pramnos\Auth\ApplicationService covers validate(), create(), update(), rotateSecret(), deactivate(), tokens(), find(), exists() and callbackCeiling(). It holds the rules that were in the controller: callback normalisation, refused schemes, the column's width, the clamps, hashed secrets returned once, and token revocation on retirement. ApplicationsController now calls it.

Fixed

  • Rotating the secret of an application that no longer exists generated a secret, updated no rows and showed it to the operator. The screen now says the record no longer exists, and so do update and delete.
  • The testing Schema recipe for applications was missing three migrations that add columns — extended info, broadcast_secret and is_confidential — so a fixture built from it could not take the insert the screen makes.
  • WebhookStorageTest builds users instead of assuming another test left it there.

Documentation

  • AuthServer Integration Guide — Managing applications from your own screen.

The MySQL test container keeps its tables in one tablespace

A --nocoverage run took 8 to 12 minutes on WSL. The time was inside the tests, spread across fixtures that rebuild applications and similar tables in setUp. On an empty table, an ADD COLUMN without ALGORITHM=INSTANT rebuilds the table, and each rebuild created and fsynced a new file: about 90ms per ALTER.

Changed

  • docker/mysql/my.cnf sets innodb_file_per_table = OFF, which makes ALTER, CREATE and DROP about 2.3× faster in the container. The full suite went from 11:50 to 6:44, and summed test time from 680s to 382s. Rebuild the container to apply it: docker compose build db && docker compose up -d db.

TestClient sends a PUT's data the way PHP receives it

From myliveradio's SPA filing, the last open item of it. TestClient copied a PUT, PATCH or DELETE body into $_POST, which PHP only fills for POST. A handler reading $_POST on a PUT therefore passed its tests and received nothing in production, and two of myliveradio's tests were green for that reason.

Fixed

  • TestClient fills $_POST and $_REQUEST for POST only. Every method with a body gets it as the raw input, which Request::decodeBody() reads.

Documentation

  • Testing Guide — Available request methods.
  • Upgrade Guide — a row: a test that now fails on PUT was testing a handler that fails in production.

The scaffolded API client says whose contract it speaks

This is the other open item of myliveradio's SPA filing. The Application Styles Guide tables which parts of lib/api.js assume an Api-style project, but the generated file said nothing, so a reader meeting the code still had to work it out.

Documentation

  • The header of spa-api-client.js.stub states that it speaks a Pramnos\Application\Api project's contract, and points at the guide section that tables what to replace. SpaApiClientStubCitesTheGuideTest checks that the section it cites exists.
  • Application Styles Guide — What the scaffolded API client assumes: the account of who filed it gave way to a line saying the file points here.