Skip to content

11 August 2026

6 changes:

  • Five rolled-up views were frozen on every database without TimescaleDB
  • A model may give load() whatever parameters it needs
  • Five developer panels had been empty for years, and nothing said why
  • The rate limiter counted the proxy, and counted it badly
  • A human check that prices spam instead of pretending to detect it
  • getInstance() is a factory, and two call sites forgot

Five rolled-up views were frozen on every database without TimescaleDB

They existed. They answered every query. And every answer was the one they gave on the day the migration created them.

Fixed

Four migrations create the same thing twice: a TimescaleDB continuous aggregate where the extension is present, and a plain materialized view where it is not. Only the first branch registered a refresh policy.

$schema->ifCapable(TIMESCALEDB,
    function () use ($schema) {
        $schema->createContinuousAggregate('authserver.daily_activity_summary', …);
        $schema->addContinuousAggregatePolicy(…);   // ← the refresh
    },
    function () use ($schema) {
        $schema->createMaterializedView('authserver.daily_activity_summary', $sql);
    }                                                // ← no refresh, ever
);

PostgreSQL never refreshes a materialized view on its own. So on plain PostgreSQL and MySQL, daily_activity_summary, daily_2fa_stats, tokenactions_hourly, application_stats_daily and application_stats_hourly were stale from the moment they were created — which is worse than missing. A view that is not there fails and gets noticed; one that quietly returns month-old numbers gets believed.

The mechanism was never missing. addContinuousAggregatePolicy() already branches by backend: a native job on TimescaleDB, a row in pramnos.framework_policies executed by the policy engine everywhere else. It simply was not called on the second path.

The parameters now live in Pramnos\Database\ContinuousAggregateRegistry, and the call sits outside the capability check, so both branches get a refresh. A test reads the migration files and fails if anyone puts it back inside one — nothing in code shows the absence of a call, which is why this survived.

Added

timescale:ensure now repairs these too, and — the part that matters — it does that before deciding whether TimescaleDB is present. The command used to report "no extension here" and stop, which meant it refused to run on exactly the backend whose views were frozen. --dry-run lists the views with no refresh and says what stale means for them.

2026_08_11_000001 does the same automatically, because the four migrations are recorded as applied on every installation that already ran them and will never run again — the same gap the repair exists to close. It adds nothing to a view that already refreshes, and creates no view that is not there.

Two supporting methods on SchemaBuilder:

  • hasView() — a continuous aggregate is not a table and neither is a materialized view, so hasTable() finds none of them, and asking it about an aggregate quietly answers no.
  • hasContinuousAggregatePolicy() — on TimescaleDB the refresh job cannot be found by the view's name. timescaledb_information.jobs records the materialization hypertable (_timescaledb_internal._materialized_hypertable_N), so the lookup goes through continuous_aggregates. Written the obvious way, the check answers "no policy" for every aggregate that has one — and a repair built on it would add a second policy on every run.

Verified against both backends, including a PostgreSQL database with the TimescaleDB extension dropped — what an installation without it actually looks like, rather than a simulation of one.

A model may give load() whatever parameters it needs

The listing helpers used to find a model's table by calling $this->load(0) — not to load anything, but hoping the subclass would set $_dbtable as a side effect. That quietly required every model's load() to accept exactly one argument.

Fixed

Three places in Model did the same thing: when $_dbtable was unset, call load(0) and hope. It coupled table discovery to record loading, issued a pointless lookup for id 0, and assumed a signature the base has no business assuming. A model written as load($username, $type) got an ArgumentCountError raised from inside a framework method, about a call its author never made.

The assumption could not simply be enforced, because enforcing it would cost more than it saved. PHP only lets a child add optional parameters, so any declaration in the base rejects a child that needs its own:

abstract load($id)   → child load($id, $x = null)     allowed
abstract load($id)   → child load($user, $type)       Fatal: must be compatible
abstract load(...$a) → child load($id)                Fatal: must be compatible
load($id = null)     → child load($user, $type)       Fatal: must be compatible

Not even a variadic declaration is neutral. Pramnos\Auth\Application already loads by id plus two options, and models are expected to keep that freedom.

So load() stays undeclared and the base gets a hook of its own:

/** Set $_dbtable here when the model works its name out at runtime. */
protected function initTable(): void
{
    $this->_dbtable = 'readings_' . $this->tenant;
}

Models that declare protected $_dbtable — nearly all of them — need nothing.

Models still written the old way keep working: when neither a property nor initTable() produced a name, the base falls back to the historical load(0) call. It first checks, by reflection, that load() can accept one argument. A model whose load() needs more gets a LogicException naming the class and the fix, instead of an ArgumentCountError from a call that should never have been attempted — and the call is not attempted.

