Skip to content

19 October 2026

Seventy entries today:

  • db:migrate-from-mysql: the dry run names a backfill time column without an index
  • db:migrate-from-mysql --defer-indexes
  • db:migrate-from-mysql --zero-date, NUL bytes counted, one conversion for move and verify
  • db:migrate-from-mysql: the dry run names triggers, views, routines and events
  • db:migrate-from-mysql: an enum keeps its list
  • The MCP endpoint lists only the scopes it takes (FW-157)
  • MCP initialize carries the analyst's instructions (FW-161)
  • A loopback redirect matches on any port (FW-159)
  • Public MCP tools: roles and gates, hooks around every call, a missing scope named (FW-158)
  • MCP scopes declared in app.php, for consent served by another application (FW-158 §3)
  • oauth.scope_refusal => 'drop': scopes a role may not grant left out, not refusing (FW-158 §4)
  • A box per scope on the consent page (FW-158 §5)
  • audited() names the account and the outcome; redacting() spares user (FW-162)
  • database.translate_mysql: MySQL-written SQL run on PostgreSQL (FW-160, phase 1)
  • PRAMNOS_TEST_SETTINGS: an application's suite against PostgreSQL (FW-160, phase 3)
  • db:dialect-lint: SQL that would mean something else on PostgreSQL (FW-160, phase 2)
  • AuthorizedApplications: one place to list and revoke what a person authorized (FW-163)
  • A test that left organisations in the shared test database
  • A refused query says why, to a developer (FW-165 §4)
  • A statement that failed inside a transaction no longer breaks the next one (FW-165 §1)
  • Wrong second-factor codes are limited (FW-164 §1)
  • second_factor_everywhere: the second factor owed by the whole session (FW-164)
  • The lockout fails open, the two-factor reset forgets trusted browsers, sign-in devices translate (FW-164 §2–§4)
  • Bytes on PostgreSQL: Binary, bytea read as bytes, text kept in a blob named (FW-165 §5)
  • db:migrate-from-mysql --implicit-defaults (FW-165 §6)
  • The crop dialog's tools are icons
  • The second-factor gate runs the application's login flow, counts against the lockout, returns to the page (FW-166)
  • db:migrate-from-mysql: bigint unsigned becomes bigint (FW-165 §2)
  • db:migrate-from-mysql: long index and key names stay distinct (FW-165 §3)
  • translate_mysql: an upsert added to after it was translated (FW-165 §7)
  • translate_mysql: SHA2, CAST AS SIGNED, DAY/MONTH/YEAR, named locks, JSON_EXTRACT, DELETE through a join, an alias in HAVING, FOREIGN_KEY_CHECKS (FW-165 §8)
  • db:dialect-lint: SQL built over several lines, prose left alone, ORDER BY, --schema (FW-165 §9)
  • Binary in prepareQuery()
  • Slow dashboard cards: shared figures and dashboard:precompute (FW-167)
  • What's new: help pages announced to the administrators (FW-168)
  • The second-factor gate: trusted browsers, a stopped session that stays stopped, a lockout said (FW-169)
  • dashboard:precompute: the application's cards, a controller, variants nobody saved (FW-170)
  • db:migrate-from-mysql: binary and bit defaults carry over (FW-172)
  • The page cache: a flush that clears only the pages, the shared Redis, pages kept gzipped (FW-171)
  • A second step owed before the application is up; the precomputed cards' controller (FW-173)
  • A message's body read from the file an application keeps it in
  • Click links issued from the message's send date, the same each time
  • The debug toolbar's timers measured with the monotonic clock
  • A sent message's body in the body store, read back as it was sent
  • mail:bodies-to-database: bodies from files into the compressed table
  • A templated message: kept once, filled for each recipient
  • Bounces and blocked addresses: no mail goes to them, a password reset included
  • Helpers::getBrowser() parses an agent once a day, not once a call
  • device-detector's rules kept in the application's cache
  • Erasure and retention reach the body store
  • WebhookHandler::onBranchStaged(): composer runs beside the live tree (FW-174)
  • mail:hypertable: the mail log as a TimescaleDB hypertable, with the site up
  • mail:bounces --imap: the bounce mailbox over IMAP, with a password or XOAUTH2
  • The Roadmap's mail-body entry removed: the body store shipped
  • The change log: events without a model, on behalf of somebody, and a page at a time
  • Helpers::objectDiff(): a null property is not a removed one
  • Writing history into compressed hypertables: the policy paused for a move, the queue for spooled late rows
  • The notification bell in every header and in the SPA, settling what is dealt with, and keeping the feed tidy
  • A record's history, in the administration, on the site and in the SPA
  • The Roadmap's remember-me entry brought up to date
  • The auth screens in Greek, and whose server it is on every card
  • /Status: a public status page
  • New scaffolds register no auth addon: remember-me works without one
  • Setting up an authorization server, end to end
  • A recreated test database starts with nothing remembered about the old one
  • mail:bounces --imap --limit reads the newest unseen messages
  • An organisation's roles, written and checked for a fraction of the queries
  • A controller segment reaches its class in any case
  • The auth screens, finished: the theme's colour, closed registration, the account overview and organisation screens in Greek
  • The page cache stores no page that prints who its visitor is

db:migrate-from-mysql: the dry run names a backfill time column without an index

Added

  • --dry-run --time-column=… names every table whose time column no index in MySQL starts with, and gives the CREATE INDEX to run before the backfill. A backfill window is a range on that column. Without such an index, every window reads the whole table, which for years of history means days. A column that comes second in a composite index is named too, since that index does not serve a range on it.

db:migrate-from-mysql --defer-indexes

Added

  • --defer-indexes, for full and operational, drops a migration-made table's plain indexes for the load and makes them again once the rows are in.
    • Each definition is recorded in pramnos_migration_progress before the index is dropped. A load that dies leaves what is needed, and the next run of any phase makes the indexes again, with or without the option.
    • Unique indexes and those behind a constraint stay, because ON CONFLICT and the keys need them.

db:migrate-from-mysql --zero-date, NUL bytes counted, one conversion for move and verify

Added

  • --zero-date='1970-01-01 00:00:00' names what MySQL's zero date becomes in a column that takes no NULL. Such a row used to be refused, with no way around it but editing the source. The result counts the replacements.
  • Values that lose a NUL byte on the way are counted. PostgreSQL text cannot hold one, so it is dropped, and that used to happen without a word.

Changed

  • One conversion, Pramnos\Database\FromMysql\RowConverter, serves the move, the key comparison and the window digest. Each had its own copy of the encoding repair, so a fourth rule would have had to be added three times. What is verified is now what was written, by construction.

db:migrate-from-mysql: the dry run names triggers, views, routines and events

Added

  • The dry run names MySQL's triggers, views, procedures, functions and events. None of them moves, because their logic is in MySQL's dialect and belongs to the migrations. The dry run says, for each, whether the target already has one of that name, so the one the migrations forgot stands out.

db:migrate-from-mysql: an enum keeps its list

