Version 1.15.2

August 18, 2026

Adds a test suite. No behaviour change, and upgrading gains a site nothing.

The plugin had no tests at all

Not a light gap on the component that runs inside a customer's Craft install and asks it to dump its own database. bin/verify-invariants.php proves a great deal about the shape of this plugin, that it registers no URL rules, cannot shell out, accepts no destination as a parameter and refuses an unpinned recovery key, but it reads the source and so it can only ever prove shape. Whether a function returns the right answer is behaviour, and nothing checked any.

Two pieces of logic are covered, chosen because both are closed sets whose whole value is what they say no to, and both are pure functions that need no Craft application to exercise:

  • Client::uploadHostFor(), which decides where an artifact may be sent. The cases are mostly refusals: an IP address, a bare label, an underscore, a trailing dot and an unparseable URL must all produce an empty string rather than a partial host, because the caller refuses on empty and half a destination is still a destination. One case asserts the function keeps taking exactly one string, since the safety argument in its own docblock rests on there being no other input.
  • Tasks::isKnown(), the gate in front of a task name that arrives from a queue payload. It is handed a method name, a path, a shell fragment and several near misses in the wrong case.

README.md now states what is covered and what is not, in a table, including the row that says the backup pipeline end to end is covered by nothing yet. That is deliberate: "no test directory" and "untested" are not the same thing, the encryption and signing are covered in manager-protocol where they are implemented, and a reader has no way to tell any of that from the outside.

ecs.php and phpstan.neon now include tests, so the new code is held to the same standard as the rest, and CI runs the suite on both Craft majors.

Version 1.15.1

August 17, 2026

Fixes the Craft licence state, which has been reported as unknown by every site since it was first sent.

Nothing to do on upgrade. The next report a site sends carries the real value.

The Craft licence was never actually read

The reporter asked Craft's cache for licenseKeyStatus. That is a Craft 3 key and exists in neither Craft 4 nor Craft 5, so the read missed every time, and the miss fell through to unknown — on every site, in every report, without exception. It now reads licenseInfo, where both supported majors keep it, and takes Craft's own state from under the craft key.

The plugin licence counts in the same section were always correct, because they go through Craft's getAllPluginInfo(), which reads the right key internally. So the signature of this bug was a sensible "3 of 4 valid" for plugins sitting beside a Craft licence that was permanently unknown, and it read as a missing permission rather than as a fault.

A consequence worth stating plainly: because a site could only ever report unknown, and the platform's licence_not_valid rule declines to raise a finding from that value, an invalid, mismatched or trial Craft licence has never once produced a finding. Sites in that state have been reporting it correctly to a platform that was reading a field the connector never filled in.

What is still unknown, legitimately

Craft only writes this cache after it has spoken to the Console API, so a site that has never checked for updates does not yet know its own licence state. Running the updates task populates it. Craft's fifth status, astray, also reports as unknown for now: the shared schema's enum has no member for it, and schemas are add-only, so carrying it is a protocol release rather than a line in this plugin.

Version 1.15.0

August 17, 2026

The runtime report can now describe the Craft install, not just the disk it sits on.

Nothing to do on upgrade. A site running this against a platform that has not been upgraded keeps sending exactly what it sent before, byte for byte - the platform says which report version it accepts and this plugin sends the newest that both sides know. Neither side has to move first.

What a site now reports, when the platform accepts system.v3

  • Craft. The number of distinct deprecation warnings it has recorded, the row count of its sessions table, whether a security key is configured, and whether the control panel is still at Craft's default trigger.
  • The database size. Total, including indexes. This is the figure that decides whether a backup finishes, and it was the one thing the report did not measure — it described every asset volume and the disk underneath them while saying nothing about the size of the thing being backed up.
  • Writable directories. Whether storage, cpresources and the project config directory can be written to.
  • Missing required extensions, and the image driver. Which of Craft's own published requirements are absent, and whether Imagick, GD or neither is available. none is a real answer and a serious one: a site in that state cannot generate a transform.

What is deliberately not sent

Each of these has a more useful shape that was refused, and system.v3.json carries the same reasoning on the wire contract itself:

  • A deprecation count, never the warnings. Each one names a template, a file and a line, and those are the site's own code. The count says whether there is a problem; finding it is done on the site, where the code already is.
  • A database total, never a per-table breakdown. Row counts describe the shape of somebody's content and table names often describe their business.
  • Whether the control panel moved, never where to. A site that moved its control panel moved it somewhere it would rather not have written down, and sending the new address to a dashboard would undo the change being reported. The schema has nowhere to put it either.
  • Whether a security key is set, never the key.
  • Missing extensions only from a closed list, so this cannot become the loaded-extension inventory that extensions is reduced to a count to avoid. Directory names come from a fixed set for the same reason a volume sends a handle rather than a path.
  • Nothing about response compression. It is decided at the edge, so a site behind a CDN would report its origin's answer rather than the one a visitor receives.

