Skip to content

5 September 2026

13 changes:

  • Helpers::fileGetContents() fetched with TLS verification off
  • A clamped rendition was never found again, and rebuilt on every request
  • Two migrations that could fail on real data now decline instead
  • Saving a token through the model stored it in plaintext and unfindable
  • get() takes upscaling per call, so a template can fill its frame
  • An application on the legacy version ledger can say what already ran
  • crop and resample are part of a rendition's identity too
  • Maintenance mode can be raised and cleared from the console
  • migrations.auto: stop migrating on your own without disabling migrate
  • Ask a production database a question without opening a shell on it
  • The public MCP endpoint ships one tool, so it can be told from a broken one
  • The diagnostic tools can answer off the box, behind three separate scopes
  • mcp:token — one command for the credential and the client config

Helpers::fileGetContents() fetched with TLS verification off

Reported from a consuming application (FW-056). On the same day \Pramnos\Security\OutboundUrl was written and MediaObject::addRemoteImage() was moved onto it, this helper was left as it was — src/Pramnos/General/Helpers.php:

CURLOPT_SSL_VERIFYPEER => false     // no certificate was ever checked
CURLOPT_FOLLOWLOCATION => true      // to wherever the far end pointed
CURLOPT_MAXREDIRS      => 10
— no CURLOPT_PROTOCOLS, no size limit, only TIMEOUT => 10

The options appeared twice, once in each of the $fakeRef branches.

So: certificates unverified, a 302 leading anywhere the fetched server chose, file:// and everything else compiled into curl reachable, and a body capped nowhere. The reporter's question was not «how do we secure it» but «why does it exist» — a general HTTP helper with TLS off, shipped beside the class that does the same job properly. grep -rn "fileGetContents(" src gives only the definition: no caller inside the framework, and it was the obvious choice for the next person who wanted «download this URL».

Fixed

It delegates to OutboundUrl::fetch(), which checks the scheme against an allow-list, resolves every A and AAAA record and refuses private, loopback, link-local and reserved addresses, connects to the address it approved while sending the name in Host: and validating the certificate against that name, re-runs the check on every redirect hop, and stops reading at a byte ceiling mid-stream.

Kept rather than deleted, because it is public API and there is a known caller outside the framework. It carries an @deprecated tag pointing at fetch(), which takes the ceiling, the timeout and the redirect budget as arguments and hands back a reason and a status — all of which a wrapper has to decide for you.

Three behaviour changes, each of which can break a caller, and all documented in Pramnos_Security_Guide.md:

  • A host on a private address is refused. A caller fetching sURL . $path against its own site stops working — locally that name resolves to a private address. Read the file instead.
  • $fakeRef is ignored. It sent a Google referer and a Firefox user agent, which is hotlink-protection evasion. The parameter stays so the signature does not change.
  • allow_url_fopen is required. fetch() dials with a stream so it can connect to an approved address rather than a name; curl is what used to work without that setting. It fails closed — falling back to an unchecked curl fetch would undo the change.

Found while fixing it: checkUrlStatus(), left alone on purpose

The same file has a second public fetch helper. It never disabled certificate verification and follows no redirects, so it is not the same defect — but it does no address check, so it will report whether something is listening on a private address, which is a host and port probe for anything that can influence its argument. It also buffers the whole response into memory to discard it.

Left as it is, and documented instead, because «is this URL up» is a question people legitimately ask about internal addresses — a health check for a service on the same subnet is the ordinary use, and moving the guard inside would break the honest callers. The guide shows the isPublic() check to put in front of it when the URL came from outside.

A clamped rendition was never found again, and rebuilt on every request

Reported from a consuming application (FW-055), found because the media changes broke ten of their tests and the new code had to be read line by line.

resize() clamping a request to the source's own size was the right change — a rendition larger than the original it came from is the opposite of what a thumbnail is for. What it exposed is that MediaObject::get() looked renditions up by the size that was asked for and stored them under the size that came out. With allowUpscale off, which is the default, those differ for every source smaller than the requested box.