Changed

  • An enum column in a table the command makes is text with a CHECK on its list. It used to be plain text, so any word would do.
    • The list takes '' too. Outside strict mode, MySQL stores '' for a value not on the list, and an installation's old rows hold it.

The MCP endpoint lists only the scopes it takes (FW-157)

Changed

  • A protected resource's scopes_supported lists what that resource takes. An MCP connector asks for everything listed there, and the user's role allowed all of it. A Root user connecting to /api/1.0/mcp got a token carrying system:admin, system:audit_read and the rest, to call tools that needed mcp. Now:
    • the MCP endpoint lists mcp, the scopes its offered tools require, and offline_access;
    • a path named in 'oauth' => ['resource_scopes' => [...]] lists those;
    • the site itself, and any other path, list every scope, as before.
  • The MCP endpoint's 401 names the same scopes in scope="…", which the MCP spec has a client prefer.

What breaks

A client that connected through the MCP endpoint and relied on getting other scopes as well. It asks for them, or the path is given its list. The Upgrade Guide has the row.

MCP initialize carries the analyst's instructions (FW-161)

Added

  • McpServer::setInstructions(). The text is sent as instructions with initialize (MCP 2025-03-26), and a client passes it to its model.
  • The HTTP endpoint sets it from the application's analyst, through AnalystAssistant::mcpInstructions(). It carries about(), the analyst's clock, its rule about people, that masked values stay masked, and which tool to reach for. That is toolGuidance(), new, or by default "schema and sql are the last resort".

Over MCP, everything the analyst knew was lost. Asked about an application's visits, a model wrote SQL against the schema instead of calling the tool made for visits.

A loopback redirect matches on any port (FW-159)

Changed

  • /oauth/authorize matches a loopback callback on any port (RFC 8252 §7.3).
    • A native client (Claude Code, an MCP inspector, a CLI) listens on a port it is given each run, so none could be registered for it. An installation had to pin one.
    • Where http://127.0.0.1/…, http://[::1]/… or http://localhost/… is registered, a request with the same scheme, host, path and query matches whatever its port.
    • Only the port is free, and only on loopback. Every other near miss is still refused, as the tests list them.

Public MCP tools: roles and gates, hooks around every call, a missing scope named (FW-158)

Points 1 and 2 of FW-158. The others follow in their own entries.

Added

  • Who sees a public tool, beside its scope. An application where the role decides had to rebuild the endpoint's server. Now there are two ways, and every one of them has to allow a tool:
    • ScopedTool::wrap($tool, $scope, minRole: Roles::ROOT);
    • PublicRegistry::gate(callable(?object $user, ScopedMcpTool $tool): bool).
  • PublicRegistry::around() runs hooks around every public tool call.
    • PublicRegistry::audited() writes an mcp.tool_call audit row: user, tool, client, and parameter names, never values.
    • PublicRegistry::redacting() passes every string of a result through the analyst's Redactor.
    • Applications no longer wrap each tool themselves.

Changed

  • A call to a tool whose scope the token lacks names the scope. The answer is 403, with WWW-Authenticate: Bearer error="insufficient_scope", scope="…", and the JSON-RPC error carries the scope in data.
    • It used to be "Unknown tool", which told an assistant nothing it could act on.
    • A tool the user may not reach at all still answers as no tool, so its existence is not disclosed.

What breaks

A client or test that read "Unknown tool" for a missing scope reads the scope from the error instead. The Upgrade Guide has the row.

Added

  • 'mcp' => ['scopes' => [...]] declares the MCP scopes in app.php, so an application that holds none of the tools knows them too.
    • The fix is for authorization and MCP endpoint running as two applications. Consent there refused mcp:diagnostics as unknown, and the endpoint's metadata listed only mcp while its 401 asked for five.
    • Name the framework's scopes, or give your own with a description, such as 'mcp:people' => 'Find…'. No Scopes provider is needed.
    • Each declared scope inherits mcp.

oauth.scope_refusal => 'drop': scopes a role may not grant left out, not refusing (FW-158 §4)

Added

  • 'oauth' => ['scope_refusal' => 'drop'] leaves out the scopes an account may not grant, and the request goes on with the rest (RFC 6749 §3.3).
    • The consent page asks for the rest, and the code carries those.
    • A refresh after a role was taken away narrows the token instead of ending the connection.
    • A request of nothing but such scopes is still refused.
    • The default stays refuse. Before this, an assistant asking for every listed scope could not connect at all for an administrator below the role of any one of them.

Changed

  • The bundled consent views have a ticked box per requested scope. What stays ticked is what the code, the token and the stored consent carry. Consent had been all-or-nothing.
    • openid and mcp, or what oauth.consent_required names, have no box and are always granted.
    • Granting nothing, with nothing required, is a refusal.
    • A consent view an application published earlier sends no scope_choice, and grants everything asked for, as before.
    • The scope list on the page is open by default, so the boxes are seen without a click.

audited() names the account and the outcome; redacting() spares user (FW-162)

Added

  • PublicRegistry::audited() records the account a call was about, and whether it succeeded. It writes the row once the call is over, whichever way it ended.
    • The account defaults to the input's userid, or its id, so an audit screen's "who looked at this person" finds MCP calls.
    • audited($event, $target) names another event or decides the account.
  • Redactor::SELF_REDACTING lists the tools that redact themselves (user). The analyst and redacting() both read it.

Fixed

  • Over MCP, redacting() masked the one email user had been asked for. The analyst had spared that tool all along, so Ask and MCP gave different answers to the same question.

database.translate_mysql: MySQL-written SQL run on PostgreSQL (FW-160, phase 1)

Added

  • 'database' => ['translate_mysql' => true] translates SQL written for MySQL on its way to PostgreSQL, through Pramnos\Database\Dialect\MysqlToPostgres.
    • It applies in query(), execute(), prepare() and prepareQuery(), and to the query builder's raw fragments, so the admin datatables' hand-written conditions are covered.
    • Translated: backticks, IFNULL, IF, ISNULL, LCASE/UCASE, RAND(), LIMIT a, b, UNIX_TIMESTAMP, FROM_UNIXTIME, CURDATE/CURTIME, DATE_FORMAT, DATE_ADD/DATE_SUB, GROUP_CONCAT, INSERT IGNORE, ON DUPLICATE KEY UPDATE and REPLACE INTO.
    • An upsert's conflict target is the table's one unique key among the inserted columns. With two, or none, it is refused with DialectException rather than guessed.
    • Only constructs invalid in PostgreSQL are touched, string literals and comments never, so a statement translates the same twice and PostgreSQL-written SQL passes untouched.
    • What would change meaning silently (a case-blind LIKE, a loose GROUP BY) is left for the dialect linter, phase 2.

Off by default. It is a bridge for an application moving from MySQL.

PRAMNOS_TEST_SETTINGS: an application's suite against PostgreSQL (FW-160, phase 3)

Added

  • PRAMNOS_TEST_SETTINGS names the settings file a test run uses in place of the bootstrap's, through TestEnvironment::settingsPath().
    • A relative path is read from the project's root.
    • A name that is not a file stops the run, rather than letting it fall back to the database it was meant to avoid.