Requires

  • coysh-digital/manager-protocol moves to ^1.9, which is where system.v3 lives.

Version 1.14.1

August 9, 2026

Nothing about this plugin's behaviour changed. Same code on a site, same wire protocol, same invariants.

Build

  • CI pins its actions to commits rather than to tags. actions/checkout@v4 and shivammathur/setup-php@v2 are moving references: whoever controls those repositories can point them at different code without the tag changing, and that code runs in the job that decides whether this plugin is safe to publish. audit.yml already pinned commits and said so in a comment that ended "ci.yml in this repository still uses moving tags - that is worth correcting separately". This is separately. Both files now name the same two commits, and that comment now describes what is true.

    Little is reachable from these runs - contents: read, no secrets beyond GITHUB_TOKEN, no pull_request_target, nothing published - which is what made this worth doing calmly rather than urgently, and no reason to leave it.

Version 1.14.0

August 7, 2026

A backup requested in Manager used to sit there. This is the site learning to answer the door.

Manager can now ask a site to check in, instead of waiting to be called

Until now every exchange started here: the site called the platform on its own schedule, and work queued in Manager waited to be collected. With cron that is up to five minutes. Without cron the schedule runs off ordinary web traffic, so it waited for somebody to visit - which on a quiet site is not five minutes, it is however long it is. For that whole time the screen in Manager was correct and looked broken.

POST actions/manager-connector/nudge/poll is the site answering. It is the first anonymous endpoint this plugin has ever had, so it is worth being exact about what it can and cannot do.

Its entire vocabulary is "poll". It reads no parameters, refuses a request that arrives with a body, and does one thing: pushes the same jobs task the site already pushes on its own timer. What happens next is the ordinary signed claim that has always happened - the site asks the platform what is waiting and decides for itself what to do with the answer. Every check that matters is behind that claim and none of it moved: JobRunner still refuses job types it does not implement, RecipientPin still refuses recovery keys this site has not pinned, and the upload host is still derived rather than accepted. So the worst a forged, replayed or misdirected nudge achieves is an early poll.

There is nowhere to put an instruction, which is a stronger guarantee than validating one would be.

To answer at all, a nudge must carry an Ed25519 signature over MGR1-NUDGE, the site identifier, a timestamp and a nonce - verified against the platform public key this site pinned when it paired, never against a key from the request. The timestamp must be within two minutes, the nonce is single-use, and both the nonce store and the throttle are atomic cache claims that fail closed. Every refusal is an empty 401, whatever the reason: an unpaired site, a wrong identifier, a stale timestamp, a bad signature and a replayed nonce are indistinguishable from outside, so this cannot be used to learn which applied.

It runs the queue, and that is the point. Craft only arranges for a pushed job to actually run by injecting JavaScript into an HTML response, so the visitor's browser starts the runner. A request answering with an empty 202 gets none of that, so pushing without running would have improved nothing on exactly the cron-less sites this exists for. Running it is not new behaviour: Craft already ships queue/run as an anonymous endpoint on every site, and this does what that does after checking a signature that one does not. The job is pushed and then the queue is run rather than the task being called inline, so the queue's own reservation still applies and a nudge cannot cause a second concurrent dump of a production database.

runQueueAutomatically is honoured. An operator who turned it off did so to keep queue work out of web requests; on such a site the nudge pushes, answers 202, and their runner picks it up in seconds anyway.

Turning it off

'acceptNudges' => false in config/manager-connector.php. Config file only, like the recovery-key settings and for the same reason: turning it off is harmless, and turning it on from a hijacked control-panel session is not.

Nothing else changes when it is off. The endpoint refuses everything, the site keeps its own schedule, and a backup requested in Manager waits for the next check-in exactly as it did before.

The invariant this narrows, and what replaced it

Invariant 4 was "the connector exposes no public inbound endpoint". It is now "no public inbound endpoint that can carry an instruction", with exactly one anonymous endpoint permitted.

That is a real narrowing of a property that used to be one sentence anybody could check, so bin/verify-invariants.php gained a stricter rule set than the one it removed. A controller on the anonymous list must declare ALLOW_ANONYMOUS_LIVE rather than true, state that it opts out of CSRF rather than inherit it, read no request parameters at all rather than merely avoid five forbidden names, refuse a body, push exactly RunTask(['task' => Tasks::JOBS]) and name no other task or job type, answer only 202 or 401 with no body, never call the platform from inside the request, build and verify a CanonicalNudge against the stored pairing key, and claim both its nonce and its throttle through atomic add(). The set of controller files is asserted whole, so a second anonymous one cannot appear by being left off a list.

