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
cropandresampleare 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 disablingmigrate- 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 . $pathagainst its own site stops working — locally that name resolves to a private address. Read the file instead. $fakeRefis 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_fopenis 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¶
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-onlySELECT. It is on the stdio server by default, which already requires a shell here; putting it on the public HTTP endpoint is a deliberatePublicRegistry::add(new DbInspectTool($db))— which is how it replaces SSH rather than merely sitting next to it. -
Two scopes,
mcpandmcp: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_supportedis served from/.well-known/oauth-authorization-serverto 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 fromPublicRegistryinstead, so an installation discloses the capability exactly when it has one, andScopesgainedMCPas a category that is usually empty.mcp:db_readinheritsmcp, so a token that can query can also callwhoamiand check itself.system:admininherits 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 aSELECT:It begins with
WITH, ends as aSELECTand 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' deletecannot collapse intoadeleteand 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'inapp.php, matched on the bare name so a declaration survives a table prefix and a schema qualifier: -
create:migrationasks, 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 likesmtp_passbecause 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
GRANTscript for the read-only account. - MCP guide —
db-inspectamong 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 theauthserverfeature 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 scopemcp, 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 withoutreset(), 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
whoamisection 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:diagnosticsstatus,migration-status,schema-drift,list-tables,query-schema,route-list,model-inspectstructure and state — names and shapes mcp:logslog-errors,log-analytics,request-debugwhat has been happening, as free text mcp:db_readdb-inspectrows, 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 implementsMcpToolInterface, which has no answer to who may call this, so none of them could be handed toPublicRegistrydirectly.
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-addwrites 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-testsread test artefacts a deployment does not have.framework-docs,find-symbol,console-commands,api-docs,theme-info,pramnos-checkanswer 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.
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 namesdiagnostics/logs/db, or full ones),--days,--urland--name.mcpis added whether asked for or not, sowhoamiis always reachable — a token that cannot ask what it is cannot diagnose itself. -
User::addScopedToken(), because a token needed to carry scopes andaddToken()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
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.