Documentation

  • The Testing Guide shows how to run an application's MySQL suite against PostgreSQL before the move: a profile-gated PostgreSQL service, a settings file with translate_mysql, the run, and how to read what fails only there.

db:dialect-lint: SQL that would mean something else on PostgreSQL (FW-160, phase 2)

Added

  • db:dialect-lint [paths] [--json] names, by file and line, the SQL the translator leaves alone because it would run on PostgreSQL and mean something else.
    • like: case ignored on MySQL, not on PostgreSQL.
    • group-by: a column neither grouped nor aggregated.
    • double-quoted-string: "…" used as a value.
  • It reads the code's string literals, statements and datatable fragments alike, through Pramnos\Database\Dialect\DialectLinter.
    • Markup and an operator's name alone are not taken for SQL.
    • // dialect-ok marks a line that has been looked at.
    • It exits 1 while anything is found.

AuthorizedApplications: one place to list and revoke what a person authorized (FW-163)

Added

  • Pramnos\Auth\AuthorizedApplications, with forUser(), byClientId() and revoke($userId, $appId, ?$byUserId). The account screen and an administrator acting for a person now do the same thing. byUserId lands as revoked_by in the activity record.
  • Account::applicationsUrl(): where revoking returns to, for an area that serves the screen elsewhere.

Fixed

  • A consent with no live token was invisible, and could not be withdrawn. It still approved the next authorization without asking. The list now includes it, with consented_at and scope. last_used is the latest of last use and creation.
  • A deactivated application could not be revoked ("Application not found"), while it stayed listed and its refresh tokens stayed live. It is now found and revoked.
  • EndSession revoked with status 0, the account screen with 3. Both write 3 with removedate.
  • The revocation messages were not translatable. They go through t().

A test that left organisations in the shared test database

Fixed

  • TaskHandoverTest failed now and then in a full run. RoleAssignmentTest and PermissionResolverOrganizationTest wrote organisations 3, 5 and 99 into the shared test database and never removed them. A later test relying on an installation that keeps no organisations then found organisation 3, depending on the order the suite ran in. Both now remove what they wrote. This was the intermittent failure of a full run seen two days running.

A refused query says why, to a developer (FW-165 §4)

Changed

  • QueryException::getMessage() carries the driver's text on a developer's machine and in an application's test run. A failing test used to read «The database refused the query» and nothing more.
    • "A developer's machine" means APP_DEBUG or DEVELOPMENT. TestEnvironment turns it on for an application's run.
    • QueryException::$detailed overrides either way.
    • Production names nothing, as before. getDetail() says everything once, either way.

Fixed

  • A datatable whose query PostgreSQL refused said only «query returned no result». Its message now carries the driver's reason beside the SQL it already had.

A statement that failed inside a transaction no longer breaks the next one (FW-165 §1)

Fixed

  • On PostgreSQL, one failed statement inside a transaction broke that statement for good. The sequence:
    1. The failure aborted the transaction.
    2. The aborted transaction refused the DEALLOCATE of the statement's plan.
    3. The connection forgot the plan while the server kept it.
    4. In a later transaction, preparing the same SQL collided with it: «prepared statement already exists». That error aborted the later transaction too, so the recovery that follows could not run.

One application counted 1,034 such errors, from a user save after a duplicate e-mail. - Now: - A plan whose DEALLOCATE the transaction refuses stays known, and is reused. - Inside a transaction, a plan is looked for on the server before it is prepared, so no collision is raised. - A plan found there under the SQL's hash is used.

Wrong second-factor codes are limited (FW-164 §1)

Fixed

  • A pending login took any number of wrong second-factor codes. A password was enough to guess a six-digit code for as long as the challenge lived.
    • Each wrong code now counts against the pending login, and against the account's lockout like a wrong password.
    • After auth.second_factor_attempts codes (default 5) the pending login is ended, and the next try starts from the password.
    • A lockout the failures set off is answered as LOCKED.
    • This covers TOTP, backup codes, mailed codes and every registered factor. A factor whose gateway is down refuses without counting.

second_factor_everywhere: the second factor owed by the whole session (FW-164)

Added

  • 'auth' => ['security' => ['second_factor_everywhere' => true]], off by default.
    • A session the login flow did not finish, for an account with a second factor, is not counted as signed in. It becomes a pending login, and the login page shows the second step.
    • The check is in Session::staticIsLogged(), so it holds for pages, controllers, /oauth/authorize and the API's session path alike.
  • Pramnos\Auth\SecondFactorGate and LoginFlow::demandSecondFactor() implement it.
    • A finished login settles the session.
    • An account with no factor, or a trusted browser, is settled.
    • Impersonation settles the account it becomes.
    • A login already waiting keeps its wrong-code count.

Before this, where one session served a site, an administration and an API, a password alone signed in through a door that did not ask for the factor.

The lockout fails open, the two-factor reset forgets trusted browsers, sign-in devices translate (FW-164 §2–§4)

Fixed

  • A login lockout whose table could not be reached failed every sign-in. It now goes on without the lockout, logging the problem once per request. auth.security.lockout_fails_closed refuses instead, for an application that would rather close.
  • auth:twofactor-reset left the account's trusted browsers trusted. One would go on skipping the factor enrolled next. The command also:
    • writes auth.twofactor_reset to the audit log, with the administrator given by --by;
    • promised a mailed code even where the application has no email factor. It now says what the next sign-in asks for.
  • SignInFingerprint::describe() was English in every language. It goes through t(), so «Chrome on Windows» in a security mail can read as the language reads.

Bytes on PostgreSQL: Binary, bytea read as bytes, text kept in a blob named (FW-165 §5)

Added

  • Pramnos\Database\Binary binds bytes as bytes. On PostgreSQL that goes through pg_escape_bytea(), which a bound string could not do: bytes that are not UTF-8 were refused, and a backslash was read as an escape. One application's single binary INSERT failed 1,343 tests.
  • The dry run of db:migrate-from-mysql names a blob that holds text, and says whether it is JSON too, so the migration can make it text or jsonb.
  • --blob-as-text=table.column makes such a column text in a table the command creates.

Changed

  • A bytea is read back as the bytes, through query(), execute() and the builder alike. It used to be PostgreSQL's hex text, where MySQL gave the bytes. The PostgreSQL value conversion, which had four copies, is now one (Result::fromPostgres()).

What breaks

Code that decoded the hex of a bytea itself drops that decoding. The Upgrade Guide has the row.

db:migrate-from-mysql --implicit-defaults (FW-165 §6)

Added

  • The dry run reads the MySQL server's sql_mode. Out of strict mode, it counts the target's NOT NULL columns with no default on either side. MySQL fills an insert that leaves one of them out, and PostgreSQL refuses it. One application had 1,809 of them. Keys are not counted.
  • --implicit-defaults gives those columns MySQL's default: '', 0, false, an empty bytea. Dates are named rather than defaulted, because MySQL's zero date has no PostgreSQL value.