So get(177, 222) on a 150×150 source wrote a 120×150 entry, and the next identical get(177, 222) matched nothing. It rebuilt the image, appended another entry and saved the row — and because ResizeTools prefixes rand(1,9999)_ when the filename it wants already exists, that was a new file on disk and a new entry in the column on every call, neither of them bounded. Measured by the reporter: three identical calls, three files, three rows. Against their real data, 467 of 1023 images are under that box in at least one dimension — 46% of images rebuilt on every page view.

Fixed

Thumbnail records requestedX and requestedY — the box that produced the rendition — beside x and y, which stay what the rendition actually is. The lookup compares against the requested box when an entry has one.

The alternative was to clamp the request the same way before comparing. Storing the request was chosen because it keeps what was asked for, which is the information the clamp discarded, and it survives a later change to the clamping rule; a lookup that re-derives the clamp has to stay in lock-step with the resizer for ever.

No migration, and nothing stored has to change. The thumbnails column is JSON of the declared fields, and the hydrator starts from a fresh Thumbnail and copies only the keys a row has — so every existing row reads back with 0, which the matcher treats as "not recorded" and compares on x/y exactly as before.

The fallback also covers a case the JSON path does not: rows written before the column held JSON are serialize() output, and the reader deliberately returns those unconverted, because an application with its own thumbnail class has been getting its own objects back from them for years. Those objects do not declare these fields at all, so the matcher reads them through isset — the same reason encodeThumbnails() already did.

The test that stopped one line short

MediaRenditionPolicyTest::testARequestLargerThanTheSourceIsNotUpscaled asked 512×512 of a 40×40 source and asserted the clamp. It never asked twice, which is why a green suite shipped this. There are now three tests that do, including a third call — because a count growing by one per call cannot be told from an off-by-one with only two.

Documentation

Pramnos_Media_Guide.md gains What was asked for, and what came out: the two pairs of dimensions, which one to read for layout and which one the cache is keyed on, and when allowUpscale = true is the right answer instead.

Two migrations that could fail on real data now decline instead

Reported from a consuming application, and not because either one bit them: their usertokens is small and their settings table has no duplicates. The deploy that prompted it is against a database where neither has been verified — and the check that would have told them is one the framework already knew how to write.

canAddForeignKey() had the pattern and nothing else used it: count the rows that would violate the change, refuse, and name what has to be repaired. It cannot fail on dirty data; it declines and says why.

Added — the vocabulary, on Migration

orphanCount() moves to the base class, duplicateGroups() and duplicateCount() join it, and decline($reason) records a refusal as its own state: MigrationRunner::RESULT_DECLINED.

migrate prints Declined: with the reason and a closing line saying these stay pending; migrate:status shows Declined and prints the reason under the table. And because getRanSlugs() counts only RESULT_OK, a declined migration is attempted again next time — repair the rows and it applies itself.

Declined is deliberately not the Skipped (cutoff) a row can already carry. Those never ran; this one ran, checked, and said no.

The constraint this was written against: a guard must not turn a real problem into silence. So a count that cannot be taken — a missing table, a permission — is zero, not a refusal, because declining there would make an unrelated fault look like dirty data; and a decline is recorded in three places rather than logged and forgotten.

Fixed — add_unique_constraint_to_settings_table

It drops the plain index before creating the unique one, so a duplicate value aborted CREATE UNIQUE INDEX and left the installation with neither index on a column every settings read uses. It now counts duplicates first and declines, naming the values and their row counts.

Declining rather than resolving: keeping the highest id per name is a decision about somebody's configuration, and two rows for sitename may mean the wrong one has been in effect for months.

Fixed — add_token_lookup_to_usertokens

Three problems, in ascending order of cost. It read the whole table into PHP before writing anything — 48 MB at 50 000 rows, so around a gigabyte at a million, which is what turns "slow" into "the deploy died". It had no WHERE token_lookup IS NULL, so a second deploy paid the whole cost again. And it computed the digest in PHP row by row, which the reporter measured at 5 617 ms where one UPDATE … encode(sha256(token::bytea), 'hex') takes 130.

Now: without an APP_KEY the whole backfill is one statement — which is also every installation that has not run key:generate. With a key, encryption genuinely needs PHP (encrypt() uses a fresh nonce per value), so it runs in batches of 2 000 behind a keyset cursor on tokenid, which keeps the buffer flat and cannot skip rows the way an offset walk over a changing table does.

