Browse the documentation
Building modules
Upgrading a module to 1.2.0
Six contract changes in Wintertrace 1.2.0 that affect existing modules. None of them raise an error at boot — most fail silently in the direction that looks fine.
Read this page if you last worked on a module before version 1.2.0.
That release hardens security and turns formatting into an installation-wide setting. Six of its changes alter contracts your module may already rely on. The awkward part is that none of them announce themselves with an error at boot — most fail silently, and in the direction that looks fine from the outside.
Work through the table, then follow the link for whichever row applies to you.
| What changed | What it does to a module |
|---|---|
Stored files are private by default; url() returns a signed, permission-checked link | A module that stores files must register its path prefix with MediaAccessRegistry, or its files are served to nobody and migrated for nobody. See Registries and Storage & backup |
| Module boot is default-deny | A hand-copied modules/<slug> directory does not boot, and does not appear in the admin interface either. Local development needs a modules row created by hand. See Lifecycle |
| The module API’s session fallback is administrators only | Anything reached from the driver or customer portal needs an ordinary permission-gated route; anything reached by a third party needs a token or its own authentication. See Routes & APIs |
| dompdf no longer loads remote resources | An <img src="https://…"> in HTML injected through the PDF hooks stops rendering. Inline the bytes as a data: URI. See PDF & reports |
| Currency, dates, times and units follow an installation-wide setting | A module that formats a value itself now overrides the operator’s choice — a US installation reads Kilogramm on your screen and pounds everywhere else. Store canonical, print through format_*() or the Formatter. See Formats & units |
| The email channel no longer sends the same mail for every notification type | A module that dispatched its own type through NotificationChannelRegistry used to get a wrong “job completed” mail. It now gets silence, unless it registers a Mailable for that type through MailableRegistry. See Registries and Notifications |
The one to check even if none of the above applies
Never authorise against users.role. That column defaults to 'driver' for every row and has not been maintained since roles were reworked, so a check against it passes for accounts that are not drivers at all. This was a real finding in a penetration test of 1.1.9: manual jobs could be booked onto non-driver accounts through nothing more than parameter tampering.
Use hasRole(), isAdmin(), or the pivot scopes instead. The details are in Core data model.
Why the silence
Each of these changes tightens a default. A tightened default is safe for the core and quiet for everyone else — the file is simply not served, the mail is simply not sent, the module simply does not boot. Nothing throws, because from the core’s point of view nothing went wrong.
That is the right behaviour for a security boundary and an inconvenient one for a module author, which is why this page exists. If something in your module stopped working after the update and left no trace in the log, start here rather than in the stack trace.
Two worked examples
Both of the modules that ship alongside the core needed their own release for this, and the shapes of the two problems are worth knowing.
A link that a third party fetches. The Telegram module put a photo URL into its outbox, and Telegram’s own servers then fetched that URL — server-side, with no session. Under the new default-deny rule that request is correctly refused with 403, so photo messages stopped arriving while text and location messages carried on fine. The fix was to stop sending a link at all and upload the image bytes instead.
The general rule: never hand a signed media link to a service that fetches it server-side. Read the bytes and send those.
There is a second, quieter reason. A signed link expires after 24 hours, and a queued message may sit for longer than that. Storing the relative storage path and resolving the bytes at send time survives the delay; storing a URL does not.
A prefix that was never registered. The Documents module stored files under dokumente/ without registering that prefix. Until it did, two things were true at once: documents uploaded before the core update stayed on the public storage path where the migrator could not see them, and new ones were served to nobody. One registration call fixed both, because the same prefix list governs delivery, the refusal of the legacy /storage/{path} route, and the scope of the background migration.
Checklist
- Every path prefix the module stores files under is registered with
MediaAccessRegistry - No media URL is handed to anything that fetches it server-side
- Local development installs have a
modulesrow, not just a directory - Nothing reachable from the driver or customer portal relies on the module API session fallback
- PDF hook output contains no remote image sources
- Every displayed value goes through
format_*()or theFormatter; every operator-typed number goes throughparseDecimal() - Any module-owned notification type has a
Mailableregistered throughMailableRegistry - No authorisation check reads
users.role