The crop dialog's tools are icons

Changed

  • Rotate left, rotate right, flip and reset in pf-image.js's crop dialog are icons. Each button's words are its title, shown on hover, and its aria-label, so a screen reader still names it. The inline SVG is drawn in currentColor and follows the theme. Cancel and "Use this picture" stay as words. Each theme's components.css styles .pf-image-icon.

Added

  • document.createElementNS() in pramnos-testing's DOM, so a page script that draws SVG can be tested. The element keeps its name's case and its namespaceURI.

The second-factor gate runs the application's login flow, counts against the lockout, returns to the page (FW-166)

Fixed

  • The gate ran the framework's LoginFlow, not the application's. A subclass with its own trusted devices decided what the login form asked, but not what a session owed. Both now make the flow with LoginFlow::make(), which reads 'auth' => ['login_flow' => …].
  • A session the gate stopped was not counted against the lockout. Its pending login carried no identifier, so its wrong codes locked nothing, and an account already locked was handed a code form. The pending login now carries the account's username as the form would, and a locked account gets none until the lockout lifts.

Added

  • auth.security.second_factor_url: a page opened in a browser by a stopped session is sent to the second step, and the account screens return to that page once it is done (SecondFactorGate::RETURN). A form post, an XHR or an API call is refused, not redirected.

db:migrate-from-mysql: bigint unsigned becomes bigint (FW-165 §2)

Fixed

  • A table made by the migration gets bigint for a bigint unsigned, not numeric(20,0). A numeric can be neither an identity column nor the target of a foreign key, so the conversion stopped. One application had 15 such tables.

Added

  • The dry run counts the values a bigint cannot hold, per column, over the whole table: rows above 9223372036854775807, each named with what to do.

What breaks

A column that holds such values must be made numeric(20,0) in a migration. The Upgrade Guide has the row.

db:migrate-from-mysql: long index and key names stay distinct (FW-165 §3)

Fixed

  • A foreign key whose name was cut to PostgreSQL's 63 bytes could be lost. Two names that shared their first 63 bytes became one. The second key read as "already exists" and was never made, and the run said nothing. One application lost 6 keys this way. A name longer than 63 bytes is now cut to 54 and ends in a hash of the whole (Migrator::identifier()), and the cut never splits a character.

translate_mysql: an upsert added to after it was translated (FW-165 §7)

Fixed

  • , n = n + 1 appended to an upsert that prepareQuery() had already translated failed with "column reference is ambiguous". The statement was by then ON CONFLICT … DO UPDATE SET, and nothing in it looked like MySQL any more, so the second pass left it. In one application this broke every request with a signed-in user. A statement already in that form now has its bare self-references qualified with the table's name, quoted ones ("n" = "n" + 1) included, as an ON DUPLICATE KEY UPDATE always had.

translate_mysql: SHA2, CAST AS SIGNED, DAY/MONTH/YEAR, named locks, JSON_EXTRACT, DELETE through a join, an alias in HAVING, FOREIGN_KEY_CHECKS (FW-165 §8)

Added

These are the constructs one application's suite measured as missing. Each is now translated:

  • SHA2(x, n) → encode(shaN(…), 'hex');
  • CAST(x AS SIGNED|UNSIGNED) → CAST(x AS bigint);
  • DAY(), MONTH(), YEAR() → EXTRACT(…)::int;
  • GET_LOCK / RELEASE_LOCK → advisory locks on hashtext(name), without waiting;
  • JSON_EXTRACT with one literal path → #>, and JSON_UNQUOTE → #>> '{}';
  • DELETE a FROM t a JOIN … → DELETE … USING … WHERE;
  • a select-list alias named in HAVING → its expression, in subqueries too;
  • SET FOREIGN_KEY_CHECKS → session_replication_role.

An integration test runs each of them on PostgreSQL and checks MySQL's answer. The Database API Guide lists each one's limits.

db:dialect-lint: SQL built over several lines, prose left alone, ORDER BY, --schema (FW-165 §9)

Fixed

  • SQL built over several lines is read as one statement. That covers a string with variables in it, strings joined with ., and a variable built up with .=. The linter read each piece alone, so a GROUP BY was never in the same piece as its SELECT. On one application it found 1 of the 13 statements its PostgreSQL suite failed on, and it now names 24 columns across them.
  • Prose is not SQL. A prompt that says like "daily" was read as a LIKE and a double-quoted string. Text made of sentences is no longer judged.

Added

  • group-by covers ORDER BY too: a grouped SELECT ordered by a column it does not group, and a SELECT DISTINCT ordered by a column it does not select. A grouped key lets its table's other columns through, as in PostgreSQL.
  • --schema reads column types and primary keys from the application's database (DialectLinter::schema()). With it, a new implicit-cast rule names a text column compared with a number, and keys are judged exactly.

Binary in prepareQuery()

Added

  • prepareQuery() takes new Binary($bytes) for a %s. Until now only execute() and the query builder did. On PostgreSQL the bytes are written as a bytea's hex form, on MySQL with its escape. On one application's PostgreSQL suite, 2,502 of 2,670 errors were one prepare() of hex2bin() and inet_pton() values that PostgreSQL refused as text. Wrapping the values in Binary is now the whole fix.

Slow dashboard cards: shared figures and dashboard:precompute (FW-167)

Added

  • A card can be shared (Widget::shared()). Its render() is split into data(), the figures, and present(array), which draws them for the person looking. The dashboard keeps the data in the cache's dashboard category under dataKey() (Dashboard\WidgetData), and people whose cards differ only in title or chart share an entry. Each card handed to a view carries at, when its figures were worked out.
  • dashboard:precompute [--area=admin] works out every shared card with its default settings and every setting saved in the area's layouts. One cron line, and nobody waits for a card. Entries are kept for 'dashboard' => ['data_ttl' => …], 300 s by default.

Changed

  • The framework's slow cards are shared: logs.errors, ai.spend, messages, users.active, database, queue and api.performance. On one application logs.errors took 1.1–1.4 s on every dashboard load, reading every log. All the other cards together took about 60 ms. Its figures, like the others', can now be up to data_ttl old. The Upgrade Guide has the row.
  • Widget::render() is no longer abstract. A card that defines it is unaffected.

What's new: help pages announced to the administrators (FW-168)

Added

  • whatsnew: in a help page's front matter announces it (Pramnos\Assist\WhatsNew). A window over the administration shows its lines until each person closes it. It opens without a script: the newest three in full, each with More, and the rest counted.
  • Close and More are a POST with the session's token to /admin/Help/whatsnew (HelpController::whatsnew()). It marks everything announced as seen in the person's whatsnew.seen setting, and goes back to the page, a path of this site only, or to the post.
  • Somebody who has never had it starts with everything dated before today seen, so the day's announcement still reaches them but not the history.
  • A header button shows while something was announced in the last fourteen days, and opens the window again.
  • Wiring: themes written by init carry both hooks, and each theme's components.css styles pf-whatsnew. Settings: 'help' => ['whatsnew' => ['shown', 'recent_days', 'min_usertype']].
  • Help pages read date: where they have no updated:, as a changelog post does, so the dashboard's help.news card orders them by it too.