And it fills only rows that can still authenticate — status = 1 and not expired. A revoked or expired token is never looked up by value again, and on a long-lived installation those rows are most of the table, because usertokens is an audit trail as much as a credential store. Safe because nothing revives a token: no path sets a row's status back to active, every path that matches on token_lookup and then acts also checks status/expires, and the paths that do not filter treat a missing row as invalid — the same answer a dead row gives.

Stated rather than left to be discovered: rows outside that scope keep their token value as it is, plaintext, on an installation where the rest of the column is encrypted. They cannot authenticate and every lookup filters in SQL, so the exposure is smaller than it sounds — but it is a real difference from "the column is encrypted", and an installation that wants the stronger claim wants a sweep it can run when it chooses, not a migration holding a deploy open to encrypt credentials that stopped working years ago.

A test asserts that the digest the database writes equals Token::lookup() exactly. If those ever disagreed by so much as a case, every authentication lookup would miss and nothing else in the suite would notice.

Documentation

Pramnos_Migration_Guide.md gains Check the data before you change it: the four methods, why an unaskable count is zero, why a decline is loud and comes back, and a section on backfills — scope them, bound them, and let SQL do what SQL can.

Saving a token through the model stored it in plaintext and unfindable

Found while verifying somebody else's reasoning about which usertokens rows a backfill could skip — not reported, and not what was being looked for.

Token::storageFor() produces the two columns from one value: the token encrypted, and the sha256 digest that all fifteen authentication lookups match on. Token::save() did not call it. It wrote $this->token exactly as it stood and never wrote token_lookup at all. So a token inserted through the model was stored in plaintext and could not be found by any lookup. User::addToken(), which INSERTs directly, had it right — which is why nothing in the framework noticed.

The second half is Token::load($value), which matched WHERE token = %s. Encryption is non-deterministic, so the stored ciphertext never equals the presented value: that comparison cannot match on any installation with an APP_KEY. It worked only where nothing was encrypted, which is how it survived being split out.

Fixed

save() goes through storageFor(), and load($value) looks up the digest with the plaintext comparison kept as a fallback for rows written before the column existed.

Only a plaintext value is converted. fillProperties() copies the row as it is, so a loaded token holds ciphertext; re-encrypting that would store something nothing can decrypt and a digest of the ciphertext — an authentication outage rather than a bug. An already-encrypted value is written back untouched. After a save $this->token holds the stored value, so a second save() does not re-encrypt the plaintext under a fresh nonce and rewrite the column for no change.

Both paths check the column exists first, cached once per process. The first version did not, and 25 tests failed with Unknown column 'token_lookup' — which is exactly what an installation that has not run the migration would have got, on the save path, turning a token save into a failed request on a database that is merely out of date. hasColumn() issues a query and save() runs whenever a token's last-used moment is persisted, so asking once per process is the difference between a guard and a tax.

get() takes upscaling per call, so a template can fill its frame

MediaObject::$allowUpscale was the only control, and it is set on the object — so it governs the derivatives processImage() generates as much as the rendition a template is asking for. Wanting a 150×150 avatar to fill a 177×222 slot therefore meant allowing invented pixels everywhere, including in images being produced to store. A consuming application had already made exactly that trade in its own copy.

Added

get($width, $height, $crop, $force, $debug, $resample, $allowUpscale = null). null means «what the object says», so nothing that does not pass it changes behaviour.

Caller Upscale
a template with a fixed frame yes — a gap is worse than a soft image
producing a derivative to store no — a rendition larger than its source is not a thumbnail

The two are cached apart. The same box asked for both ways produces two different images that record the same requested box, so Thumbnail::$upscaled is part of the entry's identity. Without it a lookup returns whichever was written first — and the direction that matters is handing stretched blur to a caller that explicitly asked for none. Entries written before the flag read false, which is what they were.

Documentation

Pramnos_Security_Guide.md gains the save()/load() contract beside the split-column section; Pramnos_Media_Guide.md gains the per-call argument and the table above.

An application on the legacy version ledger can say what already ran

The half of an earlier report that was left open. Both migration systems write to schemaversion and key it differently: the legacy path — Application::runMigration() — stores a migration's $version (0.010), the runner stores its slug (migration0010).

