Skip to main content
WintertraceLive demo
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 changedWhat it does to a module
Stored files are private by default; url() returns a signed, permission-checked linkA 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-denyA 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 onlyAnything 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 resourcesAn <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 settingA 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 typeA 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 modules row, 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 the Formatter; every operator-typed number goes through parseDecimal()
  • Any module-owned notification type has a Mailable registered through MailableRegistry
  • No authorisation check reads users.role