The second-factor gate: trusted browsers, a stopped session that stays stopped, a lockout said (FW-169)

Fixed

  • A browser the account trusts owes no second factor at the gate either. LoginFlow::demandSecondFactor() asked only whether the account had a factor. A remember-me login in a browser trusted with "Don't ask again on this device" was therefore asked again, where the form would have let it through.
  • A stopped session was a guest from its second request on. The gate signed it out and redirected the first page only. The next page saw nobody signed in and was drawn for a guest. The session now keeps what it owes (SecondFactorGate::OWED). Session::staticIsLogged() sends every page to the step again while the login waits or the account is locked out, at most fifteen minutes, and finishing the login clears it.
  • The step could not say that the account was locked. When the gate meets a lockout, SecondFactorGate::lockedFor() gives the seconds left, and the login page shows it as locked rather than as expired.

dashboard:precompute: the application's cards, a controller, variants nobody saved (FW-170)

Fixed

  • The command worked out the console's application's cards. It ran as the console's application, so on a console that boots the site it found only the framework's 7 cards, not the administration's. It now uses --app=<name>, or the area's own application where app/<area>.php exists, and puts the console's back afterwards.
  • A card's data() had no controller while precomputing. Each card is now handed one of the application's, as the dashboard hands its own.

Added

  • Widget::precomputeSettings(array $settings): array. A card spreads one setting into the variants its data has but nobody saves, such as one per market looked at lately. By default it returns the setting alone. savedLayouts($area) is documented as the application's to extend.

db:migrate-from-mysql: binary and bit defaults carry over (FW-172)

Fixed

  • A binary column's default was dropped, and so was a bit one. A binary(8) NOT NULL DEFAULT '' became bytea NOT NULL with no default, and an insert that left the column out was refused where MySQL had filled it with eight zero bytes. On one application that was the INSERT every request makes, about 2,500 tests. The default is now the bytes MySQL stores: MySQL 8 reports them as 0x…, MariaDB as an escaped literal, and a binary(n) is padded to its length as MySQL pads on insert. They become '\x…'::bytea. A bit(1) default becomes true or false, including MariaDB's bare b'1', which was read as an expression.

The page cache: a flush that clears only the pages, the shared Redis, pages kept gzipped (FW-171)

Fixed

  • Flushing the page cache emptied the whole Redis database. The adapter was built without the page cache's prefix, so flush() and pagecache:purge --all reached RedisAdapter::clear(''), which is FLUSHDB. That took the application's cache, its rate limiters and everything else with it. A file store emptied the whole cache folder. The adapter now carries the prefix, and a test on a real Redis keeps a key beside the pages through a flush.
  • Redis was localhost unless the cache settings said otherwise. On a Docker network every request missed and logged an error. The server is now the page cache's own redis, then the cache hostname, then the shared connection, as Cache finds it.

Added

  • 'compressAtRest' => true keeps only the gzipped body and unzips it for a client without gzip. On one application's production that is 11,124 pages in about 5 GB, at about a sixth of the size.
  • pagecache:purge --config=<file> reads the configuration from a file of the application's own.

What breaks

A file store's pages move into a subfolder named after the prefix, so those stored before are not read again or purged. The Upgrade Guide has the row.

A second step owed before the application is up; the precomputed cards' controller (FW-173)

Fixed

  • A pending second step was forgotten when asked about too early. A front controller asks Session::isLogged() before it creates the application, to decide on its page cache. Without an application second_factor_everywhere reads as off, so SecondFactorGate::stillOwed() cleared what the session owed. The page was then served as to a guest, or from the page cache. Without an application the session now counts as owed and nothing is cleared.
  • dashboard:precompute gave the cards a plain controller, whose getModel() did not find an administration area's models. It now uses the application's dashboard controller: 'dashboard' => ['controller' => …], or Controllers\Dashboard in its namespace.

A message's body read from the file an application keeps it in

Added

  • Pramnos\Email\Body::of($row) reads a mails row's body from content, or from the gzipped file its path names relative to the application's root, which is how applications have kept them for years. The history screen showed those messages as "No body content."; it, its report, the outbox worker and the debug panel's sign-in code now read the body. Messaging\Mail::body() does the same. A path is read only from inside the root and only as a .gz or .html file.

Documentation

  • The mail body storage design records what was measured on production, what was decided and the phases. This is the first of them.

Added

  • MailAction::tokenUntil($action, $claim, $expires), and Tracking::linkSentAt() and wrapLinksSentAt(). A click token's expiry is counted from the moment the message was sent as, so the same message, link and moment give the same link. A stored body can then be given its links back exactly as they were sent (mail body storage, phase 2). These are new methods because MailAction and Tracking are not final: the existing ones keep their signatures and count from now.
  • Email sends as one moment. send() takes it before composing and records it as the row's date. The outbox worker hands a queued message its row's date, so the links of a queued message count from when it was queued.

The debug toolbar's timers measured with the monotonic clock

Fixed

  • A named timer could measure a negative length. TimeCollector took a timer's length from the wall clock, which a virtual machine corrects in steps. A step back during a 5 ms timer measured −553 ms, and a test failed on it. A timer's length is now the monotonic clock's (hrtime), and its place on the timeline is still the wall clock's.

A sent message's body in the body store, read back as it was sent

Added

  • Pramnos\Email\BodyStore keeps a sent message's body where 'email' => ['bodies' => ['store' => …]] says. With auto, the default, that is the mail_bodies hypertable on TimescaleDB (a new migration, compressed after 7 days), and files under var/emails elsewhere, or on a storage disk. content keeps it in the row as before.
  • The body is kept before what differs per recipient: no tracking, and the framework's unsubscribe link as a placeholder. A newsletter is one body. The Gmail actions' structured data is kept with it, and hash is its md5.
  • Body::of() gives it back as it was sent, to the byte. The test compares it with the HTML that reached the transport, through a file, a disk, the database, and a compressed chunk.
  • A store that cannot be written keeps the body in the row.

Fixed

  • The kept body is now the one that was sent. The framework kept a body without its tracking or its structured data, which are added as the message is sent. A message's report therefore never found the pixel or the Gmail actions of a real send. It now reads them back.

What breaks

mails.content is empty for a message sent from now on. The Upgrade Guide has the row.

mail:bodies-to-database: bodies from files into the compressed table

Added

  • mail:bodies-to-database moves the bodies of sent messages kept as files into mail_bodies, for an installation that has TimescaleDB now: an application's .html.gz, the store's .body.gz, files on a storage disk, and with --include-content those in the row.
  • Each row reads exactly as before: it points at mail_bodies (path = db:).
  • It runs in batches and survives being stopped: a transaction per batch (--batch, --limit), and a run cut short is finished by the next.
  • --dry-run counts what there is to move, and a file that is gone is counted and its row left. The files are not deleted, because many rows share one.
  • Measured on one application's real bodies, the table holds them in about a seventeenth of their size, against roughly 1.2× for one gzipped file each once the 4 KB blocks are counted.