So an application that migrated for years through the old path has a full ledger the runner cannot read a single row of. migrate:status calls every one of those migrations pending, and migrate would run them again against a schema that already has their changes. Every such application was writing its own one-off migration to fix it — after finding out that migrate:status had been lying to it.

migration_cutoff is not the answer and it is worth knowing why: it filters on the filename timestamp, and a legacy MigrationNNNN class has none. filterCutoff() lets a migration with no timestamp through by design.

Added

php pramnos migrate:adopt-legacy --dry-run   # what would be recorded
php pramnos migrate:adopt-legacy             # record it

The mapping is derivable rather than something each application hand-writes: a migration object knows both its $version and its getSlug(), so «which of these already ran» is a lookup of the first and a write of the second. MigrationRunner::adoptLegacyVersions() is the method under it.

It records; it never executes. The work was done by the other path years ago, and running it again is the outcome this exists to prevent. Two refusals, both deliberate: a migration the ledger has never heard of stays pending, because the only evidence it ran would be this inventing it; and a slug the runner already knows is left alone, so a second run is a no-op. A migration with no $version — every modern, timestamped one — is skipped, so pointing it at a mixed directory is safe.

Also written down: an adopter can be empty

A migration whose entire body is a $dependencies list is a supported way to say «this application needs that one framework migration and nothing else». The on-demand dependency pool already did this; what was missing is that the worked example in the guide had an up() that did work, so whether an empty one was legitimate or an accident was stated nowhere.

It is legitimate — the dependency is pulled, ordered first, run, and both are recorded — and LegacyLedgerAdoptionTest now pins it, including that the dependency actually creates its table rather than merely being marked as run.

Documentation

Pramnos_Migration_Guide.md gains Coming from the legacy version ledger and the empty-adopter example beside the dependency-pool section.

crop and resample are part of a rendition's identity too

Reported the same day (FW-057), from reading the commit that added upscaled — and the reasoning quoted back was the one written in that commit message: the same box asked for both ways produces two different images which record the same requested box, so a lookup keyed on the request alone returns whichever was written first.

That argument applies word for word to $crop, and it was not applied. get() takes $crop, passes it to ResizeTools, and the matcher never looked at it.

Measured, and the first attempt at measuring it was wrong. A probe with horizontal colour bands showed all four combinations producing identical bytes — because a centre crop of a horizontally symmetric image is indistinguishable from a squash. With vertical bands and a marker near one edge, 155×148 from an 800×400 source:

crop=true  resample=true  -> 155x148  md5=6116aa5c
crop=true  resample=false -> 155x148  md5=6116aa5c
crop=false resample=true  -> 155x148  md5=d94b13a9
crop=false resample=false -> 155x148  md5=6f320215

Three different pictures at one size, reproducing the reporter's numbers exactly.

Fixed

Thumbnail records crop and resample beside upscaled, and all three have to agree before an entry is reused. The reported caller is real and sits on both sides of one if: two admin templates asking get(155, 148, true, true) in one branch and get(155, 148) in the other, for the same media — so the cropped entry was being served to the branch that asked for the whole picture.

resample is recorded although it changes nothing when cropping, because an entry is identified by what was asked for and a lookup cannot know that this particular source made the answer moot. That was raised explicitly as «if it stays out, write why» — it did not stay out.

Corrected: a claim in the previous commit

The Thumbnail::$upscaled docblock said entries written before it read false, «which is correct: they were produced when clamping was the only behaviour». That is not true for the four-day window between allowUpscale appearing on the object and being recorded on the entry: an application that switched it on in that period wrote genuinely upscaled entries that now read false.

The docblock now says what is actually the case — old entries get the class defaults, that is an assumption rather than a fact, and guessing wrong costs one rebuild and one row, once. The consequence was always small; the overclaim was the problem.

Documentation

Pramnos_Media_Guide.md gains What identifies a rendition: the measured table, the five fields that must agree, and what an entry predating them is assumed to be.

Maintenance mode can be raised and cleared from the console

Asked for: a way to put the site down by hand before a heavy migration, take it back up afterwards, and have the automatic migrations stay out of the way in between.

Two of the three parts were missing, and one of them was a trap.