What was deliberately left out

  • Any way for the platform to say what to do. No job type, no capability, no destination, no parameters. The nudge is a knock, not a message.
  • A nudge that works when the site is offline. ALLOW_ANONYMOUS_LIVE, not true: a site that is offline is usually one somebody is working on, and it falls back to its own schedule.
  • A control-panel toggle. See above.
  • Any promise about reaching the site. Manager knocks on the address its operator typed. NAT, IP allowlists, WAFs and basic auth all mean it does not answer, and every one of those falls back to polling silently. Nudging is an optimisation and never a dependency.

Also

nudge_path now rides on the claim request, so the platform knows where to knock. A path, never a URL

  • the platform pins the host to the domain its operator typed and takes only the path from here, the mirror image of the rule this connector already applies to upload hosts. Sent on the claim rather than at pairing because pairing happens once: an actionTrigger rename or a move into a subfolder would otherwise break nudging silently and forever, whereas this is re-stated on every check-in and heals itself. Omitted entirely when acceptNudges is false.

Eight statements in the README and documentation had stopped being true and are corrected rather than softened: five said this plugin opens no inbound endpoint, and three said the platform has no way to reach a site at all. Each correction keeps the part that is still true, because it is the part people actually rely on - nothing depends on a nudge arriving, so a site behind NAT or a strict firewall still needs no inbound rule and simply keeps its own schedule.

Two of the eight were found only on a second pass, after the first search looked for the word "inbound" and missed three sentences that made the same claim in different words. Worth recording, because a statement that is confidently wrong is worse than no statement at all.

Requires coysh-digital/manager-protocol 1.8.0 for CanonicalNudge.

Version 1.13.1

August 7, 2026

One request in 1.13.0 was given ten seconds to do a job measured in gigabytes.

Asking the platform to assemble an artifact no longer times out at ten seconds

POST .../assembled went out on the ordinary request budget, which is right for a heartbeat and wrong for this: the platform hashes the whole reassembled artifact against the checksum in the manifest this site signed before it answers, and on anything past a few hundred megabytes that takes longer than ten seconds. It now uses uploadTimeout, the same budget the parts themselves get. connect_timeout is unchanged and still short - a platform that will not answer the socket must never become a slow website.

The failure was worse than a slow request, which is why this is worth upgrading for. The connector gave up, reported the job failed, and the platform then settled the artifact as failed and deleted the parts - while its own assembly was still running and about to store them. A backup that had uploaded correctly was thrown away by the side that had already finished it. Reported live as

cURL error 28: Operation timed out after 10003 milliseconds with 0 bytes received

alongside a No such file or directory in the platform's log naming the staging file the connector had just caused it to delete.

Sites on a platform older than Manager 1.3.0 never reach this path and are unaffected.

Version 1.13.0

August 7, 2026

A backup no longer travels as one enormous request, which is the difference between a large database being backed up and not.

Also carried here, unchanged and never released: the two defects and four false statements that were written up under 1.12.2. That heading has been renamed rather than added to. 1.12.2 was never tagged, so it has reached nobody, and a patch number describing a new upload path would not have been a number anybody could honestly tag - which is the one property the version rule exists to keep. Its entry is the last section below, as it stood.

A backup now reaches the platform in parts, not just an object store

1.11.0 taught this plugin to upload a large artifact as a sequence of presigned parts, but only on the direct-to-storage path - the one that needs a bucket the operator has pointed DNS at. Every site without one, which is most of them and every self-hosted installation, still sent the whole artifact to the platform in a single request. This is that same idea applied to the path almost everybody actually uses.

The artifact itself does not change. Same encryption, same manifest, same signature, same file manager-restore opens, same bytes in storage. What changes is that this site sends it in bounded pieces and then asks the platform to put them together, instead of holding one request open for as long as a customer's database takes to travel.

That request was the problem, and the failure it produced was hard to read. A site was refused with

The platform rejected the artifact (HTTP 502)

which is not a refusal at all - it means a web server or PHP process on the platform host stopped answering while the body was still arriving. It is a timeout rather than a size limit, and it cannot be fixed from the platform's application code either: php-fpm's request_terminate_timeout ends the process from outside PHP. A request carrying a few megabytes never reaches it, whatever the size of the database.

Each part is retried on its own. A connection dropped near the end of a twenty-gigabyte upload now costs that part rather than the whole transfer, and if this site loses track of where it was, the platform answers with the part to continue from instead of a bare refusal. Parts are sent one at a time and never in parallel - this runs on somebody's production server, and saturating their uplink to finish sooner is not this plugin's decision to make.

Nothing has to be upgraded in step. The platform says whether it accepts parts, per artifact, and a platform too old to say so receives the whole file in one request exactly as before. Requires Manager 1.3.0 or later on the platform side to take effect; against anything older this version behaves identically to 1.12.x.

