This release includes breaking changes for platform teams planning a safe upgrade.
✓ No known CVEs patched in this version
Topics
+2 more
Summary
AI summaryBroad release touches https://github.com/wekan/wekan/commit/9f23e6384bdfa5ca81be18e4f0426c347e9894eb, wekan/mongo-tools, https://github.com/wekan/wekan/commit/143607ef22ae91803cb611053fd4ce00754e7096, and https://github.com/wekan/wekan/commit/66d8706c4d2afab4d127f8c63e05d43407efaf30.
Full changelog
v9.88 2026-07-13 WeKan ® release
This release adds the following new features:
- Added docs about new Sandstorm WeKan.
Thanks to xet7.
and fixes the following bugs:
- Sandstorm build: fixed "not writing through dangling symlink" that failed the
sandstorm.ymlworkflow atsandstorm-src/build-deps.shstep [3/7]. The
meteor-spk 0.6.0 base ships some runtime libs inmeteor-spk.deps/libas
DANGLING symlinks (e.g.libstdc++.so.6→ a library that no longer exists), so
refreshing them with the host's newer libs failed:cp -fLrefuses to write
through a dangling destination symlink. Added--remove-destinationsocp
deletes the existing destination (dangling symlink included) before copying the
host's real library. Thanks to xet7. - Sandstorm build: write the signing keyring to the path
meteor-spk pack
actually reads. Thesandstorm.yml"Restore the Sandstorm signing keyring"
step wrote the decodedSANDSTORM_KEYRINGsecret to~/.sandstorm/sandstorm-keyring,
butmeteor-spk/spkread the app private key from~/.sandstorm-keyring(a
file directly in$HOME), so packing aborted with
open(~/.sandstorm-keyring): No such file or directoryeven when the secret was
set. Now it writes~/.sandstorm-keyring, and — since the.spkcannot be signed
without it — fails early with an actionable message when the secret is missing,
instead of the cryptic later crash. Thanks to xet7. - Sandstorm .spk: trim it back toward Cloudflare's 100 MB upload limit. The
Sandstorm build no longer bundles the ~300 MB of modern MongoDB Database Tools
(wekan/mongo-tools) — they are unused in Sandstorm (grains back up/restore
themselves; the MongoDB 3 → FerretDB migration uses the legacy migratemongo CLIs- the
.mjsimporter). Also setPUPPETEER_SKIP_DOWNLOAD=1for the Meteor build
(puppeteer is a test-only devDependency, so its ~150 MB Chromium never belongs in
the.spk), and the pack step now prints the.spksize and warns if it exceeds
100 MB. (The Database Tools remain in the WeKan.zipbundle / Docker / Snap,
which do use them.) Thanks to xet7.
- the
- Sandstorm .spk: bundle the matching glibc dynamic loader so grains start:
the.spkbundled the host's new glibclibc.so.6(Ubuntu 24.04, glibc 2.39)
but kept the old glibc 2.31ld-linuxfrom the meteor-spk 0.6.0 base, sonode
failed at startup withlibc.so.6: undefined symbol: _dl_audit_symbind_alt, version GLIBC_PRIVATE(HTTP-BRIDGE exit 127) and the grain crash-looped.
sandstorm-src/build-deps.shnow also copies the host'sld-linux-x86-64.so.2
so the loader and libc are the same glibc, and adds a[verify]gate that fails
the build if they differ or the bundlednodecannot run under them — turning a
silent grain crash-loop into a loud build failure. Thanks to xet7. - Sandstorm build workflow: enable unprivileged user namespaces and build the dispatched branch:
Ubuntu 24.04 defaultskernel.apparmor_restrict_unprivileged_userns=1, which
blocks the unprivileged user namespaces the Sandstorm install and thespk
supervisor rely on, sosandstorm.ymlnow relaxes it on the runner. A newref
workflow_dispatchinput also lets the workflow build a fix branch before it is
merged, instead of a hardcodedmain. Thanks to xet7. - Meteor unit tests: fix server-boot crash from
__dirnamein an ESM test file:
server/lib/tests/dependencies.openapi.tests.jsis an ES module that referenced
the bare__dirnameglobal; under Node 24 / Meteor 3.5 the compiler injects
const __dirname = fileURLToPath(import.meta.url), colliding with the__dirname
the CommonJS module wrapper already provides, so the server bundle failed to boot
withIdentifier '__dirname' has already been declaredand the "Meteor unit
tests" CI job died before any test ran. Drop the__dirnameseed
(process.env.PWDalready reaches the repo root undermeteor test). Thanks to xet7. - Sandstorm .spk: point FerretDB state dir to writable /var so the grain starts:
FerretDB persists astate.json(version/UUID) via its state provider even with
telemetry disabled, and its--state-dirdefaults to.— which in a Sandstorm
grain is/, read-only. So FerretDB failed withFailed to create state provider: failed to persist state: open /state.json: read-only file system,
exited (code 1), and the grain crash-looped.sandstorm-src/start.jsnow creates
/var/ferretdband passes--state-dir=/var/ferretdb(plusFERRETDB_STATE_DIR)
instartFerret(), covering both the migration and steady-state FerretDB launches.
Thanks to xet7. - Sandstorm .spk: don't crash the grain when capnp.node can't load on Node 24:
WeKan on Sandstorm bundles Node 24, butcapnp.node(node-capnp) is built for an
older Node ABI (NODE_MODULE_VERSION 83 = Node 14), soNpm.require('capnp')in
sandstorm.jsfailed withERR_DLOPEN_FAILEDand crash-looped the whole grain on
boot. Cap'n Proto is now loaded lazily in atry/catchand degrades gracefully:
the grain boots and core WeKan works, because login and user identity come from
the sandstorm-http-bridgeX-Sandstorm-*HTTP headers (wekan-accounts-sandstorm)
and need no Cap'n Proto. Only the two capnp-only features are skipped when the
addon cannot load — the Powerbox identity-claim method and Sandstorm activity
notifications — with a clear warning. They can be restored by rebuilding node-capnp
for Node 24, or reimplemented over the bridge's HTTP/JSON API. Thanks to xet7. - Snap release: build amd64+arm64 natively on GitHub, exotic arches non-blocking on Launchpad:
the single snap job ransnapcraft remote-buildfor all 5 platforms on Launchpad
and blocked until the slowest resolved, so the release hung ~3.5h on riscv64's
Launchpad queue even though amd64+arm64 finished in minutes. It is now split in
two:snap-nativebuilds amd64 (ubuntu-24.04) and arm64 (ubuntu-24.04-arm)
natively on GitHub runners viasnapcore/action-build;snap-launchpadbuilds
s390x/ppc64el/riscv64 on Launchpad in a non-blocking (continue-on-error),
per-arch matrix (remote-build --build-for <arch>) so a slow or failed exotic
arch never delays the release or the other arches. Each arch publishes to the
Snap Store (candidate,beta,edge) and attaches to the GitHub Release the moment it
finishes — all 5 arches still ship. Thanks to xet7. - Sandstorm .spk: fix server-boot crashes from stale globals (Users, HTTP) in sandstorm.js:
once the capnp load was made non-fatal, the grain reached the rest of
sandstorm.jsand hit two latent Meteor-2.x-isms the Meteor 3.x migration missed
(this file only runs on Sandstorm, so it was not exercised): it referenced the
Users/Boards/Swimlanes/Activitiescollections as implicit globals, but
those are now ES module default exports, soUsers.after.insertthrew
Users is not definedat boot; and it monkey-patchedHTTP.methodsfrom the
removedmeteor/httppackage, soHTTPwas undefined. The collections (and
Accounts) are now imported explicitly, and the obsoleteHTTP.methodspatch is
removed. Thanks to xet7. - Sandstorm .spk: use boolean index options so FerretDB accepts the users index:
wekan-accounts-sandstormcreated the unique index onservices.sandstorm.id
with{unique: 1, sparse: 1}. Real MongoDB accepts the truthy1, but FerretDB
(used by the Sandstorm.spk) is strict and rejects it withThe field 'unique' has value unique: 1, which is not convertible to bool, crashing the grain at boot
during index creation. Now uses real booleans (unique: true, sparse: true).
Thanks to xet7. - Sandstorm .spk: strip Accept-Encoding so the grain doesn't serve corrupted (gzip) content:
the grain boots, but the page failed to load with a browser "Corrupted Content
Error" (NS_ERROR_NET_CORRUPTED_CONTENT).sandstorm-http-bridgeadvertises
Accept-Encoding: gzipto the app regardless of what the browser actually sent,
so Meteor served gzip/brotli-encoded responses the browser could not decode. A
WebApp.rawHandlersmiddleware now stripsAccept-Encoding(it runs before
Meteor's static/boilerplate serving) so responses go out uncompressed; bandwidth
is a non-issue behind the local bridge. Thanks to xet7. - Sandstorm .spk: bundle a modern sandstorm-http-bridge to fix "Corrupted Content":
the grain boots, but the page failed with a browser "Corrupted Content Error"
(NS_ERROR_NET_CORRUPTED_CONTENT) on WeKan's/redirect. The meteor-spk 0.6.0
base bundles an ancient (~2016)sandstorm-http-bridgethat mangles responses
(it always advertisesAccept-Encoding: gzipto the app and mishandles
redirect/encoding).build-deps.shnow overwrites the bundled
/sandstorm-http-bridgewith the modern one from a Sandstorm install
(/opt/sandstorm/latest/bin/sandstorm-http-bridge; override with
SANDSTORM_HTTP_BRIDGE), andsandstorm.ymlinstalls Sandstorm before assembling
the deps so the bridge is available on the CI runner. Thanks to xet7. - Sandstorm .spk: fix the "/" redirect (malformed Location + Content-Length mismatch):
the grain's/served a broken301: theLocationwas.../:6080boardbecause
FlowRouter.path()does not resolve on the server in Meteor 3.x (it returned the
bare route nameboard), and the response advertisedContent-Length: 90while
sending an empty body — which the browser rejected as a "Corrupted Content Error"
(NS_ERROR_NET_CORRUPTED_CONTENT). The handler now builds the board path directly
(/b/:id/:slug) and sends a real HTML body (meta refresh + link) with a matching
Content-Length, so the redirect to the hard-coded Sandstorm board works. Thanks
to xet7. - Sandstorm .spk: open the grain on the All Boards page, not a hard-coded board:
WeKan on Sandstorm originally opened a single hard-coded board
(/b/sandstorm/libreboard) and redirected/to it. It now supports many boards
and that board is no longer the right destination, so theWebApp.handlers.get("/")
redirect is removed entirely —/now falls through to WeKan's normal serving,
whose clienthomeroute renders the All Boards list, the right landing page
for a multi-board grain. This also removes the last server-side redirect the
browser was rejecting as a Corrupted Content Error. Thanks to xet7. - Sandstorm .spk: don't auto-create a board; map grain permissions to global role:
a new grain/user no longer gets a hard-codedsandstorm/libreboard board
auto-created — WeKan on Sandstorm is multi-board now, so the user creates their own
boards from the All Boards page.updateUserPermissionspreviously added the user
as a member of that single board (which would now crash since the board no longer
exists); it now maps the grain's Sandstorm permissions to the user's global
WeKan role —configure(grain owner) becomes a WeKan admin, everyone else is a
regular user who can create and manage their own boards. Thanks to xet7. - Sandstorm .spk: set SANDSTORM=1 so the header-based auto-login runs:
WeKan loaded in the grain but every page showed "Must be logged in". The
wekan-accounts-sandstormclient only starts the automaticX-Sandstorm-*header
login when__meteor_runtime_config__.SANDSTORMis set, and the package only sets
that whenprocess.env.SANDSTORMis present — which nothing did. The launcher now
setsprocess.env.SANDSTORM = '1'before loading the WeKan bundle, so the client
auto-logs-in the Sandstorm user from the headers. Thanks to xet7. - Sandstorm .spk: rewrite ROOT_URL per request to the grain URL (fixes login + CORS):
WeKan loaded but stayed on "Must be logged in", and the console showed
Cross-Origin Request Blocked … http://127.0.0.1:4000/__meteor__/dynamic-import/fetch.
The launcher sets a fixedROOT_URL(http://127.0.0.1:4000, the internal bridge
target), but Sandstorm serves each grain at a per-session host
(ui-<hash>.<host>), so the client sent its DDP connection and dynamic-import
fetches to127.0.0.1:4000— cross-origin and unreachable — and the header-based
login handshake (a DDP method call) never completed. AWebApp.addRuntimeConfigHook
now rewritesROOT_URLto the grain's real base URL (X-Sandstorm-Base-Path) per
request, so DDP, dynamic imports and the Sandstorm auto-login work. Thanks to xet7. - Sandstorm .spk: bounce from sign-in to the boards list once auto-login lands:
the Sandstorm login was actually succeeding (Meteor.userId()gets set), but the
grain stayed on the sign-in page. WeKan's home route checksMeteor.userId()once
and, because the Sandstorm header login is asynchronous and uses
connection.setUserId()(bypassing accounts-base, soAccounts.onLoginnever
fires), finds it still null on first render and redirects toatSignIn— where the
user is stranded even after login completes. A Sandstorm-only reactive autorun now
sends the user fromatSignInback tohomeas soon asMeteor.userId()is set,
so the grain lands on the All Boards page. Thanks to xet7. - Sandstorm .spk: keep the grain URL in sync with the in-app route:
navigating between boards worked but the Sandstorm shell's grain URL never updated
(it stayed on the grain root), unlike standalone WeKan. The path was synced via a
globalFlowRouter.triggers.entercallback, which does not fire reliably on client
navigation in this flow-router-extra / Meteor 3 setup; it is now synced from a
reactiveTracker.autorunonFlowRouter.watchPathChange()(the same mechanism
the title sync uses), so every route change updates the grain URL to
/grain/<id><path>. Thanks to xet7. - Sandstorm .spk: upload attachments over DDP (bridge strips Meteor-Files' HTTP headers):
adding a file to a card in the grain failed with HTTP 400Can't continue upload, session expired [408]and the file silently disappeared. Meteor-Files' HTTP
upload signals the first chunk with anx-startheader and tracks the session
withx-mtok/x-chunkid/x-fileid/x-eof, but Sandstorm's request-header
whitelist does not include them, so thesandstorm-http-bridgestrips them — the
server never seesx-start, treats every request as a chunk continuation, cannot
find the session and returns 408. Attachment uploads now usetransport: 'ddp'on
Sandstorm (DDP method calls, no custom HTTP headers); HTTP transport is kept
everywhere else. Thanks to xet7. - Sandstorm .spk: authorize attachment/avatar downloads via X-Sandstorm-User-Id:
once uploads worked, the uploaded image still showed as a broken thumbnail,
minicard cover and slideshow in the grain — the file was on disk but the download
returned HTTP 403. The download route (server/routes/universalFileServer.js)
authorizes files on private boards with a Meteor login token (Authorization /
X-Auth-Token /authTokenquery /meteor_login_tokencookie), but Sandstorm has
none of these: authentication is viaconnection.setUserId()and the
sandstorm-http-bridgeX-Sandstorm-*request headers, soextractLoginToken()
returned null andisAuthorizedForBoard()denied every request. Sandstorm already
gates grain access at the platform level, so any request that reaches WeKan is an
authenticated grain user — bothisAuthorizedForBoard()and
isAuthorizedForAvatar()now allow whenMeteor.settings.public.sandstormis set
and the request carries the bridge-injectedX-Sandstorm-User-Idheader. Gating on
the setting means a spoofed header cannot bypass auth on non-Sandstorm deployments.
Thanks to xet7. - Sandstorm .spk: open All Boards (not sign-in) and keep the grain URL in sync:
two grain-navigation regressions against the last working Sandstorm build (v6.15).
(1) Opening a grain showed "Must be logged in" instead of the All Boards page: on
Sandstorm the platform authenticates the user asynchronously over DDP via
connection.setUserId(), which (unlike a password login) does not set
Meteor.loggingIn(), soMeteor.userId()is null for the first moments after the
grain opens — and useraccounts'ensureSignedIntrigger plusrenderBoardList()
both bounced that brief null window to theatSignInroute, a sign-in page that
does not exist inside a grain.config/router.jsnow uses a Sandstorm-aware
ensureSignedInUnlessSandstormwrapper (a no-op on Sandstorm) and
renderBoardList()no longer redirects on Sandstorm; the list renders and fills in
reactively once the login lands. (2) The Sandstorm shell's outer grain URL did not
update when switching boards — the shell rewrites/grain/<id><path>when the app
posts a{ setPath }message, but the sync was a bare top-levelTracker.autorun
that could run before flow-router-extra's reactive path tracking was ready and then
never re-run. Restored v6.15's event-drivenFlowRouter.triggers.enter(fresh
entering path, order-independent) and kept awatchPathChange()autorun wrapped in
Meteor.startupas a backup. Thanks to xet7. - Sandstorm .spk: fix the MongoDB 3 → FerretDB migration of an existing grain:
importing an old WeKan grain crash-looped in the one-time migration — mongod 3.0
forked and its child aborted with exit code 14. Two bugs. (1) WiredTiger
cache_size=0G: mongod 3.0 sizes its WiredTiger cache from detected RAM
(RAM/2 − 1GB), but inside a Sandstorm grain sandbox RAM detection returns 0, so it
computedcache_size=0Gand WiredTiger refused to open (minimum is 1MB), logging
"Value too small for key 'cache_size'" / "Fatal Assertion 28561" to
/var/migration-mongod.logbefore aborting. Both mongod invocations (the niscu →
3.0 stage and the 3.0 → FerretDB stage) now pass an explicit
--wiredTigerCacheSizeGB 1so the cache size never depends on RAM detection
(mongod 3.0.7 parses this option as an integer number of GB — a decimal like0.25
fails withBad digit ".", fixed to1—
and it is a cache cap, not a preallocation). The data
itself is intact (the "unclean shutdown" notice is harmless —
WiredTiger recovers from the last checkpoint). (2) Wrong source database:
Sandstorm WeKan grains store their data in the Meteor-default databasemeteor, but
the importer was toldSRC_DB=wekan, so even once mongod started it would have
exported zero collections; only the FerretDB target database iswekan. Thanks to
xet7. - Sandstorm/Snap migration: import mongodb and bson as default (CommonJS) exports under Node 24:
with the WiredTiger cache fixed mongod 3.0 started and the migration importer ran,
but crashed immediately on its named imports —import { EJSON } from 'bson'and
import { MongoClient } from 'mongodb'each threw "Named export '…' not found. The
requested module is a CommonJS module". Both packages, as bundled in WeKan's server
node_modules, are CommonJS, so Node 24's ESM loader exposes no named exports on
them. Import the default and destructure, as Node's own error message advises (bson in
the commit linked above; mongodb in the same way).
Shared by the Snap MongoDB 3 → FerretDB migration too (same Node 24). Thanks to xet7. - Sandstorm/Snap migration: connect mongoexport over IPv4 so it can read the old data:
with the importer finally running, everymongoexportof a source collection failed
— at first silently (its own--quietflag suppressed the reason),
and once that was
dropped the real error showed: "error connecting to db server: no reachable
servers".mongoexportis a Go tool whose--hostdefaults tolocalhost, which
resolves to::1(IPv6) first, but the migrationmongodlistens only on
--bind_ip 127.0.0.1(IPv4); the mongo shell defaults its host to127.0.0.1and so
connected fine (it listed all 42 collections), which is why only the shell worked.
Pass--host 127.0.0.1explicitly. The one-time progress dashboard also now shows a
live Activity panel (mongoexport-ready line, per-collection export/insert counts, GridFS
extraction counts) with a spinner and an auto-updating timestamp,
so it is
clear what the migration is doing rather than sitting on "(waiting…)". Thanks to
xet7. - Sandstorm/Snap migration: resolve bson/mongodb from the modern server bundle so EJSON exists:
once mongoexport connected, every collection failed with "Cannot read properties of
undefined (reading 'parse')" —EJSONwas undefined. The importer script sits at the
deps root right next to the OLD meteor-spk 0.6.0 basenode_modules(kept only for the
niscu → 3.0 stage): itsbsonis 1.x with noEJSONat all, and itsmongodbis
ancient — it hasMongoClient(so the connection worked) but noEJSONre-export. A
bareimport/requirefrom the script resolved those adjacent old copies, so every
way of reachingEJSON(barebson,mongodb.EJSON) came back undefined. WeKan's
currentbson7.3 and mongodb driver (withEJSON) live under
programs/server/npm/node_modules; anchorcreateRequire
inside that modern bundle
first (falling back to the deps root) and load bothmongodbandbsonthrough it —
which also moves the importer to the same modern mongodb driver the WeKan app uses
against FerretDB. An upfront guard fails loudly with a diagnostic list ifEJSON.parse
is still unreachable. Thanks to xet7. - Sandstorm/Snap migration: insert into FerretDB with the modern mongodb driver (OP_MSG):
text collections exported and "inserted N/N" was logged, but every document actually
failed with "Unsupported OP_QUERY command: update" — nothing reached FerretDB.
requireAny('mongodb')had resolved the ancient meteor-spk base driver (v2.x, at the
deps root) because the modern driver is not directly under
programs/server/npm/node_modules— Meteor nests it at
…/meteor/npm-mongo/node_modules/mongodb(v6.16). The 2.x driver speaks legacy
OP_QUERY, which FerretDB rejects; the 6.x driver speaks OP_MSG (the same driver the
WeKan app uses against FerretDB). Add the npm-mongo path as the first resolver anchor,
and log the resolved driver version. Thanks to xet7. - Sandstorm/Snap migration: extract Meteor-Files GridFS attachments + parse legacy v1 binary:
with text migrating, attachments still produced 0 files and "parse attachments.chunks:
Unexpected Binary Extended JSON format". Two causes. (1) mongo 3.xmongoexportwrites
binary as legacy Extended JSON v1{"$binary":"<b64>","$type":"00"}, but modern bson
EJSON.parseonly accepts v2{"$binary":{"base64":…,"subType":…}}— rewrite v1→v2 per
line before parsing. (2) The grain stores attachments in Meteor-Files' own GridFS buckets
(attachments.files+attachments.chunks, with the FilesCollection record in the
attachmentscollection), not CollectionFS'scfs_gridfs.*. Reassemble those buckets to
disk, link each GridFS file to its record viametadata.fileId/versionName, then repoint
the record'sversions.<v>at the file and dropversions.<v>.meta.gridFsFileId—
otherwise WeKan'sgetFileStrategykeeps choosing the now-empty GridFS backend and the
image 404s. Verified end to end on a real grain: boards/cards/lists/swimlanes migrate and
all 3 attachments extract and display. Thanks to xet7. - Sandstorm/Snap migration: auto-open All Boards after migrating, WeKan-themed dashboard:
the one-time progress dashboard used to sit on the grain URL for 60s after completion,
forcing a manual reload to reach WeKan. On success the importer now hands off quickly and
the done page polls/until WeKan's app shell answers (riding out the brief
importer→WeKan port hand-off) and then opens All Boards, with the spinner still spinning so
it is clear the grain is still working. The dashboard is also recoloured to WeKan's
blue/white/grey. Thanks to xet7.
Thanks to above GitHub users for their contributions and translators for their translations.
Weekly OSS security release digest.
The CVE patches and breaking changes that affected production tools this week. One email, every Sunday.
No spam, unsubscribe anytime.
Share this release
About wekan
The Open Source kanban, built with Meteor. GitHub issues/PRs are only for FLOSS Developers, not for support, support is at https://wekan.fi/commercial-support/ . New English strings for new features at imports/i18n/data/en.i18n.json . Non-English translations at https://app.transifex.com/wekan/wekan only.
Related context
Related tools
Beta — feedback welcome: [email protected]