The console did not survive the flag. Application::__construct() answers the maintenance page and exits when var/MAINTENANCE exists — correct for a request, and it applied to every console command as well. Raising maintenance therefore broke migrate, which is the work maintenance is usually raised for, and maintenance:off, which would have been the way back. The only exit was deleting the file by hand, which is the state this feature exists to replace. Measured before the change: bin/pramnos migrate:status printed an HTML page and stopped.

And automatic migrations did not stand down. runAutoMigrations() never looked at the flag.

Added

php pramnos maintenance:on --reason="Adding an index to usertokens, ~20 minutes"
php pramnos maintenance:off

maintenance:off takes down only a flag a person raised. The flag now records its origin on a line of its own — manual from the command, automatic from runMigration() and MigrationRunner, unknown for a flag written before this existed. The last is refused rather than assumed to be yours: there is no way to tell who raised it, and the safe reading of "no way to tell" is "not yours". --force is for a batch that died and left its flag behind, and says that it forced.

The refusal is the point of the feature. A flag the framework raised means a schema is in flux and something is either still running or died half way; clearing it because you meant to clear your own is the mistake worth making impossible.

Fixed

runAutoMigrations() returns as soon as it sees the flag. An explicit migrate is unaffected — it goes through MigrationRunner directly, and clears only a flag it raised itself.

Application::__construct() skips the maintenance page when PRAMNOS_CONSOLE is defined, which Pramnos\Console\Application does before building the application. Keyed on that rather than on PHP_SAPI on purpose: a cron worker or a deploy script booting an application under the CLI should still be held off a schema in flux, and it does not define that constant.

The origin line is kept off the maintenance page. It is bookkeeping for whoever may clear the flag, not something a visitor on a 503 has any use for.

What breaks in subclasses

Application::startMaintenance() gained a second parameter, and PHP does not allow a subclass to declare a narrower signature. An application that overrides this public, documented method dies at class load — not a page and not a route, but the whole site and the whole suite, before anything runs:

Fatal error: Declaration of App\Application::startMaintenance($reason = '')
must be compatible with Pramnos\Application\Application::startMaintenance(
$reason = '', $origin = self::MAINTENANCE_AUTOMATIC)

If you have an override: delete it. The base class now does what yours did, plus the origin. The same applies to any override of stopMaintenance() — it no longer lifts a flag an operator raised by hand, which is behaviour an old copy would undo.

There is no compatible way to add a parameter to a parent method, and the alternatives — a second method, or a property instead of an argument — are worse designs bought to avoid a one-off cost. What was wrong was the announcement: the commit was feat(console): with no ! and a scope naming the wrong subsystem, so a reader scanning the log before upgrading had no indication they were about to lose the site. The convention is now written down in the project rules.

Documentation

Pramnos_Framework_Guide.md gains Raising and clearing it by hand under maintenance mode: the two commands, the origin table, and why the console is exempt while a worker is not.

migrations.auto: stop migrating on your own without disabling migrate

Reported: an installation that applies migrations by hand — a deploy window, a large index build, anything somebody wants to watch — had no way to say so.

'migrations' => ['framework' => false] was the only switch there is, and migrationScope() reads it for auto-run and for the CLI. So turning it off to stop the automatic run also empties the pramnos migrate it was turned off in order to perform. One answer for two questions.

What prompted it: a batch putting five indexes on tokenactions, which is a compressed hypertable, where a plain CREATE INDEX locks each chunk while it builds. The first visitor's request after a deploy is the wrong place to discover that.

Added

'migrations' => ['auto' => false],

It gates the entry points nobody asked for by name — exec()'s checkversion()/upgrade() pair, and runAutoMigrations(), which migrate() exists to reach — and nothing else. pramnos migrate and migrate:status still see everything, because migrationScope() does not read it.

Application::runPendingMigrations() is the explicit counterpart: it ignores the key and runs both systems, the legacy migrations.php ledger and the framework's own, because an installation that switched the automatic run off has two halves to catch up and no reason to have to remember that they are two. It also lets a failure out, where migrate() swallows it — a migration failing in a watched window is what the operator is standing there to see.

The gate goes on the call site, not in the method

upgrade() is also the explicit path, so a gate inside it turns «do not run by yourself» into «do not run at all». That is not hypothetical: the reporting installation gated the method rather than the call site and took 738 test errors in one run from a bootstrap that was calling upgrade() deliberately.