What each caller does with an unresolved table is unchanged: _getJsonList() still returns an empty list, the others still skip the query. Turning that into an exception would have changed three public methods for no gain.

Five developer panels had been empty for years, and nothing said why

They queried a table called tokens. The framework's is called usertokens. Every query threw, an empty catch swallowed it, and the panel rendered as "nothing to show".

Fixed

The DevPanel's Active Sessions, Login Lockouts, Token detail, Slow users and Queue stats sections were broken on every installation. Not subtly:

Asked for Actually exists
tokens usertokens
last_used, ip_address lastused, ipaddress
tokentype IN (1,3) a text column — auth, access_token
loginlockouts with identifier, lockout_until authserver.loginlockouts with displayvalue, lockoutuntil
queue_jobs queueitems
{$prefix} from a PREFIX constant never defined anywhere in the framework

The test above them was green, because an empty panel still contains its own headings. It asserts on the values now.

SchemaBuilder's TimescaleDB methods no longer return false for two different things. They returned it when the backend lacked the extension — documented, deliberate — and when the statement failed. Both silent, so a migration whose createHypertable() failed looked exactly like one running on MySQL, and the table stayed unpartitioned with nothing anywhere saying so. The signature is unchanged; what changed is that only the no-op is quiet now, and a real failure is logged with the statement that produced it.

keys() says whether it can look. Adapters that cannot enumerate — File, Array, Memcached — returned [], which reads exactly like "nothing matched". supportsKeyEnumeration() answers the question directly, and FlatCache says so once in the log rather than handing back a convincing empty list.

ApiAccount::revokeToken() is now actually tested. It carried @codeCoverageIgnore — exercised via integration, not unit tests, and no such test existed. Rather than delete the claim, three integration tests make it true: the row is deactivated, the token stops identifying its user, and revoking an unknown token is harmless. That last one matters because API tokens have no expiry by default on existing installations — revocation is the only thing that ends a session.

Every silent catch in src/ now says why the failure does not matter. There were 68; 30 said nothing at all. Swallowing an exception is sometimes right — instrumentation must not break a response, a webhook must answer even if its log write fails — but doing it wordlessly leaves the next reader unable to tell a considered decision from an unfinished one.

Added

SilentFailureTest reads the whole of src/ and fails when a catch discards an exception without a comment, or when a coverage exclusion promises tests that do not mention the class. It chases no particular bug; it makes the shape expensive. Every serious finding in this audit had that shape — a failure that looked like a result.

The rate limiter counted the proxy, and counted it badly

Behind a reverse proxy every visitor shared one bucket, so the limit fired for everybody at once. Reading X-Forwarded-For to fix that would have been worse: the header is written by the client.

Added

Request::clientIp() — one answer to "who is the client".

The framework had no answer. It had seventeen inline reads of $_SERVER['REMOTE_ADDR'] with four different fallbacks ('0.0.0.0', '', 'none', 'unknown'), and three places that read CF-Connecting-IP with no check at all.

REMOTE_ADDR is the connecting peer. Behind a proxy, a CDN or a load balancer that peer is the proxy — one address for the whole world. A per-IP rate limit becomes a global one, and anything binding to the address binds every visitor to the same value.

The obvious repair is a total bypass, which is the interesting part. A client that sets a fresh random X-Forwarded-For on every request gets a fresh bucket every time and defeats the limiter completely — while the logs show a healthy spread of addresses and the limiter reports that it is working. A control that appears to work and does not is worse than no control at all.

So a forwarding header is believed only when the peer that delivered it is itself a trusted proxy, and the chain is walked from the right — the end the infrastructure appended — taking the first address that is not a trusted hop. The leftmost entry is the client-supplied end of the chain and is never trusted.

// app.php — nothing is trusted by default
'trusted_proxies' => ['private_ranges'],
'trusted_proxies' => ['cloudflare'],
'trusted_proxies' => ['10.0.0.0/8', '2001:db8::/32', '192.0.2.7'],

With the list empty the answer is REMOTE_ADDR, unchanged. That is both the safe default and the previous behaviour, so an application that does not opt in sees no difference.

X-Real-IP is deliberately not consulted: it is single-valued, so there is no chain to walk. Forwarded (RFC 7239) is understood, including its obfuscated for=_hidden identifiers, which are dropped rather than used as an address.

Cache::increment() — an atomic counter. Redis INCRBY and Memcached increment, with supportsAtomicCounter() to ask first. The Memcached adapter had no counter at all; it creates one through add, which is atomic, so a race to create it loses no increments either way.

supportsAtomicCounter() is a declaration on the adapter, not a method_exists() probe — and that distinction was itself a bug in the first version of this work. Every adapter inherits a working increment() from AbstractAdapter, so probing for the method reported the File adapter as atomic and sent the limiter down the exact-counting path on a backend that loses increments. The middleware's own tests could not have caught it: they used doubles that answered the question themselves. AtomicCounterCapabilityTest asks the real adapters instead.