Fixed

  • A 502 no longer sends you to look at the body size limit. The message that explains an error page from something in front of the platform said "check the upload body size limit" whatever the status was. That is right for a 413 and wrong for a 502, which is a timeout in a different setting in a different file - and telling somebody to check the wrong one costs as much as telling them nothing. The two now read differently.

  • The queue reservation would have become the new wall. uploadTimeout used to bound a whole upload, so the reservation derived from it was never reached - Guzzle ended the transfer first. It is now a per-request budget, which makes an upload of several hours possible for the first time, at which point a backup would have died partway through with the queue starting a second dump of a production database. The reservation is now at least six hours, matching the window a platform gives a declared artifact to arrive.

Everything below was written up as 1.12.2, and ships here instead

Two defects in code that runs inside customers' production Craft sites, four statements this plugin made about itself that had stopped being true - and the pairing fix that 1.12.1's notes described but 1.12.1 does not contain. All of it is unchanged; only the number it arrives under has moved.

First, a correction to 1.12.1

The tag published as 1.12.1 does not include "A control panel's address is not necessarily an upload address", which its own release notes below credit it with. The release commit that bumped the version and wrote those notes went in on 4 August; the code merged the next day, behind it. A tag cannot be changed, so 1.12.1 stays as published and the fix arrives here instead.

If you are on 1.12.1 and pairing prefilled a control-panel hostname, this is the version that fixes it. The notes for it are unchanged and are still below, because they describe the change accurately - only the version they sit under was wrong.

Nothing warned about this and nothing would have: no check compares a tag against the changelog section naming it. What stops a repeat is procedural rather than automated - the version and its notes now move in the same pull request as the code, so there is no window in which they can be in different commits.

A failed encryption left an encrypted copy of the database on disk

Two paths, one cause. The caller's finally shreds the dump and whatever the method returns, so a throw inside it returns nothing and the temporaries survive: a throw during encryption left the .stream, a complete encrypted copy with no manifest, and a throw during envelope assembly left a partial .artifact.

The likeliest cause is what makes it worst. Both fail part-way when the disk fills, so every retry added another encrypted copy to a disk that was already full, until nothing could be written at all and the site had neither backups nor space. Both now go with the exception.

bin/verify-invariants.php asserts this cleanup and passed throughout, because it matches the shape in the caller - which is correct for the case it covers and cannot see inside a method that throws.

There is nothing to clean up by hand unless a backup has already failed this way. If one has, look for orphaned .stream and .artifact files in the connector's temporary directory; they are encrypted, so they are not a disclosure, but they are occupying space nothing will reclaim.

Two of three HTTP clients followed redirects

The direct-upload path sets allow_redirects => false and verify => true, with the reasoning written out beside them: a storage service answering with a redirect must not send a customer's database somewhere else, and a "disable certificate checking" option exists to be switched on during a support call and left on.

Neither was applied to putFile(), which uploads the same artifact through the platform, nor to send(), which makes every other signed request. Guzzle follows redirects by default, so a 302 in front of the platform would have re-sent the whole artifact wherever it pointed, with the site's signature attached.

verify is now stated rather than left to the default, because Craft::createGuzzleClient merges config/guzzle.php - so an installation that turned certificate verification off globally turned it off here too, silently.

Four statements corrected

  • config.php said an empty backupUploadHost disabled direct uploads and sent artifacts through Manager. It has derived uploads.<platform-host> since the derivation was added, so an operator reading it would decline to set a value believing artifacts went nowhere but the platform.
  • The control-panel screen said backups go "to the platform this site is paired with and nowhere else". The half that matters is still true and now says only that: where the artifact goes is decided here, and no instruction from the platform can change it.
  • UpdatesController said release notes are never sent. They are, bounded per note and across the report.
  • "Credentials rotated" showed the pairing date beside a keypair with no rotation mechanism, which reads as a key being cycled. It is now "Signing key generated", and says that re-pairing replaces it.

The version is now checked in four places rather than two

bin/verify-invariants.php already compared composer.json against Plugin::VERSION, because those two had drifted once and Packagist published nothing at all as a result. Since 1.12.1 the version is also written into README.md and docs/installation.md, as the pinned constraint in the documented install command, and nothing was comparing those - the commit that added them said so and left it.

All four are now checked, along with the top heading of this file. A stale install command is a worse failure than a stale manifest: it is the line somebody pastes into a production site, and it silently installs the version before the fix they came for.

Also in here

Advisories are scanned on a schedule rather than only when somebody opens a pull request; the install command in both documentation files is pinned and each of its flags explained; and typographic punctuation across prose is now plain ASCII, which touched no identifier and no value that crosses a wire.