A templated message: kept once, filled for each recipient

Added

  • Email::setBodyTemplate($body, $values, $user) hands over the template and the values rather than the filled body. The message is wrapped as the template and filled afterwards, so the body kept is the wrapped template, once for every recipient of it. The values are kept in the new mails.bodyvars column, and reading fills it again, to the byte.
  • Pramnos\Email\TemplateValues supplies the account's values: names, username, email, id, the site and its sign-in page, escaped because they are the account's text. An application adds its own with TemplateValueProvider ('email' => ['template_values' => …]), and those win.
  • A migration adds bodyvars, nullable and at the end, which MySQL 8 and MariaDB add instantly. Without it, a templated message is kept filled.

Bounces and blocked addresses: no mail goes to them, a password reset included

Added

  • Pramnos\Email\Bounces records bounces and blocks, and says why an address is refused: blocked, hard-bounced once, soft-bounced 15 times or within the last day. Each threshold is set under 'email' => ['bounces' => …].
  • send(), queue() and mail:flush refuse such an address, writing the mails row with the reason. A message to several addresses leaves out the refused ones.
  • A 5.1.x refusal at send time is recorded as a hard bounce.
  • mail:bounces reads delivery reports (RFC 3464) from a folder of .eml files or from standard input, so it can be a mail server's delivery target. It also blocks, clears and looks up an address.
  • The bounces migration uses the layout applications already keep. An existing table keeps its rows and gains blocked and reason.
  • A message's page in Email History says whether mail goes to its recipient, with Stop all mail to it and Send to it again.

Fixed

  • The bootstrap and plain-css email pages read an undefined $sentAt for the date. The plain-css page's container also had a broken style attribute.

What changes for an application

  • Mail is now refused to addresses already in bounces. An application's own table with recorded bounces refuses mail sent through \Pramnos\Email\Email once the migration has run. See the Upgrade Guide row for the thresholds that keep the old behaviour.

Helpers::getBrowser() parses an agent once a day, not once a call

Fixed

  • The page cost of getBrowser() with matomo/device-detector: it parsed the agent on every call. That meant reading the package's regex files each time: about 60 ms for a desktop Chrome and 320 ms for a phone or a Mac, on every page a signed-in visitor opened.
  • The answer is now kept by agent, in the process and for a day in the cache's browsers category. A repeated agent costs nothing, measured at 735 ms for the first call and 0 ms after, in a new process too. A cache that cannot be reached means parsing again.

device-detector's rules kept in the application's cache

Added

  • Pramnos\General\DeviceDetectorCache implements device-detector's CacheInterface over Pramnos\Cache\Cache, in the device-detector category, so Redis where it is configured. getBrowser() hands it to every parse.
  • What it saves: reading the rules from YAML was nearly all of a parse, mobiles.yml alone about 230 ms for every phone, Mac or unknown agent. A per-agent answer alone still left a phone's first page at that cost, because phone agents vary by model, OS version and browser version.
  • Measured: a phone agent never seen before, in a new process, takes 47 ms where it took 726 ms.
  • Updates are safe: the package's version is in each key, so composer update reads the new rules. A store that is down means reading YAML again.

Erasure and retention reach the body store

Added

  • Pramnos\Email\MailPersonalData::erase() pseudonymises every message to an address: no address, no name, the subject [erased], no body, no outbox state.
  • It matches however the address was stored, PostgreSQL included.
  • The row stays, so the log still says a message of this module went out then.
  • Tracking rows keep their counts and lose the address.
  • AccountErasure::erase() calls it with the account's address.
  • MailPersonalData::export() is the right-of-access half: every message to an address, with its body as it was sent. An account's data export carries it as its mail section, labelled Mail sent to you.
  • BodyStore::discard() removes the kept bodies of a set of mails rows: their mail_bodies rows, their files, their files on a disk. A file another row still names is left.
  • mail_bodies and emailtracking are now declared personal data, so db-inspect withholds their rows.

Fixed

  • mail:prune with the body store left bodies behind. Stripping emptied content and left the body in the store; pruning deleted rows and left their files and mail_bodies rows behind. Both now remove the body from wherever it is kept.
  • PostgreSQL now sweeps in batches too. Both commands work in batches by id through the query builder, where PostgreSQL ran one statement over the whole range.
  • pruneRecipients() goes through the query builder too.

What changes for an application

  • An erased account's mail rows are kept pseudonymised, and stripped rows lose their path. See the Upgrade Guide row.

WebhookHandler::onBranchStaged(): composer runs beside the live tree (FW-174)

Added

  • onBranchStaged($branch, $after, $stage, $composer) maps a branch to a staged deploy:
  • export the branch to a stage directory beside the checkout;
  • copy the current vendor/ into it;
  • run composer install there;
  • go live in one command, git reset --hard and vendor/ replaced by a rename;
  • clean up and run the application's commands.
  • Why: a reset followed by composer install rewrites vendor/ under live requests. Every deploy that changed composer.lock served seconds of fatals.
  • Measured by the application that asked: composer took 3.6 s outside the live tree, and the live change 0.73 s, almost all of it the reset.
  • A composer that fails leaves the live tree untouched.
  • A vendor.old a failed deploy left behind is removed first, so the rename cannot move vendor/ into it.

mail:hypertable: the mail log as a TimescaleDB hypertable, with the site up

Added

  • mail:hypertable, with Pramnos\Email\MailHypertable underneath, makes mails a hypertable on TimescaleDB: monthly chunks, compressed after ninety days.
  • The conversion only renames and creates, in 23 ms: mails becomes mails_legacy, and a new mails is made like it.
  • date is widened to bigint.
  • The primary key is (id, date).
  • The status index becomes (status, date).
  • The id sequence passes to the new table.
  • The old rows move newest first, each batch in one statement and resumable; 60,000 rows/s measured.
  • date stays a Unix timestamp, so no reader or writer changes.
  • Erasure, mail:prune and a person's copy read both tables while the rows move.

Fixed

  • Database::getInsertId() on PostgreSQL returned the wrong id after an insert that opened a chunk. It was LASTVAL(), and TimescaleDB takes a sequence value of its own when it opens a chunk, so the insert got the chunk's number. It now reads the table's own sequence. The lookup cannot fail inside a transaction.

mail:bounces --imap: the bounce mailbox over IMAP, with a password or XOAUTH2

Added

  • Pramnos\Email\ImapClient: just enough IMAP to read a mailbox, without the imap extension PHP no longer ships.
  • It connects with TLS, STARTTLS or neither.
  • It signs in with a password, or with an OAuth2 access token over XOAUTH2.
  • It lists the unseen messages, or those read before a day, and reads one without marking it.
  • It marks messages seen, deletes and expunges.
  • It comes from an application that had written it for a press inbox. That application can now use the framework's copy, with the same methods.
  • Pramnos\Email\BounceMailbox and mail:bounces --imap read the bounce mailbox. Each delivery report is recorded and marked seen; anything else stays unread.
  • The mailbox is configured under email.bounces.mailbox, or else in the imap_* settings an application already keeps.
  • With a refresh token, the access token is fetched and XOAUTH2 used, so Gmail needs no Gmail API.