TooManyRequestsException. An \Exception with code 429 — so every existing handler is unaffected — that carries a Retry-After value.

Fixed

The sliding window undercounted exactly when it mattered. RateLimitMiddleware did load → filter → count → append → save with no lock and no compare-and-set. Two concurrent requests both read the same list, both append, and the second save overwrites the first. Under a burst of N simultaneous requests the stored count could advance by as little as 1 — and a flood is concurrent by definition, while the slow trickle it counted perfectly is the case nobody needed protection from.

Where the cache can count atomically the limit is now a fixed window on the server's own counter, which is exact under concurrency. The trade is at the window boundary: up to 2× the limit can pass across one. For a spam gate that is the better bargain.

Where it cannot — the Array and File adapters — the sliding window remains, and RateLimitConcurrencyTest measures the loss rather than asserting it away. The docblock says the count is approximate there. Documented slop is defensible; silent slop in a security control is not.

ThrottleMiddleware had the same shape and now counts through apcu_inc(), which creates and increments in one operation. An application that overrode its storage seams keeps its own behaviour — routing around a subclass's storage would be a worse bug than the race it fixes.

The Redis counter's expiry no longer slides by default. RedisAdapter::increment() reset the TTL on every call, so a key under sustained traffic never expired: the count climbed for ever and the client stayed locked out permanently. The expiry is now applied by the call that creates the key. FlatCache asks for the sliding behaviour explicitly, because that is what its own documented contract promises and applications rely on it for login-attempt counters.

A dropped Redis connection no longer means no limit. increment() returns false for "the counter did not work", which is not zero. Reading it as an empty bucket would open the door at the moment the site is under strain.

Retry-After goes through the response. Both limiters emitted it with a bare header() call — invisible to anything inspecting or buffering the response, and silent in CLI and tests. It is now carried on the exception and set by ExceptionHandler::render(), and it says how much of the window is actually left rather than always the full length.

CF-Connecting-IP was trusted unconditionally in three placesUser\Token, SessionTrackingMiddleware and the System\Session addon — so any client could dictate the address written into its own session and token records. Those records read as evidence. They now go through the resolver.

Installations behind Cloudflare must configure this

This is the one change that needs action. An application relying on the old unconditional CF-Connecting-IP read will record the Cloudflare edge address instead of the visitor's until it sets 'trusted_proxies' => ['cloudflare'] in app.php. Nothing breaks, but the addresses in new session and token rows are wrong until it does.

ClientIpResolver::CLOUDFLARE_RANGES is a snapshot of the published list and does change; an installation that cares should pin its own copy.

A human check that prices spam instead of pretending to detect it

The framework now ships a proof-of-work check for public, unauthenticated writes. It is not a CAPTCHA, and the reason is the same reason this codebase keeps finding bugs: a control that appears to work and does not is worse than no control at all.

Why not a CAPTCHA

Distorted text is solved by commodity OCR, and by any vision model, at rates that make it decorative. An image grid needs a labelled dataset nobody has. Either would be defeated for free by a script while every application that adopted it believed itself protected — and the cost would be paid by real users, disproportionately those using a screen reader.

Hosted options solve the detection problem by putting a third party's script on the first page a visitor ever sees, and sending their traffic there. For an application that wanted to answer "must we take a third party onto the signup page?" with a permanent no, that is not an option either.

Added

Pramnos\Security\HumanCheck — proof-of-work. The client is given a challenge and must find a nonce whose SHA-256 begins with a required number of zero bits. There is no shortcut: solving it is paying the cost.

$check     = new HumanCheck(difficultyMs: 300);
$challenge = $check->challenge();           // hand to the page

// on submit
if (!$check->verify($submitted['challenge'], $submitted['solution'])) {
    // refuse
}

The properties are the opposite of a puzzle's. It is arithmetic rather than perception, so there is nothing a model can recognise better than an honest client can compute. Nothing leaves the server. It runs in a Web Worker while the visitor is still typing, so by submit time it is normally already done. And there is nothing to perceive, so it is accessible by construction.

What it does not do, stated in the class docblock as well as here: it does not stop spam, it prices it. An attacker with a botnet and free CPU still gets through. What changes is that a thousand signups cost real compute instead of nothing. That is the right defence against volume and no defence at all against a targeted attack — code reading human_check: true must not conclude more than "this submission cost something", and in particular must not conclude that a human was involved.

It costs the visitor battery. Difficulty is therefore expressed in milliseconds of work on a mid-range phone rather than as a leading-zero count nobody can reason about, defaults to a modest 300ms, and is set per call site — a signup form and a login form do not deserve the same cost. The assumed hash rate is deliberately pessimistic: guessing high would make "300ms" mean several seconds on the slowest devices, which are the ones least able to spare it.

