Wekan
Open source kanban board application built with Meteor
Alternative to: trello
About
Versions (218)
v9.88
2026-07-13v9.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-destinationsocpdeletes 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 packactually 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 withopen(~/.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, sonodefailed 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.2so 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 defaults
kernel.apparmor_restrict_unprivileged_userns=1, which blocks the unprivileged user namespaces the Sandstorm install and thespksupervisor rely on, sosandstorm.ymlnow relaxes it on the runner. A newrefworkflow_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 injectsconst __dirname = fileURLToPath(import.meta.url), colliding with the__dirnamethe 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 a
state.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, but
capnp.node(node-capnp) is built for an older Node ABI (NODE_MODULE_VERSION 83 = Node 14), soNpm.require('capnp')insandstorm.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 ran
snapcraft 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 theUsers/Boards/Swimlanes/Activitiescollections as implicit globals, but those are now ES module default exports, soUsers.after.insertthrewUsers is not definedat boot; and it monkey-patchedHTTP.methodsfrom the removedmeteor/httppackage, soHTTPwas undefined. The collections (andAccounts) 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.idwith{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-bridgeadvertisesAccept-Encoding: gzipto the app regardless of what the browser actually sent, so Meteor served gzip/brotli-encoded responses the browser could not decode. AWebApp.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 withSANDSTORM_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.../:6080boardbecauseFlowRouter.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 matchingContent-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-coded
sandstorm/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.addRuntimeConfigHooknow 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 usesconnection.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
global
FlowRouter.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 400
Can'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 thesandstorm-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()andisAuthorizedForAvatar()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 setMeteor.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-awareensureSignedInUnlessSandstormwrapper (a no-op on Sandstorm) andrenderBoardList()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.autorunthat 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 inMeteor.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.25fails 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'andimport { MongoClient } from 'mongodb'each threw “Named export ’…’ not found. The requested module is a CommonJS module”. Both packages, as bundled in WeKan’s servernode_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, every
mongoexportof 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 underprograms/server/npm/node_modules; anchorcreateRequireinside 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.parseis 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 underprograms/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.x
mongoexportwrites binary as legacy Extended JSON v1{"$binary":"<b64>","$type":"00"}, but modern bsonEJSON.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 theattachmentscollection), 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.