The Roadmap's mail-body entry removed: the body store shipped

Documentation

  • The "Where a sent message's body is stored" entry is gone from the Roadmap. All seven phases of the design shipped today. How the store works is in the Email guide, and the design page keeps the decisions.

The change log: events without a model, on behalf of somebody, and a page at a time

Added

  • ChangelogWriter::event($entity, $itemid, $event, …, by:, scope:, at:, source:) records an event on any record without a model: a user, an organisation, an imported row.
  • Model::logEventAs($userid, …) records an event on behalf of somebody other than whoever is signed in. A console command or an import acting for a person can now say who.
  • ChangelogReader::listing($filters, $limit, $offset, $order) gives a page and the total for a listing screen. It filters by record, event, logtype, user, source, time window and tenant, and refuses a filter it does not know.
  • An application's own log can be moved in. at:, by: and source: let a command of the application's own bring its userlog or itemlog over: each row at its own moment, by whoever acted. There is no framework import, because before this there was no standard log to import from. The Change Feed guide shows the shape of such a command.
  • 'retention' => null written in an app.php hypertables override removes the retention policy an earlier timescale:ensure installed. This is a behaviour change, with an Upgrade Guide row.

Fixed

  • logEvent() wrote created_at with date('c'). MariaDB drops the offset, and in strict mode refuses the row. Events now go through Instant::local().

Why now

  • These are the three items one application's plan for moving its changelog onto the framework filed against it: a listing reader, attribution, and the retention trap. They also let a second application move its userlog and itemlog over with a command of its own.

Helpers::objectDiff(): a null property is not a removed one

Fixed

  • objectDiff() checked whether a property exists with isset(). isset() is false for a property that is there and null, so a property null after the comparison was reported as removed, and one null before as added.
  • One application logs the removed half on every user save, as a "data removal" entry. It had almost as many of those as of real changes: 78,861 against 79,691 in its development database. Each listed fields nobody touched, all null.
  • A field cleared to null was filed as removed, losing what it had been changed from.
  • Now array_key_exists() decides. A value that became null is a change, with its original. Null on both sides is nothing. removed and added hold only properties one object does not have.
  • This is a behaviour change, with an Upgrade Guide row.

Writing history into compressed hypertables: the policy paused for a move, the queue for spooled late rows

Measured

  • TimescaleDB 2.26 takes writes into a compressed chunk, but slowly. A single insert took 3.18 ms against 0.52 ms into an uncompressed chunk. 50,000 rows in bulk took 129 s, against 1.3 s to decompress the chunk, insert them and compress once.
  • Updating or deleting a few rows there is cheap: 5–7 ms. Erasure and retention are not affected.

Added

  • SchemaBuilder::withCompressionPaused($table, $callback) unschedules the table's compression policy for a bulk write. It reschedules it however the write ends, and then runs it once so that what aged past it is compressed straight away. Rows are readable as they are written.

Fixed

  • mail:bodies-to-database and mail:hypertable raced the compression policy. A long move could have a chunk compressed while it was still filling it, and pay the slow price for the rest of that chunk. Both now run inside withCompressionPaused(). The Email guide says to move the bodies before converting mails, because moving a body updates its mails row.
  • deferred_writes had no effect on spooled rows. The change log tables declared it, but the spool inserted every row directly, so an event an import dates in the past went straight into its compressed chunk. The spool's drain now sends a row dated before the cutoff to the deferred-write queue, which timescale:drain writes hourly, a chunk at a time. This is a behaviour change, with an Upgrade Guide row.

The notification bell in every header and in the SPA, settling what is dealt with, and keeping the feed tidy

Added

  • The scaffolded header draws the bell. NotificationBell::headerButton() is drawn for whoever is signed in, in all three UI systems. One header serves the site and the administration, so the bell is in both. It draws nothing where there is no notifications table.
  • Notification\FeedApi::routes($r) serves the feed to a SPA in the API's envelope, with a bearer token or the page's session. init mounts it with the auth feature.
  • NotificationBell.svelte in every Svelte SPA, drawn by the shell for whoever is signed in.
  • It is built from the bell an application had written for itself, and uses the same pf-notify-* classes as the server-rendered one.
  • It reloads when opened and when the tab is brought back, every interval seconds while visible, and on pf:notifications:refresh.
  • It has a Vitest file.
  • Feed::settleAbout($keys, $userId) marks read the notifications whose about names something now dealt with: everybody's, or one person's. It also comes from that application.
  • notifications:prune, daily, removes notifications read more than keep_read_days ago and never removes an unread one. Without a number it does nothing. Feed::prune() does the same from code.

A record's history, in the administration, on the site and in the SPA

Added

  • Changelog\History::page($filters, $page, $perPage) gives a record's history the way people read it. Each line has who did it (a name, or nobody for the system), what happened, as a rendered sentence, and when, plus a save's changed fields. It is built on ChangelogReader::listing(), and generalises what one application had written for its team's activity.
  • History::allows() decides who may read it.
  • By default: whoever may open the administration, and anybody reading their own account's history.
  • History::allowUsing() replaces the rule.
  • Html\HistoryPanel::render($entity, $id, $options) draws it in a server-rendered page, in the administration or on the site. Older pages are links, and no script is needed.
  • Changelog\HistoryApi::routes($r) serves it to a SPA. init mounts it with the changelog feature.
  • HistoryPanel.svelte in every Svelte SPA draws it there, with Older appending, and comes with its Vitest file.
  • The pf-history-* classes are in the three themes' stylesheets, shared by both panels.

The Roadmap's remember-me entry brought up to date

Documentation

  • The entry said a built-in remember-me was still to come. It shipped with the token-per-sign-in work: Auth\RememberTokens, and Auth::rememberLogin() behind authCheck().
  • What is left: new scaffolds still register the UserDatabase addon for it, and the addon-free path still has to be verified in a real application.

The auth screens in Greek, and whose server it is on every card

Added

  • The framework translates its own screens. resources/language/<code>.php holds the phrases of the sign-in, second-factor, registration, consent, device-code, account and status screens, and the messages the auth controllers flash. Language::load() merges it under the application's catalogue, found by LangShort or by the language's name (greek → el), so a phrase the application words itself keeps its wording. Greek ships: 482 phrases.
  • Every visible phrase of those views goes through t(), in all three themes, and the Account and TwoFactorAuth flash messages with them. OAuth error responses stay English: programs read them.
  • Auth\Brand::current() and the auth_brand partial: the logo, or the name, at the top of every card screen — sign-in, second factor, approve, messages, the password screens, registration, the consent screen and the device screens — from the auth_brand_* settings, else sitename. Somebody who arrives from a link sees whose server is asking before it asks.
  • auth_home_link: the public screens before sign-in carry a link back to the site.
  • FrameworkGreekCatalogueTest fails when a screen gains a phrase with no Greek line, or a line whose placeholders differ from its key's.