The challenge stores nothing. It is HMAC-signed and carries its own difficulty and expiry, so handing one out costs a hash and cannot be used to fill a cache. Editing the difficulty invalidates the signature — without that, a client simply asks for zero work.

It is single-use, atomically. A solved challenge replayed a thousand times is the obvious bypass, and without this the check costs an attacker one unit of work in total. The claim goes through Cache::increment(), so first-use-wins is one indivisible operation rather than a read-then-write that two simultaneous replays would both pass. On adapters that cannot count atomically the fallback closes the replay window but not the race — an installation using this to do security work belongs on Redis or Memcached, and the docblock says so.

scaffolding/assets/js/pf-humancheck.js — the client, dependency-free and served from the application's own origin. A proof-of-work widget that loads from a CDN has given back the property it existed to provide. The worker is built from a blob of the file's own source, so there is no second file to keep in step, and it reports progress every 20,000 hashes for the slow devices where this takes a visible moment.

Not built, deliberately

A signed single-use form token and a submission-timing floor were considered alongside. Both are nearly free and both are honest only if labelled as filters rather than controls — each is trivially bypassed by anyone who looks. The form token shares its entire implementation with the challenge above, so an application wanting one can mint a HumanCheck challenge at difficulty zero and verify it on submit. A honeypot field is the weakest of the three and is worth a line of markup, never a line in a security report.

getInstance() is a factory, and two call sites forgot

Reading one setting could build an entire application — database, language and session included. On the connection path that meant querying the database through the connection still being opened.

Added

Application::currentInstance(): ?self — the current application if one exists, without creating one.

$app = \Pramnos\Application\Application::currentInstance();
if ($app === null) {
    // no application: fall back, do not build one
}

getInstance() is a factory. Given no existing instance it reads app.php, defines constants and runs the whole constructor, which sets up the database, the language and the session. That is correct for a caller that wants an application, and wrong for one that only wants to read a setting.

Low-level code should use currentInstance(). The distinction is not academic: both bugs below were the same mistake, made in the same change.

Fixed

A CSRF check could boot an application. Session::getFingerprint() began asking for the trusted-proxy list, which reads application config. CSRF verification and rate-limit middleware run before the request has decided anything — they are the two places least able to absorb a side effect of that size. A reference application's login tests failed on "security token invalid or expired" because a second application was being constructed underneath them.

The connection path resolved configuration. Database::setTrackingInfo() runs while the PostgreSQL connection is being opened, stamping tracing variables onto it, and called Application::getInstance() to read the application name. Building an application there sets up Settings, which queries the database — through the connection still being established. It surfaced as a MySQL-quoted statement arriving at PostgreSQL:

ERROR:  syntax error at or near ","
LINE 1: select `setting`, `value` from `settings`

Backticks untranslated and #PREFIX# empty: the signature of a query issued on a connection that did not yet know its own driver. The !== null check beside the call had already been written as though it could not construct anything; now it cannot.

An empty REMOTE_ADDR is not an absent one. The CSRF fingerprint was $_SERVER['REMOTE_ADDR'] ?? 'none', and ?? does not fire for the empty string, so an empty value hashed as ''. A rewrite that substituted 'none' for both cases changed the fingerprint — and the fingerprint is hashed into a token issued by one request and verified by the next, so every form in flight would have broken at deploy. The fallback now reproduces the original expression exactly.

User serializes through __serialize(). It used __sleep(), which returns property names that PHP then looks up on the object. Private properties are stored under a mangled name, so serializing a subclass instance — the normal case, since applications extend this class — emitted "_userstable returned as member variable from __sleep() but does not exist" for every private property, on every serialize.

If you overrode __sleep() on a User subclass

It is no longer called: __serialize() takes precedence. Rename your override to __serialize() (returning the data array rather than a list of names) and add the matching __unserialize(). The plaintext password is still excluded, which is what this machinery exists for.

Added — a guard for the whole class of error

ConnectionPathPurityTest reads the source and fails when anything on the connection-establishment path calls a configuration lookup. It is a source check rather than a runtime one because the bug is structural: the call is wrong even on the runs where it happens to work.

It earned its place immediately. It was written to cover a defect this work had introduced, and the first thing it reported was the older one in setTrackingInfo() that had been sitting there unnoticed.

How this was found

None of it by this framework's own suite, which is green at 8836 tests. All four defects came from running a reference application's 5401-test suite against the framework — a second application to construct, a REMOTE_ADDR set to an empty string, a User subclass to serialize, a live connection to open. The framework alone has none of those.

Both versions were then run against the same database: pinned release 5401 OK, this branch 5401 OK, identical assertion counts.