MigrationsAutoKeyTest pins it from both directions — and the first version of that test would not have. Its probe application overrode upgrade(), so it passed against a framework broken in exactly the way being guarded against; it now stubs only what upgrade() reaches and lets the real method run. Verified by reintroducing the mistake: two tests fail.

Documentation

Pramnos_Migration_Guide.md gains Applying migrations by hand — the table of what still runs, runPendingMigrations(), why migrations.framework is the wrong key for this, and where the gate goes.

Ask a production database a question without opening a shell on it

Diagnosing a live problem meant SSH, and SSH gives a shell: everything the account can do, no record of what was read, and one mistyped DELETE away from an incident. db-inspect is the narrower thing — one capability, one statement, revocable with a token.

Added

  • db-inspect, an MCP tool that runs one read-only SELECT. It is on the stdio server by default, which already requires a shell here; putting it on the public HTTP endpoint is a deliberate PublicRegistry::add(new DbInspectTool($db)) — which is how it replaces SSH rather than merely sitting next to it.

  • Two scopes, mcp and mcp:db_read, registered only while a tool asks for them. Without them the public endpoint was ungrantable — Scopes::hasInvalidScopes() rejects anything not in the list, so no token could be issued that reached MCP at all.

    Registering them permanently would have been worse than the gap it closed. scopes_supported is served from /.well-known/oauth-authorization-server to anybody who asks, with each scope's description attached, so a fixed entry would announce that every Pramnos site has a capability for running SELECTs against its live database — including the great majority that never offered one. The list is derived from PublicRegistry instead, so an installation discloses the capability exactly when it has one, and Scopes gained MCP as a category that is usually empty.

    mcp:db_read inherits mcp, so a token that can query can also call whoami and check itself. system:admin inherits neither, deliberately: administering the application and reading its production tables from another machine are two decisions, and one grant covering both is how the second gets made without ever being taken. - Pramnos\Security\ReadOnlyQuery, which decides whether a statement is a read. It does not check the first keyword, because that check is beaten by a statement that really is a SELECT:

    WITH gone AS (DELETE FROM usertokens RETURNING *) SELECT count(*) FROM gone;
    

    It begins with WITH, ends as a SELECT and empties a table. So a writing keyword anywhere outside a string literal or a comment is a refusal. Literals, -- and /* … */, dollar quoting and double-quoted identifiers are blanked rather than removed, so 'a' delete cannot collapse into adelete and slip past a word boundary.

  • Pramnos\Security\PersonalDataRegistry, which decides what comes back. A query against a declared-personal table answers with the row count and the column names, not the rows. That is not a lesser answer for most questions — how many live tokens have no digest, are there duplicate settings names are counts, and a count exposes nobody.

  • 'personal_data' in app.php, matched on the bare name so a declaration survives a table prefix and a schema qualifier:

    'personal_data' => [
        'tables'  => ['customers', 'support_tickets'],
        'columns' => ['tax_number'],
    ],
    
  • create:migration asks, once per table, whether it holds personal data, and prints the line to paste. A denial list's standing weakness is that somebody has to remember to update it later; creating the table is the one moment somebody knows the answer.

  • An optional read-only database account, database_readonly_dsn, stored encrypted like smtp_pass because it is a credential. Not required: setting one up is real work and an installation may reasonably decide a developer with access to the box is already trusted — in which case the lexer is the boundary, and that is worth knowing rather than assuming.

Why a denial list, when PublicRegistry argues the opposite for tools

It does, and it is right about tools: filtering a shared list means every future tool is public until somebody remembers to exclude it.

Tables are not tools. An unknown tool is dangerous by definition; an unknown table is usually mundane, and an allow-list would mean nothing new can be looked at until a person adds it — friction on exactly the diagnosis the tool exists for.

The cost of that choice is paid by the registry's second half: column names are matched wherever they appear, so a table nobody classified still has its email, password, token and phone withheld. It is a heuristic and it is written down as one — it reduces what leaks from an unclassified table, and it does not replace classifying it.

Fixed while building it

A configured-but-unusable read-only account came back as query_failed, because connection() was called inside the same try as the query. That is the shape of a typo in the SQL, and the two need different people to do different things — a boundary somebody believes they have and does not is the worst of the three outcomes, because it is invisible.