Fixed

  • The plain-CSS privacy settings page left the marketing checkbox's <label> and <div> open and drew the analytics block twice.
  • A registration message split over two literals could never be translated: the key looked up was its first half.

/Status: a public status page

Added

  • Application\Controllers\Status extends Health with a page anybody may open: the overall verdict in words and each check's verdict by name, run as the page loads. It never shows a check's message or details, which describe the host; administrators read those on /health.
  • init scaffolds src/Controllers/Status.php beside the health wiring, and each theme ships status/status.html.php.

New scaffolds register no auth addon: remember-me works without one

Changed

  • init no longer registers the UserDatabase addon. Its comment said remember-me needed it; Auth::authCheck() signs a remembered browser back in through rememberLogin() whenever no auth addon is registered. Applications that register the addon keep working as before.
  • A characterization test signs in from the remember-me cookies with no addon registered, on MySQL, PostgreSQL and TimescaleDB.

Documentation

  • The Authentication Guide says remember-me needs no addon; the Roadmap keeps only the check in a real application.

Setting up an authorization server, end to end

Documentation

  • Setting up an authorization server: scaffold it, generate its keys and first administrator, brand it, its language, the first client, status and health, and what to change and what to leave to the framework.

A recreated test database starts with nothing remembered about the old one

Fixed

  • A test database dropped and created again under its name kept two caches of the old one. The var/migrations/*.verified markers said its migrations were done, so none ran and every test failed on a missing table with nothing mentioning migrations. The connection's SQL cache answered the dropped database's settings, so a migration that writes a setting only when it is unset wrote nothing. Both are keyed on the connection's name. TestEnvironment now forgets both at the one moment anything knows the database is new: when it creates it — the first run, after test_database.rebuild_above dropped it, or after somebody did by hand. A database it finds already there keeps its markers.

mail:bounces --imap --limit reads the newest unseen messages

Fixed

  • Behind a backlog of unread replies, a new delivery report was never read. mail:bounces marks only reports as seen; a reply stays unseen for a person, so in a mailbox that also receives replies the unseen messages only grow. BounceMailbox::unseen($limit) took the oldest $limit, which once the replies outnumbered the limit were the same replies on every run. One production mailbox held 375 unseen messages, 354 of them replies and 3 reports; a run with a limit of 100 read the oldest 100 and found none. It now takes the newest $limit, still read oldest first.

Documentation

  • The Email Guide says that non-report mail in the bounce mailbox stays unread and needs a person, and that --limit takes the newest messages.

An organisation's roles, written and checked for a fraction of the queries

Profiled in an application's API tests, an organisation's team roles took about half of every test: one call to allowMany() per role, each a DELETE and an INSERT per grant, thousands of catalogue queries asking whether the same tables existed, and a webhook lookup for every change with nobody subscribed. Production pays the same on every invitation, role change and erased organisation.

Added

  • QueryBuilder::insertMany($rows): one multi-row INSERT per 500 rows. A row whose columns differ from the first row's is refused before anything is written.
  • SchemaBuilder::forgetWhatExists(?Database $db = null), for DDL run through another connection in the same process.

Changed

  • hasTable() and hasColumn() remember what they found, per connection, for the life of the process. A missing table is never remembered, so one a migration creates is seen at once. The builder's DDL, any DROP/ALTER/RENAME through the same connection and the migration runner forget the answers. The Upgrade Guide has the row, for long-running workers.
  • Permissions::allowMany() in the authserver store is one DELETE and one INSERT, whatever it grants.
  • WebhookService::queuePermissionsChanged() asks first whether anything is subscribed to permissions_changed, in one query. With nothing listening it does not look for a role's holders or their applications at all.

A controller segment reaches its class in any case

Fixed

  • /twofactorauth/backup answered 404 or the page, depending on what the request had loaded first. A controller is found by ucfirst() of the segment, Twofactorauth. PHP's class names ignore case, so that worked once TwoFactorAuth was loaded, but before that the autoloader looked for Twofactorauth.php, which a case-sensitive filesystem does not have. Application::getController() now tries the exact name in the area, the application and the framework first, as before. Only when none has it does it search the autoloader's class map and PSR-4 directories for the name in another case. The segment is compared with names that exist and never becomes part of a path.

Documentation

  • The Routing Guide says which class a controller segment reaches, and in what order.

The auth screens, finished: the theme's colour, closed registration, the account overview and organisation screens in Greek

Found by an authorization server that deleted its copies of the framework's auth views and looked at what was left.

Fixed

  • The brand colour overrode the theme. With auth_brand_primary_color unset, the sign-in, password, second-factor and approval screens still set --color-primary to #2563eb, so a daisyUI theme's own primary, and a dark theme's, became hard-coded blue. Brand::current() gains primary_color_set, and the views apply the colour only when it is set.
  • The sso and OAuth2 fallback screens sent an end user to the administration (Dashboard). They link to the account now. The sso register button shows only while registration is open.
  • A closed registration showed an empty card, and its error could appear twice. It shows one notice.
  • The account overview was English only. Every phrase of dashboard/dashboard goes through t() in the three themes.
  • The organisation screens had no account layout: no sidebar, breadcrumb or flash messages, and a text box where the role picker belongs.
  • The scaffolded Login controller's postLoginTarget() skipped the parent, so the page the second step was to return to stayed in the session and a later sign-in could land on it.
  • The notifications' phrases split over two literals could not be translated; each is one key.

Added

  • The Greek catalogue has 576 phrases: the password toggle, the security and sign-in-code emails, the messages inbox, the organisation screens, the cookie banner, the account overview and the scheduled tasks of a deleted account. Allow reads «Έγκριση», Scopes: «Δικαιώματα:»; the account's navigation uses a new Overview key («Επισκόπηση») and the administration keeps Dashboard («Πίνακας ελέγχου»). FrameworkGreekCatalogueTest covers the new screens.
  • The logo's size is --pf-auth-logo-max-width / --pf-auth-logo-max-height (220px / 64px), on a pf-auth-logo class, so a wider logo needs no copied partial.
  • The second-factor screen carries the link back to the site, as the other public screens do.

Documentation

  • The AuthServer Setup Guide covers the colour, the logo variables and closed registration; the Internationalization Guide what the catalogue covers.

The page cache stores no page that prints who its visitor is

Fixed

  • A page that printed the visitor's id could be stored and served to everybody. The tracking cookies (visitorid, lastseen) may go with a stored page, so the response that minted a new visitor id, very often a bot's first request, was storable even when its body printed that id. In one application a footer printed it for a script, and 797 visit ids were found shared by 3,054 distinct visitors in a day. store() now refuses a body that contains the session's visitorid or the value of a tracking cookie the same response sets.

Documentation

  • The Page Cache Guide says that nothing about the visitor may be in a stored page, and how to count the visitors who only ever receive cached pages: a hit records no visit, so either a beacon to an uncached POST, or tracking ahead of the cache at the cost of a session per hit.