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/tokennever authenticated an API call - Invitations: the four gaps glideday found moving onto them
/oauth/userinforefused the token/oauth/tokenhad 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\Clientcan 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_changedfor every change, and a Permissions form with every column- Every framework admin screen opens at usertype 98; roles open the rest
/api/internal/permissionsanswers 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_changedreaches 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
RateLimitMiddlewarecan 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
TestClientsends 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.
initscaffoldsPOST /mcpinto the API routes, andApiAuthMiddlewareanswered403 APIKeyMissingbefore the controller ran. A connector cannot send anapiKeyheader, so it never reached the401that starts OAuth discovery. Listing the path inpublic_api_pathsremoved 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
Apiputs/<version>/mcpon the public list itself, so nothing has to be declared. -
WWW-Authenticatepointed under the API. It was built fromsURL, which inside the API ishttps://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 thatresourceis the URL they were given. The401now nameshttps://host/.well-known/oauth-protected-resource/api/1.0/mcp, built from the site's origin, andDiscoveryanswers that address with the endpoint as itsresource. 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-serverlackedcode_challenge_methods_supported, although S256 was implemented, and had nononeamong the auth methods. Both are there now. registration_endpointwas the sign-up page. A JSON POST to it answered200with HTML.
Added¶
-
Dynamic client registration, RFC 7591 —
POST /oauth/register, opt-in with'oauth_dynamic_registration' => trueinapp.php.initwrites that line for anauthserverproject, and the discovery documents publish the endpoint only when it is on.It admits public clients only:
token_endpoint_auth_methodmust benone(the RFC's default,client_secret_basic, is refused), grants are the code grant and refresh, and callbacks arehttpsor loopbackhttp. A confidential client is refused because it could useclient_credentials, which needs no person — open registration would have been open issuance of machine tokens. Twenty registrations an hour per address. -
The scaffolded
.htaccessroutes/.well-known/oauth-protected-resource/<path>toDiscoverywith 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_pathsroute. - 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/authorizecould be exchanged, ever. The controller wrote a 64-character hex code intousertokensitself;/oauth/tokenhands every code to League'sAuthCodeGrant, which decrypts it, and refused each one withCannot 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.
resourceon a token request adds that URI to the access token'saud, beside the client id; a resource that is not on this site's origin isinvalid_target. The MCP endpoint then refuses a token bound only to another resource, as the MCP authorization spec requires. A token with no URL inaud— every token requested withoutresource, 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
resourceis read.
A client that cannot keep a secret needs a registered redirect URI¶
Fixed¶
-
/oauth/authorizeaccepted anyredirect_urifor 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'sredirect_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 emptyapisecret— 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, andaccess_denied),EmailclickandFormRequest::failWith()go throughApplication::redirect(). The destination is recorded ingetRedirect(), whichTestClientanswers asLocation.Oauthsets it and callsredirect()with no argument, becauseredirect($url)writes the URL to the developmentredirectslog and this URL carries the code.failWith()stopped the test run. It sent the header and calledexit, which ends the PHPUnit process with status 0. It now ends the request the way everything else does, and has a test.
Added¶
RedirectsAreRecordedTestfails on aheader('Location: …')anywhere insrc/outsideApplication::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(), notheader()andterminate(). - Upgrade Guide — a row: tests that expected
…Oauth::terminate() calledcatchApplicationClosedExceptioninstead.
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¶
$_SERVERleaked between tests. Fourteen test classes started with$_SERVER = []and none restored it.OauthRefreshCommandTestthen warned on Symfony Console's missingPHP_SELF, and the session-tracking tests on aSERVER_PORTwith noSERVER_NAME.Request::getURL(false)readSERVER_NAMEbare when a port was set. The branch without a port already treated it as empty.UserCoverageTesterrored after certain classes. It loaded the fixture settings and then took whatever Database singleton existed; one built before any settings pointed atlocalhost, and mysqli answered "No such file or directory" looking for a socket. It rebuilds the connection once, insetUpBeforeClass(), as the other database tests do.PasswordResetTokenTest::testATokenExpiringNowIsStillAcceptedfailed across a second boundary — storing attime()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:$_SERVERas it was after the bootstrap script, restored before every test.pramnos initregisters it.failOnWarningandfailOnDeprecationin the framework'sphpunit.xml, so the next one fails the run instead of joining the noise.ignoreIndirectDeprecationskeeps out one dependency's deprecation in another —league/oauth2-servercalling aleague/urimethod 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.
AccessTokenRepositorystores an access token by its identifier — itsjti— andUser::loadByToken()looks a token up by its whole text, so a token from/oauth/tokenwas 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 with401on its first call. glideday found it against production-equivalent code, with the cause and the fix;Oauth::resolveStoredTokenValue()already tried thejtisecond, which is why introspection and revocation worked while authentication did not.ApiAuthMiddlewareandUnifiedAuthMiddlewarenow look a token up by itsjtiwhen the literal lookup finds nothing, reading thejtifrom the claimsJWT::decode()has just verified. The fallback is not inloadByToken():Gdpr::resolveActor()calls it with an unverified bearer, and there a forged JWT carrying a realjtiwould 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/tokenis 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) orpermissions. Underpermissionseach 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 inexec()before any action — inside the administration area, where there is one, so a public page built on a screen outside it keeps answering — and$adminPublicActionsfor the ones that answer without it.Permissions::clearCache(), andNavRegistry::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 isusertype, so it can be switched on a running installation. The panel offers Default / Allow / Deny per screen;AdminAccess::setDecisions(),decisionsFor()andmode(). 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.logslike 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\Invitationsandauthserver.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 asToken::lookup(); one live invitation per address. Accepting joins the organisation, gives the role and firesinvitation.acceptedwith the row. An Invitations screen (admin.invitations, 98 under usertype) in all three themes;pramnos initscaffolds its wrapper. auth_registration_domainslimits open registration to whole domains.- Address confirmation —
Pramnos\Auth\EmailVerification,auth_registration_verify_email, always on under a domain list. Sign-in answers the newLoginFlowResult::EMAIL_UNVERIFIED(after the password, so it reveals nothing) and mails a fresh link at most every ten minutes; the API login answers403 email_unverified. One row in the Upgrade Guide. - Mail templates
auth.invitationandauth.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_tablefailed a second run on MySQL — "Duplicate key name 'uq_settings_name'" — because MySQL has noADD INDEX IF NOT EXISTS. It returns when the index is already there, as a migration re-applied byTesting\Schemaexpects.- A changelog row written without the service provider stored the array raw. The JSON
encoders for
changes,detailsandcontextwere registered inChangelogServiceProvider::boot(), soChangelogWriterused on its own — or afterWriteSpool::reset(), or on the synchronous path when the spool is unavailable — handed an array to the insert.ChangelogWriter::registerEncoders()now runs before every append, andModel::logEvent()calls it too.
Tests¶
- Fixtures that left shared tables in another shape, or gone:
SessionTestbuilt a four-columnusersand kept it;UsersControllerTestdroppedusersandusertokenson the way out; tests that only needed a setting in memory wrote it tosettings. Each suite that needsusersorsettingsnow ensures its production shape (Testing\Schema::table('settings')is new), and the PostgreSQL registration test brings theapplicationssequence 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/Pramnosrather than the fixtures other suites generate intosrc/.
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,metadatausually, 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 itwithPreheader(…)or replace it.InvitationNotificationgainedwithPreheader()andmailPreheader(), which the mail channel already reads.{days}for theauth.invitationtemplate, 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_templatesran once, on 26 September, beforeauth.invitationandauth.verify_emailexisted, so on those installations Administration → Mail templates did not list them. The seeding is nowSystemMailTemplates::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 callsInvitations::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
jtigap, 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
jtiis 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 realjtiand a broken signature still gets401.
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()sends202and releases the caller, and the run still ended inrespond(), 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 theexitit ends with is a seam of its own (finish()), so a test runs the realrespond()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 ranCREATE MATERIALIZED VIEW … WITH (timescaledb.continuous …)with no data clause, and PostgreSQL's default isWITH 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 DATAand refreshed up tonow(). 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 themigrationslog 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_afterinContinuousAggregateRegistry.HypertableRegistrycould 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. Acompress_afterthat does not reach further back thanstart_offsetis refused, because a refresh would have to rewrite compressed chunks. Off TimescaleDB it is ignored.SchemaBuilder:enableContinuousAggregateCompression(),isContinuousAggregateCompressionEnabled(),hasContinuousAggregateCompressionPolicy()andisLongerInterval(). The policy lookup shares its join withhasContinuousAggregatePolicy(), 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 withoutcompress_afterthe 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, anddecodeGzip()inflates a.gzfile 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 owncopy('compress.zlib://…')instead. An error's body is kept asbody()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, andMailTypes::byList()finds a type by its list.Pramnos\Email\MailingListover a newpramnos.mailing_list_subscriberstable: 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, andsubscribe()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 ofall. - 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¶
emailoptoutswas 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.initsendsmailinglistto PHP in a single-page application.
Changed¶
Unsubscribe's self-contained page and plain-text answer moved into aStandalonePagetrait that both controllers use.page()andrespond()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_listin the criteria), with the same preview. One recipient per account; a pending address is nobody's recipient. - Recipients by address:
massmessagerecipientsgainsemailandlanguage, andMassMessageDispatcher::queueAddresses()writes them,userid0 when there is no account. deliverToAddress()sends each in its own language as the list'sMailType, 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¶
-
PermissionResolvernever readroles.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'sget_user_effective_permissions()checked it, and PHP never calls that. Every check goes through the resolver, soPermissions::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.roleskeeps 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_activeororg_type, andOrganizationsController::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 hidden0, so unticking means inactive), andsave()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 aPOSTwith a token this session issued (either of the framework's two, as a field or asX-CSRF-Token). A page is sent back with a message, a script gets403JSON. No action needs a check of its own.Icon::postButton()andIcon::postControl()— a one-buttonPOSTform 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 andsaveare 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· Logsclear,clearFile· Organizationssave,delete,addmember,removemember· Servicesstop,start,restart· SettingssaveSystem,save,delete· Accountrevokeapplication· Applicationssave,delete,rotate,webhook,webhookrotate,webhookdelete· Permissionssave,delete· Rolessave,delete,addmember,removemember· Tokensrevoke,revokeall,deactivate,delete· MailTemplatessave,delete,test· MassMessagessave,send,delete· Queueretry,retryall,delete,clear. - Logs → Rotate shows its confirmation on
GETand rotates only on a verifiedPOST. - 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.jsalso sendsX-CSRF-Token. The API controller is unaffected. - The
POSTforms 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()sendsAccept: application/jsonandX-CSRF-Token.
Added¶
tests/Unit/Application/WriteActionViewsContractTest.phpreads 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.
PermissionResolverandauthserver.effective_permissionscompare with>=; the stored priority is what was written, on both databases. - Migration
unify_authserver_deny_ruledrops PostgreSQL'sset_permission_prioritytrigger, brings the deny rows it inflated back withpriority % 1000, recreates the view on both databases, and letscheck_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 byPermissions,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 thanuserorrole, conditions that are not a JSON object, and an expiry that is not a date. "Group" is gone from the three themes' forms.Permissions/assignis a write action: aPOSTwith 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 theadmin_default_usertypesetting — is the floor of every framework screen and its menu item.Controllerapplies it to$requiredUserTypeat 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 = mixedorpermissions, even below itsmin_usertype.initwritesmin_usertype => 98. UserTypes::capabilityMap()reads theadmin.*capabilities from the registered screens at their floors,admin.areafrom the area anddevpanelfrom the panel.DEFAULT_CAPABILITIESkeeps root's*, the machine account andaccount.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_idonGET /api/internal/permissionsasks the scoped question. A resolver an application wrote toPermissionResolverInterfacealone, without the method, refuses it with400rather than answering the unscoped one.
Documentation¶
- AuthServer Integration Guide — the endpoint as it is: HTTP Basic client credentials,
the flat
permissionslist, the tie rule,organization_id. Authorization Guide — who callsresolveForOrganization().
/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, abilityadmin.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, bothPOSTwith the token; Export confirmed (CSV). Views in the three themes, a menu item under System, and the wrapperinitwrites.MailingList::lists(),counts(),subscribers(),subscriber(),resendConfirmation(),unsubscribeSubscriber(),confirmedExport(). Unsubscribing a row goes throughunsubscribe(), 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 tomails.moduleinfo; the dispatcher sets it to the message's id (MassMessageDispatcher::MAIL_MODULEis 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
$moduleinfowith 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", throughusers/emailpreference/{id}(POSTwith 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 clearsall, 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 byhttpsaddress, clear formatting, and the HTML. Pasting inserts text. Shipped with the otherpf-*.js.Pramnos\Html\MessageEditor—mode()readsmessaging.editorfromapp.php(builtinby default,tinymce,none) andscripts()prints what a mode needs; TinyMCE takes itssrcandlicense_keyfrommessaging.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 bySafeHtmlinstead 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 isOAuth2ServerFactory::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-areafor headings, paragraphs, lists, quotes, links and images; it gets the page's CSP nonce like any inline<style>.- Headings are applied with the bracketed
formatBlockvalue, 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 hidden0behind the checkbox, so unticking saves. ApplicationsController::save()writestrustedonly 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, withcountsfalse 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/authorizebefore consent; inScopeRepository::finalizeScopes()and inAccessTokenRepository::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.ClientEntitycarries 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 anid_tokenwhenopenidwas granted and the token has a user: RS256 with the access tokens' key and the JWKSkid,iss= the discoveryissuer,sub,aud,iat,exp,nonce,auth_timeon a code, and the scope claims.UserClaims::for($userId, $scopes)— the claims builder/oauth/userinfoand the ID token share, so the two cannot disagree.- The
nonceon/oauth/authorize, carried with the code in the newusertokens.oidc_context(migrationadd_oidc_context_to_usertokens, withauth_time), and read back at redemption throughOAuth2ServerFactory::redeemingCode().
Fixed¶
- Discovery:
response_types_supportediscode,response_modes_supportedquery, noimplicitgrant, back-channel logoutfalse,userinfo_signing_alg_values_supportednone, andclaims_supportedlists 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_ttlandauth_code_ttlinapp.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 bysave()only when sent. Applied inAccessTokenRepository/RefreshTokenRepositoryas the token is persisted, soexp,expires_inand 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 anapp_idgoes 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()andPermissionsControllergo through it, the latter with the permission'sapp_idon 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 inModeluse it.forgetColumns()/forgetAllColumns()also clearModel::$columnCache, the list a model keeps for the rest of the process.cache:clearclears 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:clearclears.
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 allowedorganization/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 aspermissions_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 arePOSTwith 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()andcacheflush()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,RoleandPermissionResolveralready 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/fixturesholds files reached by path, andtests/Fixturesholds PSR-4 classes. On a case-insensitive disk the two are one directory, so the fixture application's migrations and the filesOutboundFetchTestserves were committed undertests/Fixturesand worked there. On Linux they were somewhere nothing looked. - Tables built from assumptions, not migrations.
AccountExportTesthand-rolledsessionswithout 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
applicationswith 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.NoFixtureDirectoryDiffersOnlyByCaseTestfails the next time two directories differ only by case, and the Linux run is where it can see that. sessionscomes fromCreateSessionsTable, and the drops ofapplicationsturn the foreign-key checks off, asApikeyCharacterizationTestandOauthTestalready 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,nullfor 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 likeSystemMailTemplates::register(): registered types are offered, accepted and delivered like the built-in ones. The built-in types win a collision.WebhookService::queueEventForApplications(), added today forpermissions_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_typesdrops the CHECK onoauth2_webhook_endpoints.webhook_type, found by name on both databases. Validation moved toWebhookService::saveEndpoint(), which throwsInvalidArgumentExceptionfor a type nobody registered, and to/Webhook/registerand the administration form, which readWebhookEvents::names().WebhookService::EVENT_TYPESremains 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 gets403 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\ApplicationStatsMiddlewareadds 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\ApplicationServicecoversvalidate(),create(),update(),rotateSecret(),deactivate(),tokens(),find(),exists()andcallbackCeiling(). 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.ApplicationsControllernow 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
Schemarecipe forapplicationswas missing three migrations that add columns — extended info,broadcast_secretandis_confidential— so a fixture built from it could not take the insert the screen makes. WebhookStorageTestbuildsusersinstead 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.cnfsetsinnodb_file_per_table = OFF, which makesALTER,CREATEandDROPabout 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¶
TestClientfills$_POSTand$_REQUESTfor POST only. Every method with a body gets it as the raw input, whichRequest::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.stubstates that it speaks aPramnos\Application\Apiproject's contract, and points at the guide section that tables what to replace.SpaApiClientStubCitesTheGuideTestchecks 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.