Documentation

  • Security guide — a new Personal data and the denial list section: the four boundaries and which one is load-bearing, declaring your own tables, and the GRANT script for the read-only account.
  • MCP guide — db-inspect among the built-in tools, and Reaching it from off the box: the four steps to answer an MCP client over HTTP, and what you are trusting when you do.
  • Console guide — the wizard's personal-data question, and why the answer is printed rather than written into the migration.

The public MCP endpoint ships one tool, so it can be told from a broken one

PublicRegistry starts empty by design: a tool is offered over HTTP only because an application put it there, and the framework's twenty development tools are a different collection entirely so that no future one becomes public by being forgotten about.

The cost of that was an endpoint whose correct behaviour and whose broken behaviour are the same observation. A client connects, authenticates, asks for the tool list and receives nothing — and has no way to tell working wiring from a wrong token from scopes nobody granted.

Added

  • whoami, registered by the framework on the public endpoint when the authserver feature is on — which is what scaffolds the endpoint, so with the feature off there is nothing to smoke-test and nothing is announced. It asks for the scope mcp, which every authenticated caller holds — a smoke test guarded by a scope nobody has would be missing from exactly the tool list somebody is trying to debug — and answers with the id and scopes of the token the call arrived with:

    { "user_id": 4242, "authenticated": true, "scopes": ["mcp", "orders.read"], "server": "pramnos-mcp" }
    

    No name and no email address. This is a production endpoint whose answers travel, the caller already knows who they are, and an id is what identifies a row in a log.

    It is also the first thing to reach for when a tool is "not showing up": if the scope that tool needs is missing from this answer, that is the whole diagnosis.

  • PublicRegistry::remove($name), for an application that wants even this gone without reset(), which would take its own registrations with it.

The exception, stated as one

PublicRegistry's rule is that a tool is there only because an application put it there, and this breaks it. The rule is about disclosure, and this discloses nothing the caller did not already present — which is why it is the one thing that can be shipped registered without weakening what the rule protects.

It is registered in Application::init() rather than in McpServiceProvider, and the distinction matters: that provider builds the internal stdio server and never runs in a web request, which is the only place the public endpoint is answered from. Registering it there would have shipped a tool that appeared to exist and was absent from every actual HTTP call.

Documentation

  • MCP guide — a whoami section under Serving it over HTTP, and the endpoint's description corrected: it no longer starts empty.

The diagnostic tools can answer off the box, behind three separate scopes

db-inspect answers what is in the data. The rest of what somebody opens a shell for — is anything broken, what is pending, does the schema match, what is failing and how often — was already written, as tools on the internal stdio server, reachable only by whoever already had the shell.

Added

  • McpServiceProvider::offerDiagnostics($app), one deliberate call that offers eleven readers on the HTTP endpoint. Without an application it still offers the ones that need none — the log readers work when nothing boots, which is exactly when somebody wants them. A second argument narrows the offer to named tools.

  • Three scopes rather than one, because they disclose different things:

    Scope Tools Discloses
    mcp:diagnostics status, migration-status, schema-drift, list-tables, query-schema, route-list, model-inspect structure and state — names and shapes
    mcp:logs log-errors, log-analytics, request-debug what has been happening, as free text
    mcp:db_read db-inspect rows, with the denial list applied

    Reading the schema, reading the logs and reading the rows are three questions. An installation granting the first must not silently be granting the third.

  • ScopedTool, which puts a scope on a tool written without one. Every tool on the internal server implements McpToolInterface, which has no answer to who may call this, so none of them could be handed to PublicRegistry directly.

Why a wrapper and not a requiredScope() on each tool

Adding the method to the diagnostic tools would make every one of them offerable from then on — and the property protecting this endpoint is precisely that they are not. A twenty-first tool would reach the network by being written rather than by being chosen.

Wrapping keeps the decision at the call site, where it shows up in a diff, and makes the scope a property of this exposure rather than of the tool: the same reader can sit behind different scopes on different installations without either of them editing the framework.

What is deliberately not offered

Not taste. Each omission is a different reason, and the test names them so that adding one back has to be a decision rather than a drift:

  • changelog-add writes files. An endpoint that edits the repository on a production box is a different risk class from one that reads it, and no incident needs it.
  • coverage, find-tests read test artefacts a deployment does not have.
  • framework-docs, find-symbol, console-commands, api-docs, theme-info, pramnos-check answer questions about the codebase, which whoever is asking has checked out locally. Over HTTP they disclose source structure and buy nothing.

None is blocked — ScopedTool::wrap() offers any of them in one line for an installation that disagrees. They are simply not the default, because a default is what nobody re-examines.

Stated plainly, because it is the one without a boundary

mcp:logs hands over free text, and free text is not redacted. PersonalDataRegistry withholds columns; it cannot see an address inside a stack trace, a request body captured by the debugger, or a token somebody logged in a URL. mcp:diagnostics and mcp:db_read have boundaries. mcp:logs has the same exposure as handing somebody the log directory, and is worth granting on that understanding rather than by analogy with the other two.

Documentation

  • MCP guide — Offering the diagnostic tools, the three-scope table, what is left out and why, and a note defining what "public" means for this endpoint: reachable from off the machine, always authenticated, always scoped — not open.
  • Security guide — the three scopes beside the denial list, and the warning about mcp:logs.

mcp:token — one command for the credential and the client config

The endpoint, the tools and the scopes existed. Getting a credential to any of it meant walking an OAuth authorization flow by hand — a browser round-trip for a capability whose whole point is that somebody is at a terminal, on a server, wanting to know what is wrong.

php <cli> mcp:token --user=you@example.com --scopes=diagnostics,logs,db --days=30

Run on the installation you want to reach, because minting a token means writing a row into its usertokens. It prints the token once — the column is encrypted at rest — and the .mcp.json block to paste, bearer header already filled in.

Added

  • mcp:token, with --user (id, email or username), --scopes (short names diagnostics / logs / db, or full ones), --days, --url and --name. mcp is added whether asked for or not, so whoami is always reachable — a token that cannot ask what it is cannot diagnose itself.

  • User::addScopedToken(), because a token needed to carry scopes and addToken() could not be given a sixth parameter. See below.

Two things the tests found, both worth the space

A sixth parameter on addToken() is a BC break, and not the kind rule 6 usually catches. An optional trailing parameter is safe for every caller and fatal for every subclass that overrides the method: PHP refuses to load a child whose signature no longer matches. The first version of this did exactly that and the suite stopped booting with

Declaration of StubApiUser::addToken(…) must be compatible with User::addToken(…)

An application with its own User::addToken() would have hit that on upgrade, as a fatal at boot. addScopedToken() is a separate door onto the same private writer instead.

Refusing to mint a token for an unregistered scope was wrong. The first version checked Scopes::hasInvalidScopes() and refused. But an MCP scope is registered only while a tool asks for it, and the call that offers those tools runs in an application ServiceProvider — booted by Application::init(), which a web request runs and a console command does not. So the registry looks empty from a terminal on every installation, including the ones that offer everything, and the check refused to mint a working token for a correctly configured site. It is a note now, saying what it cannot verify rather than pretending it verified it. The endpoint decides at request time, and whoami is what reports the answer.

Verified against a real database

The command's job is to produce something accepted three steps later by code it never calls, so the tests drive the whole path: the printed token is the one loadByToken() finds by digest, the scopes come back off the row in the shape Token decodes, asking for logs grants ['mcp', 'mcp:logs'] and nothing else, the stored value is not the plaintext, and the printed JSON parses with the same token in its Authorization header as the line above it.

findUser() and signingKey() are protected rather than private, and that is a seam with a reason. Both of the command's refusal paths depend on a state no test can arrange against a working installation — a database that is not there, and an installation with no key — and the second is the most important line in the file: it is what stops a convenience command minting a credential under a key that, without authenticationKey and without sURL, reduces to a hash of a version string defaulting to edge. Every installation in that state would sign with the same publicly computable constant, and a token from any of them would verify against all of them. A refusal nothing exercises is a refusal nobody has read back.

Documentation

  • Console guide — the command, its options, and why it cannot tell you whether the endpoint offers a scope.
  • MCP guide — step 3 of Reaching it from off the box is now this command rather than a pointer at the OAuth flow.