Back to Wekan

WeKan ® 2026-07 releases

old-CHANGELOG/2026/07.md

10.99666.5 KB
Original Source

WeKan ® 2026-07 releases

Moved out of CHANGELOG.md to keep that file small enough to open (wekan/wekan#6580). Nothing here has been changed: a release section is a record, and it reads the same as it did there.

Releases per day:

2026-07Releases
052
065
093
114
132
156
161
173
182
192
204
215
2214
237
242
252
262
273
286
293
301
311

v10.53 2026-07-31 WeKan ® release

This release fixes the following bugs:

<details> <summary><a href="https://github.com/wekan/wekan/commit/35e3c8c66">The card window opens beside its card again, on whichever side has more room</a>. Thanks to csonkaoszimt, Mintyt and xet7.</summary>

"notice how the card info docks neatly right next to the card" — the 6.09 behaviour asked for in #6465. The window had been moved off the middle of the board and docked to the end edge instead, which stopped it covering the board but left it nowhere near the card it belongs to: open a card in the FIRST list of a wide board and its details are a screen away, with the whole board between them.

It now opens beside the minicard it was opened from, on whichever side has more room, and always entirely inside the visible area. A card on the left of the board opens to its right; a card on the right opens to its left.

This is horizontal only. The vertical geometry — the staggered top and the bottom: 8px that makes the window full height — is already right, so nothing here writes top, bottom or height, and a test fails if it starts to.

The width is passed through unchanged unless the viewport itself is narrower than the window. Shrinking the window to fit the gap beside the minicard was tried and dropped: on a 600px-wide desktop it turned a 520px window into a 240px one, and this is about where the window is, not how big it is. When neither side has room it overlaps the card instead, pushed as far towards the roomier side as the viewport allows — the card is behind it for a moment, which is recoverable; a window off the edge of the screen is not.

Nothing is placed unless there is something to place it against: a card opened from a URL, from search, or whose list is scrolled out of view has no minicard on screen and keeps the previous dock. Only the desktop floating window is touched — the popup form (Board Table, search results) and the mini-screen card have their own geometry, and the maximized window's insets are !important. A window the user has dragged keeps where they put it; the viewport clamp still applies to it, so a browser window that shrinks cannot leave it hanging off the edge.

One vertical side effect had to be paid for. The stylesheet's default-position rule is :not([style*="left"]):not([style*="top"]), so writing an inline left switches it off — and it carried the top as well as the width. Without a replacement, a sixth open window (past the five staggered rules, reachable with "Open many cards at once") would have had no top at all. The replacement is vertical-only and written BEFORE the stagger rules, so cards 1–5 still take their staggered top from those and no vertical position changes anywhere; the test pins that order, because the same rule written after them would win the specificity tie and flatten cards 2–5 onto card 1.

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/8e89262d0">Propagate Members To Boards now adds the members when it is ticked, instead of only at the next LDAP sync</a>. Thanks to ChristianMa97 and xet7.</summary>

Ticking "Propagate Members To Boards" for a team in Admin Panel > People > Teams stored the flag and added nobody. The members appeared later, when the LDAP sync cron ran the same propagation by its other, correct route — which is why the feature looked half-working rather than broken.

setTeamPropagateMembersToBoards(team, value) receives team as the selector the client sent — { _id: … }, the same value it hands to Team.updateAsync on the line above — and passed it whole to propagateGroupMembersToBoards, which put it straight into the member lookup. So it ran

Meteor.users.find({ 'teams.teamId': { _id: 'abc' } })

comparing a string field against an object. It matched nobody, the function returned "0 boards updated, 0 members added", and nothing threw: the checkbox went green and did nothing. The org column beside it was identical and is fixed with it, though the report was about teams.

Both call sites pass the id now, and propagateGroupMembersToBoards normalises whatever it is given at the one point every caller goes through — a plain id, or a document or selector carrying one. Anything it cannot turn into an id is refused and logged, rather than quietly treated as a group that happens to have no members, because that silence is exactly how this survived a release. A missing group stays quiet, since the propagate-everything pass may legitimately have nothing to pass.

The select-all checkbox in the column header makes the same promise to the admin, and it did not propagate at all — not even wrongly: it set the flag on every row and stopped. It propagates now when that field is the one being turned on, and only for its own kind, so ticking the team column does not act on the org column beside it. The propagate-everything pass takes an optional kind for this; its default is unchanged, so the LDAP cron and the propagateOrgTeamMembersToBoards method still do both.

The two wiring tests required the buggy calls — they asserted propagateGroupMembersToBoards('team', team) verbatim, pinning that a call happened without asking whether its argument was right, and so held the bug in place. They require the id now and fail if the whole selector comes back, and the normaliser is exercised for real rather than read: every shape a caller might hold an id in resolves to it, a selector with no id resolves to nothing and warns, and an absent group is quiet.

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/fe27dc0b8">Moving a card to another board no longer blanks every custom field id</a>. Thanks to ChristianMa97 and xet7.</summary>

The values survived the move; their keys did not. Every entry came out keyed by an empty _id, which no board can match to a field definition, so the card arrived on the destination board with every custom field showing empty. Drag-and-drop, the Move card popup and the REST API newBoardId move all go through the same helper, and a cross-board card copy had it too.

mapCustomFieldsToBoard() was synchronous and called ReactiveCache.getCustomField(), which is async on the server — it awaits findOneAsync — and synchronous only on the client. So on the server both lookups returned Promises. A Promise is truthy, so the "field not found" guard never fired and the "the destination board has its own definition" branch always did, assigning newCf._id: undefined on a Promise. The schema declares customFields.$._id as optional: true, defaultValue: '', so collection2 cleaned that undefined to '' on the way to the database. Nothing threw and nothing was logged.

Both lookups are awaited now, and so is addBoard() — also async, and until the two above were awaited its branch was unreachable, so its own missing await had never been exercised.

Two more things in the same function. The entries are rebuilt rather than mutated in place: a card copy deliberately works on a shallow copy of the card "to avoid mutating the source card in ReactiveCache", and cf._id = newCf._id reached straight through that copy into the source card's own entry objects, re-keying the card being copied from. And an entry with no id is passed through instead of looked up — getCustomField() defaults its selector to {}, so a lookup of an empty id returns an arbitrary custom field, and a blank entry (including one blanked by this very bug) would have been re-keyed to whichever field came first.

This is the same defect as the fix for #6504 — an unawaited ReactiveCache.getBoard in this same cross-board branch, which gave "newBoard.getNextCardNumber is not a function" — one line further down. The guard added then named the two getBoard calls and stopped there, so the third call shipped broken for twenty more releases. That guard now checks the branch as a whole and fails on any unawaited async call in it; run against the pre-fix file it reports exactly this one.

The new test lifts the real function out of the model and runs it against both worlds — an async cache (the server, where this broke) and a synchronous one (the client, where it always worked) — which must give the same answer: a value whose field exists on the destination board by name and type is re-keyed to that board's definition, and one whose field does not is left pointing at the definition it had, which gains the destination board so the value still resolves.

Cards moved or copied between boards on an affected release are not repaired by this. The entry kept its value but lost the only reference to which field it belonged to, so there is nothing left to match it back with, and those values have to be set again.

</details>

v10.52 2026-07-30 WeKan ® release

This release fixes the following bugs:

<details> <summary><a href="https://github.com/wekan/wekan/commit/91bd63107">Fix WeKan not starting in Yandex Browser: Meteor's own transpile asked for a module that was not there</a>. Thanks to zubzhaaaw and xet7.</summary>

Three attempts at this had added imports to client/lib/swcHelpers.js, and 10.51 still failed in Yandex Browser with the same line:

Uncaught Error: Cannot find module
'@swc/helpers/_/_possible_constructor_return'
    at a.s [as link] (…)  at client-rspack.js (…:285:985)

Read the stack outward: client-meteor.js links client-rspack.js, and the failing link() is inside client-rspack.js. But the bundle rspack writes contains no @swc/helpers specifier at all — the built _build/main-prod/client-rspack.js has the helper bodies inlined and the string nowhere. The import is in the copy of that file that Meteor compiled, and Meteor put it there: package.json says "meteor": { "modern": true }, which turns on Meteor 3.3+'s SWC transpiler for every file; babel-compiler sets jsc.externalHelpers: true whenever node_modules/@swc/helpers exists — and WeKan depends on it — so the output imports its helpers instead of inlining them; and for web.browser.legacy that transpile passes no jsc.target and an env.targets down to IE 11, so class is lowered to ES5 and the output gains import { _ } from "@swc/helpers/_/_possible_constructor_return". That specifier is not in the legacy bundle's module tree, so the whole app fails before anything runs. The modern bundle was never affected: nothing there is lowered that far, and where a helper is needed the modern module runtime resolves module (the esm/ file) while the legacy one prefers main (../../cjs/_x.cjs).

That is also why the earlier fixes could not work, whatever they did. client/lib/swcHelpers.js is in the rspack graph; rspack resolves its imports and inlines the helper bodies, so they never become entries in Meteor's module tree — which is the tree the failing link() searches. No import written in app code can satisfy a link that Meteor's transpiler adds afterwards.

So /.swcrc sets jsc.externalHelpers: false, and SWC inlines each helper into the file that needs it: the import is never emitted, so it cannot go missing. Both readers of that file merge it over their own defaults and neither preserves this key — Meteor keeps only jsc.target, env.targets and module.type, and @meteorjs/rspack only jsc.target — so it reaches the Meteor transpile and the rspack build alike. It costs one copy of a helper per file that uses one, which is the right trade for a bundle that otherwise does not load.

.gitignore's *.sw* is there for vim's .swp/.swo and matched .swcrc too, so adding the file was silently a no-op; !.swcrc now follows it, and the test fails if that exception is removed or moved above the rule it corrects.

Only a browser served the legacy bundle could hit any of this, which is why it read as a Yandex bug. useragent-ng reports Yandex Browser as the family "Yandex Browser", webapp camel-cases that to yandexBrowser, and modern-browsers has neither a minimum nor a chrome alias for that name — isModern() is false for a name nobody declared — so every Yandex Browser user was served ES5. server/modernBrowsers.js declares yandexBrowser: 18: its major version is a year, and 18.x is already Chromium 64, past full ES2015 (51) and dynamic import() (63). Samsung Internet, Vivaldi, Opera Mobile, Whale, MIUI, UC Browser and QQ Browser are in the same position and are left alone on purpose — their version numbers do not map onto a Chromium version — and the file says so rather than looking as though they were forgotten.

The test reproduces Meteor's decision — webapp's camelCase, modern-browsers' alias table and lookup — against real user agent strings, so it fails if the key stops matching the family name or the minimum stops covering the versions in the reports.

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/08be34b70">A write the database was too busy to take is now retried, which nothing had ever done</a>. Thanks to Nissulya and xet7.</summary>

The restart loop is gone — a transient database error no longer ends the boot or the process — and the reporter's 10.49 log shows the same contention arriving in front of a user instead:

Exception while invoking method '/users/updateAsync' MongoServerError:
  … [collection.go:191 sqlite.(*collection).UpdateAll]
  [db.go:151 fsql.(*DB).InTransaction] database is locked (5) (SQLITE_BUSY)

That error goes to the client: the edit fails, and the user reloads to find out what happened — which is the complaint that they only see their cards after a reload. And it was not supposed to. models/lib/databaseErrors.js has classified a locked SQLite as { id: 'deadlock', act: 'retry' } with "Retried automatically." since it was written, and server/00processErrors.js says "the write is retried by whatever issued it". Nothing read act. No code in WeKan retried anything, anywhere.

SQLITE_BUSY is the one database error a client is meant to handle itself. SQLite has a single writer; a second writer that arrives while the lock is held is told to come back, and nothing was applied — FerretDB takes the lock before the transaction, so a BUSY answer means the write did not happen. Retrying the same write is the documented behaviour and stays idempotent: Meteor has already chosen the _id, so a retry that raced a duplicate is refused by the unique index rather than inserted twice.

server/00retryBusyWrites.js wraps insertAsync, updateAsync, upsertAsync and removeAsync: a transient database error is retried with exponential backoff and full jitter, bounded at five attempts and two seconds in total (WEKAN_DB_RETRY_*). Bounded on purpose — retrying for longer holds a method invocation and its slot in the connection pool while the contention it is waiting for gets worse. When it still fails, the original error is thrown unchanged, so a database that is genuinely stuck looks exactly as it did before and nothing is hidden; anything that is not transient is rethrown on the first attempt, so a full disk is not waited on four times before being reported.

It is loaded from server/imports.js, after the Meteor packages, so it sits outside the wrappers collection2 and collection-hooks put on the same prototype — which is how it sees the error at all, because by then the busy error is collection2's ValidationError with the database's message inside it, the form in the reporter's SyncedCron traces.

Writes only: every error in the reports is a write, which is what contends for the one writer, and SQLite in WAL mode does not block a reader against a writer. Retries are counted and summarised at most once a minute, so a contended database is visible without the log becoming the new problem, and a write finally given up on is recorded for Admin Panel / Problems — except an eventlog write, which is how the recorder writes, and reporting its failure would call the recorder from inside itself.

The reporter asked outright whether PostgreSQL is an option for a snap installation. It is, and it has been: the advice for constant contention now says how — snap set wekan wekan-ferretdb-handler=postgresql wekan-ferretdb-url=…, or docker-compose-ferretdb-v1-postgresql.yml in Docker — instead of only saying that SQLite has one writer.

The test loads the real module against a stub collection prototype and exercises the behaviour rather than reading the source: a busy write is retried and succeeds with its arguments and its collection unchanged, the ValidationError form is recognised too, a full disk is thrown at once and never retried, an endlessly busy database gets the original error after a bounded number of attempts and a bounded wall-clock time, the backoff grows and is jittered and capped on the shipped numbers, reads are left unwrapped, wrapping twice is a no-op, and the eventlog write is retried without reporting on itself.

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/ca0e36e37">Database problems now shows which database said it and what it said</a>. Thanks to xet7.</summary>

A database problem arrived in Admin Panel / Problems / Database problems with an empty Category, Name and Action and a detail that said "The database returned an error WeKan has no rule for. Read the message below" — with no message below, or anywhere else on the page. The row said that something had happened and nothing about what.

Nothing was wrong with the classifier or the recorder. EventLog has a SimpleSchema attached, and collection2 cleans every insert against it with filter: true, so a field the schema does not declare is dropped silently on the way to the database. The four fields the database stream uses — type (the rule id), db (which database said it), kind and message — were never added to that schema, so they were the four that were thrown away, and they are exactly the four the page shows in Category, Name, Action and the message it told the admin to read. The other four event streams were unaffected: they only ever write fields the schema declares.

The schema declares them now, and the test pins the general rule rather than this one instance — for each of the five event loggers, every key of the document it inserts must be declared in the EventLog schema — so the next field added to a logger cannot vanish the same way.

The message is shown as well: the Detail cell carries WeKan's reading of the error and then the database's own sentence, which is the one thing an admin can search for or paste into an issue. The stream search looks at message, db, kind and type, so searching for "postgresql" or for a phrase out of the message finds the row that displays it, and the unclassified advice no longer promises a message "below" — it follows in the same cell.

Because that message is now stored and displayed, it loses its credentials first. A database that refuses a login or cannot be reached quotes the connection URL back, password and all, so classifyDatabaseError redacts the userinfo of any URL in the message before returning it — keeping the host, the port and the database name, which are what makes the message useful — and it is done there rather than in the recorder so no future caller can forget it. The message is also sanitized like every other stream's detail: one line, control characters out, capped.

</details>

v10.51 2026-07-29 WeKan ® release

This release publishes the following packages that were not being published:

<details> <summary><a href="https://github.com/wekan/wekan/commit/f3f3d21a3">One release now publishes all three snaps: wekan, wekan-ondra and wekan-gantt-gpl</a>. Thanks to xet7.</summary>

People are still on the older snap names, so a release that publishes only wekan leaves them on a stale package. Two things stood in the way, both in the snap-variants job.

It gated EVERYTHING on WEKAN_REPO_TOKEN being able to push to wekan/<variant>. Building and publishing a snap needs SNAP_AUTH and nothing else; keeping the variant GitHub repositories in step with wekan/wekan is a separate, optional thing. Because the two were tied together, a token without push rights meant no variant snaps at all — which is why v10.48 and v10.49 published none. The guard answers two questions now: one decides the build and the publish, the other decides only whether the repository is updated, and every "cannot push" warning ends with "The snap is still built and published".

The variant tree is also no longer taken from a clone of the variant repository: it is copied from the release tag the job checked out, with the snap name and title written into BOTH snapcraft.yaml and snapcraft-core26.yaml — renaming only the first left the core26 file saying name: wekan. So the snap is built from what was just released, whatever state the variant repository is in.

Both variants build on amd64 and arm64 now, the two native runners the default snap uses, and publish to stable, candidate, beta and edge like the default snap: base: core24 with grade: stable, which is what those channels accept. A last check before the upload refuses a build whose file name does not start with the variant's snap name — publishing a wekan_*.snap from there would overwrite the default snap in the store.

SNAP_AUTH has to carry the ACL for all three names, which is one snapcraft export-login --snaps wekan,wekan-ondra,wekan-gantt-gpl.

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/652a6b850">The variant sync renames the snap in both snapcraft files, not just one</a>. Thanks to xet7.</summary>

snapcraft.yaml is what the variant job builds; snapcraft-core26.yaml is the same snap on the next base. The sync step renamed only the first, so the core26 file in wekan-ondra and wekan-gantt-gpl kept saying name: wekan — and a core26 build from either repository would have published itself as the DEFAULT WeKan snap. Both files are renamed now, and a guard pins it.

It also ignores /wekan-ondra/ and /wekan-gantt-gpl/, which is where those two repositories are cloned when they are synced by hand from this working copy.

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/bb2938310">The variant Docker images are published by hand, by retagging the released image</a>. Thanks to xet7.</summary>

wekanteam/wekan-gantt-gpl and wekanteam/wekan-ondra are the same WeKan as wekanteam/wekan — the variant repositories are identical to wekan/wekan apart from the snap name — so nothing is rebuilt: a new workflow, "Publish variant Docker image (manual)", points the variant tag at the multi-arch manifest the release already built and verified. Same digests, all five architectures, seconds instead of a half-hour emulated build, and no way for the variant image to claim a release it was not built from. releases/docker-publish-variant.sh does the same thing from a terminal.

It is workflow_dispatch only, on purpose: the release publishes wekanteam/wekan every time, and the variant images are published when the maintainer decides to. The credential is checked and logged in with before anything is pushed, and both the source manifest and the pushed tag are asked which architectures they carry.

</details>

and fixes the following release-workflow mistake:

<details> <summary><a href="https://github.com/wekan/wekan/commit/4f872a68c">A credential the release cannot read is not the Snap Store refusing it</a>. Thanks to xet7.</summary>

v10.50 built the amd64 snap, uploaded it, and died on

Publishing snap "wekan_10.50_amd64.snap"...
Unsquashing snap file 'wekan_10.50_amd64.snap'.
Credentials could not be parsed. Expected valid Ubuntu One credentials.

and the job then printed "SNAP_AUTH did not work: the Snap Store refused the upload". The store refused nothing — snapcraft could not READ the secret. The commonest way that happens is storing what snapcraft export-login PRINTED instead of the file it WROTE.

The secrets check now rejects what is unambiguously not a credential — under 100 characters, or carrying snapcraft's own "Exported login" banner — in one line at the TOP of the job instead of after a full build on every architecture. The message after a failed upload no longer claims to know which failure it was: it names both, and how to tell them apart from the snapcraft line above it ("could not be parsed" is an unreadable secret, 401/403 is a valid one without the ACL for that snap name). And every re-export command in the workflow now names all three snaps, since one release publishes wekan, wekan-ondra and wekan-gantt-gpl.

</details>

and has the following developer-tooling changes:

<details> <summary><a href="https://github.com/wekan/wekan/commit/b7d66e97a">Every maintainer script in releases/ is now a menu entry in build.sh and build.bat</a>. Thanks to xet7.</summary>

releases/ holds about ninety scripts and FOUR of them were reachable from a menu — the test run, the database conformance run, run-everything and the CHANGELOG hash repair. Everything else existed only for whoever remembered the file name: the bundle builds per architecture, the whole snap flow, the Transifex scripts, the Sandstorm packaging, the tag and release helpers, the Docker image publishing, the VM helpers.

They are one list now, in eight groups — Release, Snap, Bundles, Docker images, Sandstorm, Translations, Git and repo, Server and VM. Pick a group, pick a script; one that needs a version, a language code, a branch or a file is ASKED for it and gets it passed through. build.bat offers the same entries in the same order through Git Bash, and says so by name when bash is not on PATH.

Nine scripts are deliberately absent, each with its reason recorded in the guard: the four the Tests and Setup menus already run, the helper the others source, the three that run inside the built snap, bundle or Docker image, and the superseded ones.

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/74a8f931f">Twelve one-line wrapper scripts became commands, and both menus gained a command line</a>. Thanks to xet7.</summary>

releases/ufw-enable.sh was one line — sudo ufw enable — and so were eleven others. Nothing called them except the menus, so they are the command itself now, in the same menu entries. Checked one at a time before deleting: docker-build-deps.sh looked like the same kind of thing and is still here, because releases/docker-build.sh runs it. The ten that are systemd, ufw, snap or multipass are Linux-only and build.bat does not offer them, because Windows cannot do them at all.

And both scripts run any entry without the menu, which is what makes them usable from a script or a cron entry:

./build.sh --list                     every name, with what it does
./build.sh release-snap 10.50
./build.sh push-translation ja
./build.sh ufw-enable                 what releases/ufw-enable.sh used to do

The name is the file name without its extension, or — for the twelve commands — the name of the wrapper file it replaced, so anything anybody had in a script keeps working. An unknown name says so and exits 2. Guards keep the two menus offering the same entries in the same order, and pin that every example in the help text is a real command name.

</details>

and has the following documentation fix:

<details> <summary><a href="https://github.com/wekan/wekan/commit/af2683d56">Say why the snap is built on core24: it is the base that may publish to stable</a>. Thanks to xet7.</summary>

The header of snapcraft.yaml still described an older policy — "the workflow publishes it to the candidate + beta + edge channels; the stable channel is published MANUALLY later" — which has not been true for a while: every snap job publishes stable, candidate, beta and edge.

Both snapcraft headers now say the actual reason for the base. core24 is a released base, so the snap can be grade: stable and the stable and candidate channels accept it. core26 is still experimental: build-base: devel forces grade: devel, and a devel-grade snap is refused by stable and candidate, so it could only reach beta and edge. That is why the release builds core24 and keeps the core26 file for testing only. A guard pins all of it, including that snapcraft-core26.yaml is only ever renamed by the variant sync, never built.

</details>

Thanks to above GitHub users for their contributions and translators for their translations.

v10.50 2026-07-29 WeKan ® release

This release has the following release-workflow fix:

<details> <summary><a href="https://github.com/wekan/wekan/commit/9b57dfd8b">The Windows zip was never broken: a matched file was reported as missing, because of SIGPIPE</a>. Thanks to xet7.</summary>

v10.49 printed the archive listing that the previous release added, and it ends two releases of guessing about wekan-*-win64.zip:

7z t: archive is intact.
7z l -ba listed 51850 entries; the first five:
2026-07-28 21:58:39 D....        0        0  bundle
2026-07-28 21:52:07 .....        9        9  bundle\.node_version.txt
2026-07-28 21:58:36 ....A  8828928  3365469  bundle\bsondump.exe
2026-07-28 21:58:39 ....A 55313920 26791675  bundle\ferretdb.exe
2026-07-28 21:52:07 .....      243      168  bundle\main.js
##[error]Process completed with exit code 141

bundle\main.js is in the zip, the archive passes its integrity test, and the step still failed — with 141, which is 128+13, SIGPIPE. printf … | head -5: head takes its five lines and exits, printf is killed writing into a closed pipe, set -o pipefail (which GitHub's bash sets) makes that the pipeline's status, and set -e ends the step. The evidence printed, and then killed the job.

The same mechanism is the ORIGINAL failure, and that is the part worth keeping: printf … | grep -qF -e "bundle\main.js"grep -q exits the instant it MATCHES, printf dies of SIGPIPE, pipefail reports 141, and if ! reads that as "not found". So "has no bundle/main.js" was printed BECAUSE the file was found. Both forms were reproduced exactly in bash before the fix was written.

Every listing now goes through a file and every reader reads the file: the win64 verify, the four unzip -l "$zip" | grep -q checks on the other platforms — the same trap, only luckier so far about the size of the pipe buffer — and the five gh release view … | grep -qxF asset checks. A guard pins that nothing in the workflow pipes a listing into grep or head.

</details>

and has the following test fix:

<details> <summary><a href="https://github.com/wekan/wekan/commit/18a2dbda8">The card-drag test measured the board's edge auto-scroll, which is a feature</a>. Thanks to xet7.</summary>

The browser test added for #6558 ran for the first time and failed on the lane's scroll position, while everything it exists for passed: the drag was really in progress, and neither the canvas nor the lane carried dragscroll while it was.

It grabbed the first card of the first list, which sits within 40px of the lane's left edge. Inside that zone the drag's own auto-scroll takes over by design and moves the lane 15px per mouse event — eight moves, 120 − 8×15 = exactly the 0 the test read. That is the off-screen-list auto-scroll working, not the panning the test is about.

Panning follows the pointer 1:1 and happens anywhere; edge auto-scroll happens only at an edge. So the test drags in the middle now: it measures the lane and canvas rectangles, picks the minicard with the most room inside a 70px margin on every side, and moves it along a short diagonal clamped to that box. A viewport too small for such a drag skips the test with that reason, rather than measuring the wrong mechanism.

</details>

Thanks to above GitHub users for their contributions and translators for their translations.

v10.49 2026-07-29 WeKan ® release

This release has the following release-workflow fixes:

<details> <summary><a href="https://github.com/wekan/wekan/commit/89e6189cc">Release jobs now say what actually failed: no snap, no evidence hidden, no wrong secret blamed</a>. Thanks to xet7.</summary>

Four jobs of release-all.yml reported the wrong cause in v10.48, which is worse than the failure itself — two of them sent a whole round of work after the wrong problem.

snap-launchpad never checked whether a .snap existed. Its test was snaps=( wekan_<version>_<arch>.snap ) followed by a count, in three steps; that string has no wildcard, so it is a literal filename, nullglob cannot empty a literal, and the count was always 1. s390x therefore logged an empty Artifacts: list, then "Remote build s390x succeeded", then 'wekan_10.48_s390x.snap' is not a valid file — and the error text blamed SNAP_AUTH, although the Snap Store never saw an upload. It is a real glob now, the match must be non-empty, and a missing artifact and a refusal by the store are two different messages.

The Launchpad build log — the only thing that says why a build ends as "Stopped" — was downloaded and then printed only when snapcraft exited non-zero, which after the above it did not. It is printed whenever there is no snap.

build-win64 verified the zip by listing it into a variable and grepping that, so a failure printed the verdict and none of the evidence, and a bad zip and a bad grep looked alike. The bundle's main.js, node.exe and start-wekan.bat are now checked in the directory before zipping, the archive must pass 7z t, and the listing is printed with a match count — a zero count is a warning that names the two facts contradicting it, not a failed release.

The snap-variants pre-flight asked GitHub for .permissions.push, which describes the authenticated user's role rather than what the token may do: it answered true one step before remote: Permission to wekan/wekan-gantt-gpl.git denied to xet7. It now asks the receive-pack advertisement, which is what GitHub refuses for a token without Contents:write.

Finally, the one cause that leaves no trace in a build log is named in the job's error output: snapcraft remote-build files every build under an auto-generated Launchpad project, and a project with no licence does not qualify for free hosting, so its builds are stopped.

</details>

v10.48 2026-07-28 WeKan ® release

This release fixes the following bug:

<details> <summary><a href="https://github.com/wekan/wekan/commit/c185f5e98">Moving a card no longer pans the board at the same time</a>. Thanks to mueschel and xet7.</summary>

On a board large enough to have scrollbars in both directions, dragging a card sometimes moved the card, sometimes scrolled the list, sometimes scrolled the board, and often several of those at once — so a card could not be dropped where it was aimed.

A board runs THREE drag-scroll implementations over the same pointer: the dragscroll library, bound separately to .board-canvas, to every swimlane and to every lane; the lane pan in swimlanes.js; and jQuery UI sortable, the one that is supposed to move the card. Nothing stood any of them down while a drag was in progress. swimlanes.js did try, for list drags, but reset the library FIRST and removed the class afterwards — a reset binds to whatever carries the class at that moment, so every listener stayed — and it left the canvas tagged anyway. The lane pan ignored the nodragscroll marker the library honours, so pressing a minicard, which carries that marker exactly because it is the card's drag handle, started a horizontal pan that ran alongside the card drag.

Drag-scrolling is now suspended for the whole drag — class off, THEN reset — and exactly the elements it was taken from get it back, so a board route does not gain panning it deliberately turned off. Every sortable on the board (cards, lists, swimlanes) suspends when a drag starts and resumes when it stops, a window mouseup / touchend / dragend restores in case a drag never reaches its stop handler, and a swimlane re-rendered mid-drag does not re-arm panning under the pointer. The lane pan refuses to start on a drag source, and gives way if a drag begins after the press. The drag handles themselves are marked nodragscroll: a press on a handle moves the item, it never pans the board underneath it.

A guard pins the order of the two steps, the restore-what-was-suspended rule, the safety net, the suspend/resume in every sortable and the two markers; a browser test drags a card sideways on a 12-list board and pins that neither the lane nor the canvas scrolls while it moves, and that panning is back afterwards.

</details>

and has the following developer-facing changes:

<details> <summary><a href="https://github.com/wekan/wekan/commit/9d045d3a3">Every CHANGELOG commit link points at a commit that exists, and the repair no longer breaks good ones</a>. Thanks to xet7.</summary>

106 of the 3014 commit links in this file pointed at nothing: a rebase, amend or squash after the entry was written, or a 40-character hash whose first nine characters were right and whose tail was invented. Clicking one gave GitHub's 404 with no way to tell which change it had meant. Each was resolved against the history — by subject and author date, by prefix, or by reading the entry's own text against the commit messages of the release it sits in — and every link now resolves to a commit in the repository.

releases/fix-changelog-hashes.sh, which build.sh runs after a rebase and release-all.sh runs before a release, decided a link was stale when its commit was not an ancestor of HEAD. A commit that lives only in an old release tag is not on this branch, yet GitHub serves it fine, so that rule repointed working links to a DIFFERENT change — two 2019-era links were repaired that way and had to be put back. Staleness is now "no ref in this clone reaches it", the replacement is matched on subject AND author date (a rebase keeps the author date), then on the patch itself, then by prefix, and a subject shared by several commits is never used to pick one. --all-sections checks the whole file rather than the section being released.

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/31ba78a31">Fix the last two guards of the full test run, one of which was right about the changelog</a>. Thanks to xet7.</summary>

The 18:10 run: 267 node suites with 2 failures, all three browsers clean, Mocha 500 passing, FerretDB unit and integration green, and the conformance run has all four backends — SQLite, PostgreSQL, MySQL and MariaDB — answering identically.

changelogFormat was right, and about the entries written that day: two lines START with #, which markdown renders as a heading and which splits the version list. The issue numbers are escaped and the lines rewrapped so none begins with one. boardsVisibilitySettings pinned a save handler that no longer saves the display toggle — that toggle writes itself from its own click handler — so the guard now checks what the code actually does.

</details>

Thanks to above GitHub users for their contributions and translators for their translations.

v10.47 2026-07-28 WeKan ® release

This release fixes the following SECURITY ISSUES found by GitHub CodeQL code scanning:

<details> <summary><a href="https://github.com/wekan/wekan/commit/032722fcc">Trust a certificate instead of disabling verification, and escape every metacharacter</a>. Thanks to GitHub CodeQL code scanning and xet7.</summary>

Alert #430, js/disabling-certificate-validation, High: reqOptions.rejectUnauthorized = false in the outgoing-webhook path. It is right, and the switch is gone.

rejectUnauthorized: false accepts ANY certificate — including the one a man in the middle presents — which is the attack the TLS handshake exists to stop. What the reports behind it actually need is not "verify nothing", it is "this certificate is legitimate", so that is what WeKan takes now: WEBHOOK_TLS_CA_CERT (the certificate or CA to TRUST for outgoing webhooks), MAIL_TLS_CA_CERT (the same for the mail server) and MAIL_TLS_SERVERNAME (the name to verify the mail certificate AGAINST, for a wildcard that covers one level fewer than the host has).

Each is the PEM itself or a path to a file holding it. A self-signed certificate is its own issuer, so naming it here is exactly what makes it valid — and verification stays ON, the chain is still checked, the hostname is still checked. A path that cannot be read is not fatal: it says which setting failed and keeps the system trust store. The SSRF protections are untouched — the address is still resolved once and pinned, private ranges are still refused, redirects are still blocked.

Alert #429, js/incomplete-sanitization, High: a test escaped dots only when building a regular expression, so a backslash in the value could change the meaning of the pattern it was spliced into. It escapes every metacharacter now, the backslash first, with the helper the other test files already use.

</details>

and fixes the following bug:

<details> <summary><a href="https://github.com/wekan/wekan/commit/edd290433">A rejected promise no longer ends the whole server when the database is busy</a>. Thanks to Nissulya and xet7.</summary>

The snap that was restarting in a loop (#6533) showed the earlier fixes working. It also showed the server dying regardless:

SyncedCron: Fatal error encountered (unhandledRejection): ValidationError:
  ... sqlite.(*collection).UpdateAll ... database is locked (5) (SQLITE_BUSY)
SyncedCron: Received UNHANDLED_REJECTION signal - cleaning up running jobs
systemd: Main process exited, code=exited, status=1/FAILURE

quave:synced-cron installs a process-wide unhandledRejection handler that calls process.exit(1). So ANY unhandled rejection — including one write losing a race for the SQLite write lock, which is transient by definition — killed the whole server, and systemd restarted it into a database the restart had made busier. The restart counter reached 73.

WeKan takes that decision back. The package is deliberately polite about it: it only cleans up and exits if (process.listenerCount('unhandledRejection') === 1) — when nothing else has an opinion — so having one is the documented way to stop it. A transient database error is logged and recorded for Admin Panel / Problems and WeKan keeps serving; anything else is logged with its full stack and WeKan still keeps serving, because ending everybody's session over one rejected promise is a larger failure than the one being reported. An uncaught EXCEPTION still exits unless it is a transient database error: a process that threw out of a place nobody handled can be holding half-applied state.

Nothing is silenced — every rejection is logged, and the database ones are counted in the database event stream where Admin Panel / Problems shows them.

</details>

and has the following test-harness fixes:

<details> <summary><a href="https://github.com/wekan/wekan/commit/9978219b2">Four guards that pinned an older shape, and a browser test that named the wrong half</a>. Thanks to xet7.</summary>

The Visibility saves share one "is this input on screen" check now, so the guard looks at the helper rather than at each call site. /information redirects the FlowRouter way, like /translation before it. The phone rules carry !important, because the desktop rule they override is more specific. And "the newest release" in the changelog guard means the newest RELEASE — an Upcoming section may sit above it, it is checked by its own test, and a release needs at least ONE entry, since "more than five" measured the day's workload rather than the format.

The background-image tile test waited only for .board-list-item.has-background-image, which cannot tell "the board has not arrived in minimongo yet" from "it arrived without the class"; under a three-browser run WebKit reported the second when it was the first. It waits for that board's own tile first, so a future failure names the actual problem.

</details>

and updates the backlog:

<details> <summary><a href="https://github.com/wekan/wekan/commit/11c946301">TODO Later: ten issues closed since it was written, and what testing FerretDB on MySQL answered</a>. Thanks to xet7.</summary>

Every issue in # TODO Later was checked against GitHub. Ten are no longer open and are gone from the list: issues #3138, #3252, #3276, #3378, #3748, #3828, #4055, #4774, #5149 and #6511. The "already correct in the current code" category went with them, because it held only the two that are now closed.

Issue #6509 — "please test FerretDB v1 with MySQL, MariaDB and SAP HANA" — is mostly answered rather than pending: the conformance harness runs one catalogue of 100 queries against every backend with an image for this machine, and MariaDB now answers identically to SQLite on 98 of them, the two exceptions being the $slice / $elemMatch projections that no backend implements. Getting there took a dozen fixes in the fork. MySQL's confirming run is still pending and SAP HANA is untested — its image needs a licence acceptance — and the entry says so instead of implying the whole request is done.

Finnish gained databaseReportTitle ("Tietokantaongelmat"). The other 40 placeholders in that language are numbers, symbols, product names and a font name, which are the same in Finnish — the count of "untranslated" strings is mostly that. The search-operator abbreviations (b:, l:, s:) are left in English on purpose: translating them changes how a search is TYPED in that language, which is a decision for the maintainer, not a wording fix.

</details>

and updates translations:

v10.46 2026-07-28 WeKan ® release

This release fixes the following bug:

<details> <summary><a href="https://github.com/wekan/wekan/commit/c1bb2a6a5">The SWC helper fix shipped in 10.45 was deleted by the bundler, because the package says it may be</a>. Thanks to zubzhaaaw and xet7.</summary>

WeKan 10.45 still failed in Yandex Browser with Cannot find module '@swc/helpers/_/_possible_constructor_return' (#6556), and the fix for it was in that build.

@swc/helpers declares "sideEffects": false — a promise to the bundler that importing one of its modules changes nothing observable, so an import whose bindings are never READ may be removed entirely. import '@swc/helpers/_/_possible_constructor_return'; is exactly that: every line of the new file was dropped, the package subdirectory was never pulled into the bundle, and the legacy module tree came out as before. The fix compiled to nothing and looked like it had been applied.

Every helper is imported BY NAME and read now — collected into an array whose length is written to window.__wekanSwcHelpers. That is an observable effect, so no optimizer may remove the imports that feed it, and the global says in one word whether the helpers reached the bundle at all if this ever happens again.

</details>

and fixes the following test-harness faults:

<details> <summary><a href="https://github.com/wekan/wekan/commit/64971491a">A flaky WebKit navigation and a leftover container were reported as failures they were not</a>. Thanks to xet7.</summary>

Two things in the test harness answered the wrong question, and four guards had drifted.

WebKit: page.goto answered "WebKit encountered an internal error" — the browser under the load of a three-browser run, not WeKan. openBoard retries five times for exactly that, but a THROW from the navigation escaped the loop, so one flaky goto failed a test with four attempts left. The navigation is inside the try now, and the final message says which of the two happened: a board that never rendered, or a navigation that never succeeded.

Database conformance: PostgreSQL and MySQL were reported as "container did not start" when the log said failed to bind host port 127.0.0.1:35432: address already in use — a container left behind by an interrupted earlier run, not a broken image. The script removes containers named wekan-conformance-db-* (its own; a docker compose stack is named differently and is never touched) before it starts, and a docker run that hits a taken port moves to the next free one and retries.

The guards: a template lookup that knew only one of the two quote styles the file uses; a changelog summary one character over the limit, and two summaries linking the FerretDB commit they describe, which the guard now accepts because that fork is where the code lives; a redirect asserted as FlowRouter.go when a triggersEnter redirect is FlowRouter's own way to do it; and "every rule mentioning the bell or the avatar", which is three rules, not the two that place them.

</details>

v10.45 2026-07-28 WeKan ® release

This release fixes the following bugs:

<details> <summary><a href="https://github.com/wekan/wekan/commit/e901ebd3f">Every LDAP user became an admin, and searching all boards failed</a>. Thanks to karvox, ahlgrimma, frantzstaboeeg and xet7.</summary>

With ldap-sync-admin-groups set to ONE group, every user that logged in became a WeKan administrator (#6540). Two independent causes.

The group query builds (&(objectclass=group)(member=<the user>)). The member clause — the one that says WHOSE groups these are — was left out whenever the user entry had no value for the configured member format, and the search then ran as (&(objectclass=group)), which answers with EVERY group in the directory. So every user "was in" the admin group, and a login restricted by group let everyone in for the same reason. It answers with NO groups now, names the misconfigured setting in the log, and first tries the other usual spellings of the same value.

The comparison was split(',') matched exactly: "ti, admins" produced " admins" and matched nothing, an unset value produced one empty string that matched a group whose name the directory did not return, and Active Directory's case-insensitive names did not match at all. It is one shared rule now — trimmed, case-insensitive, and an EMPTY configured list can never grant admin — used by both the login path and the background sync.

"Search All Boards" answered "Server Error", with $nin needs an array in the log (#6537): two calls in the global-search publication were not awaited, so a PROMISE was handed to Mongo where an array belongs, and both id helpers ignored the userId their callers passed, so the archived half of a search asked for the boards of nobody.

The REST route that creates a checklist item gave every item sort: 0, so a checklist filled over the API came out in an order nobody chose (#6544). It appends now, like the UI does.

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/98bce9714">OAuth2 scopes, a bind address, an IPv6 database, and two TLS switches</a>. Thanks to lukasjelonek, sysblade, scoopex, 1977er, GuiGuiSoft, marioschulz93 and xet7.</summary>

A Keycloak login opened its popup and closed it again immediately (#6545): the snap's default for the OAuth2 scopes was "'openid profile email'" — the quotes are part of the VALUE — so the scope sent to the provider was 'openidemail'. The default has no quotes now, and WeKan strips them anyway, so an install still carrying the old value keeps working.

There was a mongodb-bind-ip but no way to say where WeKan itself should listen, so IPv6 was unreachable (#6546, #6555): snap set wekan bind-ip='::'. And an IPv6 DATABASE could not be reached at all (#6550), because an IPv6 literal has to be bracketed in a MongoDB URI — mongodb://::1:27019/ is not a URL.

A mail server whose certificate does not match the name it is reached by ("Hostname/IP doesn't match certificate's altnames") could not be used (#6551), and neither could a webhook endpoint with a self-signed certificate (#6553). MAIL_TLS_REJECT_UNAUTHORIZED=false and WEBHOOK_TLS_REJECT_UNAUTHORIZED=false say "connect anyway": off by default, one per purpose, and never NODE_TLS_REJECT_UNAUTHORIZED, which would drop certificate checking for everything WeKan connects to. The webhook switch changes nothing else — the connection is still pinned to the resolved address and private ranges are still refused.

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/8267a320a">The snap has an application-menu entry that opens WeKan</a>. Thanks to COOKIE-1816 and xet7.</summary>

"Wekan installed but is not visible in menu" (#6539) — because the snap installs a SERVER: it starts a daemon and shipped no .desktop file, so nothing appeared in the menu and it looked like nothing had been installed.

wekan.open is that entry. It reads the snap's own settings and opens the address WeKan is actually serving — ROOT_URL when there is one, otherwise localhost with the configured port — so snap set wekan port=… is followed without anyone editing a desktop file. On a headless install, where there is no session to hand the URL to, it prints the address instead of failing silently.

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/75d193d58">Impersonate said "Match failed", and a localhost ROOT_URL now says what it will do to your email</a>. Thanks to ahlgrimma, BastienGraziani and xet7.</summary>

Impersonating a user did nothing, and the log said "Match error: Expected string, got null" (#6536). The popup called the server with the id from its data context, and when there was none it called with undefined. The client does not call at all without an id now, and the method answers a missing one with "impersonate: a user id is required" instead of "Match failed", which named neither the method nor what was missing.

And an invitation mail arrived with http://127.0.0.1/b/... in it (#6538). Every link WeKan sends is built from ROOT_URL, so when that is left at localhost the mail goes out with the sender's own machine in it — unusable for everyone who receives it, with nothing failing and no error to look at. WeKan says so once at startup now, naming the setting and what to set it to. A warning, not a refusal: a single-machine install where localhost IS the address is perfectly valid.

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/e3f49bb26">One missing SWC helper stopped WeKan from starting in an older browser</a>. Thanks to zubzhaaaw and xet7.</summary>

WeKan 10.44 in Yandex Browser died at load with Cannot find module '@swc/helpers/_/_possible_constructor_return' and nothing rendered.

An older browser is served web.browser.legacy, where SWC compiles classes down to ES5 and emits imports of its own runtime helpers. The built legacy bundle contains link("@swc/helpers/_/_possible_constructor_return", …) — the app asks for it — while the module tree beside it holds 22 helper directories and not that one, so the module system cannot resolve what the code imports.

It is the order of the build: Meteor's scanner includes an npm package's files from the imports it can SEE, and these imports are written by the transform afterwards. _call_super came in through another helper's relative require and _possible_constructor_return, which nothing else requires, did not — exactly one was missing, and it was enough to stop the app.

client/lib/swcHelpers.js imports the ES5 class, iteration and async helper set from ordinary client code, which the scanner does see, and it is loaded first. Having the whole set removes the class of failure rather than this one instance: the next class written slightly differently would otherwise pull in the next helper nobody imported. The modern bundle was never affected, which is why this showed in one browser only.

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/3a5b22879">A busy database no longer costs WeKan its boot, and the boot no longer keeps it busy</a>. Thanks to Nissulya and xet7.</summary>

A snap upgraded from 6.09 to 10.44 was in a systemd restart loop at restart 72, with 8 CPUs at load 7 and SQLITE_BUSY everywhere:

error on boot.js Error [ValidationError]: Failed validation
[collection.go:191 sqlite.(*collection).UpdateAll]
database is locked (5) (SQLITE_BUSY)

Three things, each making the others worse.

A transient database error ended the boot. Meteor's boot.js exits the process when a startup callback rejects; systemd restarts it, and the restart re-runs the same startup work against a database that is busy because the previous boot was doing it. SQLITE_BUSY means another writer had the lock — nothing is wrong with the data — so every startup callback is wrapped now: a transient database error is logged, recorded for Admin Panel / Problems, and swallowed, and that step runs again on the next start. A full disk, a refused login or a syntax error is still fatal, because none of those fix themselves.

The board-id backfill scanned every card on every boot. It streamed the whole Cards collection and issued one multi-update per card — 130,947 of them on that instance, for each of two collections — and its "anything left to do?" guard could never go quiet, because a checklist whose card was deleted has no board id to copy. It is driven by the rows that are MISSING the board id now (normally none), in bounded chunks, and version-gated like the schema upgrade beside it, so an unchanged version costs one findOne.

FerretDB answered SQLITE_BUSY where the busy timeout could not help. The driver's default transaction is DEFERRED: it takes the write lock at its first write, and if another connection has written since its read snapshot, SQLite fails it immediately without calling the busy handler. The fork's SQLite DSN defaults to _txlock=immediate now, so BEGIN asks for the write lock — which the 30-second busy handler does cover — and a contended writer waits its turn.

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/c5d13889b">The WIP limit would not switch off, the Attachments checkboxes were not ticks, the sidebar rows did not line up</a>. Thanks to Alishara and xet7.</summary>

Three UI bugs from one report.

The WIP-limit popup: "the checkbox can not be unchecked and the counter always falls back to 1" — one cause for both. getWipLimit() read the list back through ReactiveCache.getList(this._id), and on the SERVER that getter is async: it returns a promise, and a promise has no wipLimit, so the helper answered 0 for every option. enableWipLimit therefore saw a value of 0 and reset the limit to 1 on every click, and toggled !enabled where enabled was always 0, so every click turned the limit ON. The document is this — the server needs no lookup, and a toggle wants the state as it was when the click happened. The client keeps the lookup, which is what makes the popup follow the change. Apply also refuses a limit that is not a usable number instead of sending NaN, which passes check(limit, Number) and then dies in the schema with nothing to show the user.

Admin Panel / Attachments: every checkbox on those panes — Backup's three, each storage's Enabled and Read, the S3 path-style flag, the avatar-upload block — drew as a grey rotated rectangle instead of a green tick. They were native <input type="checkbox"> styled into WeKan's material checkbox, which needs the browser to drop its own rendering for appearance: none; where it does not, the geometry applies and the colours do not. They are .materialCheckBox divs now, the same markup as the rest of WeKan. Two of them could not be unchecked for a second reason: their state was written as checked="{{filesystemRead}}", a quoted STRING — and checked="false" is checked in HTML.

The sidebar checkbox rows ("checkboxes and text don't fit well"): a row is a.flex > i.fa + span, and .flex is only display: flex — no alignment and no gap — so the box glyph touched the first letter of its label and sat on a different line from it.

</details>

and fixes the following release-tooling bugs:

<details> <summary><a href="https://github.com/wekan/wekan/commit/520254768">Two release failures that were the workflow's own fault</a>. Thanks to xet7.</summary>

The v10.43 run failed five jobs; two of them were the workflow's.

build-win64 failed with "wekan-10.43-win64.zip has no bundle/main.js" — four lines after 7-Zip reported writing a 283 MiB archive of 46014 files. The zip was fine; the check added the night before was not. 7-Zip lists Windows paths with BACKSLASHES, and it searched for them as a regular expression, where bundle\main.js means bundlemain.js — which nothing is called. It would have failed on every release. The listing is taken once now and searched as a fixed string, for either separator, and both forms were replayed in bash to be sure the old one matches nothing and the new one matches 7-Zip's own output.

snap-variants did all its work and died on the last push: "remote: Permission to wekan/wekan-gantt-gpl.git denied". Its guard checked that WEKAN_REPO_TOKEN was SET, and set is not the same as allowed — so a whole snap build burned before the token was found wanting. It asks GitHub whether the token can push to that variant repository now, and skips with a named reason if it cannot. That does not grant the rights; widening the token is a maintainer action, written up with the rest of the run's failures in ../log/workflow/TODO.txt.

The other three failures are not the workflow's: ppc64el and s390x cannot be built by an unmaintained QEMU action that caps at core22 (Snap-Core.md) — fixed by the next entry — and the variant pushes need a token that may write those repositories.

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/2dde0b48a">The ppc64el and s390x snaps build on Launchpad now, and the dead QEMU job is gone</a>. Thanks to xet7.</summary>

Both legs failed on every release, and not because of a secret or a flake: "Your build requires a base that this tool does not support (core24)". They were built by a snap-qemu job using diddlesnaps/snapcraft-multiarch-action, whose compiled dist/index.js caps at core22 in three independent places, whose build image has no :core24 tag, and which is unmaintained. WeKan's snapcraft.yaml is base: core24, so the build died the instant snapcraft read it — after the version gate passed and QEMU had set up. There is no maintained QEMU multi-arch snap action that does core24; Canonical's answer for an architecture with no native runner IS Launchpad remote-build (Snap-Core.md reads the evidence out of the action's source rather than off the error string).

snap-launchpad's matrix is now [ppc64el, s390x, riscv64], and snap-qemu is deleted rather than disabled.

These two arches once left Launchpad FOR QEMU, because the old remote-build legs ended in Launchpad state "Stopped" with no snap and then failed at snapcraft upload ("is not a valid file", exit 64). Today's job is what those legs were not: it retries the remote build 3×, requires the .snap file to exist, and uploads only when it is there. It stays continue-on-error, fail-fast: false and timeout-minutes: 180, so the price of this path — a Launchpad queue that can last hours — can neither fail the release nor cancel another architecture, and each arch publishes the moment it finishes.

It needs LP_CREDENTIALS as well as SNAP_AUTH, which these two arches did not need before. The first step names either secret when it is unset and decodes LP_CREDENTIALS, and the remote-build step says by name when Launchpad answers unauthorized, so a credential problem is one named line rather than an ordinary-looking build failure.

</details>

and improves FerretDB v1, which WeKan runs on:

<details> <summary><a href="https://github.com/wekan/FerretDB/commit/f4b1450e">MySQL and MariaDB, found by running the query catalogue against them</a>. Thanks to xet7.</summary>

The conformance run is a live client against a live engine, and it took the mysql backend from answering nothing to answering nearly everything, in two passes.

First pass: every pushed-down filter was built as col->$.?, which is not MySQL — the -> operator takes a LITERAL path, and a placeholder there is a syntax error — so any find, update or aggregation carrying a filter failed with Error 1064. Paths are bound through JSON_EXTRACT(col, ?) now. 55 identical answers became 65, and 44 errors became 33.

Second pass, the rest of them: JSON_CONTAINS wants a JSON document as its candidate, so $eq, $ne and $in answered Error 3146 until the candidate became CAST(? AS JSON); createIndexes on a field that already had an index built either a trailing comma or the bare ALTER TABLE db.t; and the statistics query behind collStats / dbStats never aliased information_schema.tables.

MariaDB could not create a collection at all: it does not have MySQL's -> and ->> JSON operators, so every statement carrying one failed there. All of them are JSON_EXTRACT / JSON_UNQUOTE(JSON_EXTRACT(...)) now, which both engines understand.

Third pass, after MariaDB could run at all: CAST(? AS JSON) is a syntax error on MariaDB, which has no JSON type, so the candidate goes through JSON_EXTRACT(?, '$') — one statement both engines accept. And reading the paths those fixes had just made reachable found four more: DeleteAll could never delete a document (its branch was inverted and crossed), DROP INDEX was PostgreSQL's spelling, a boolean candidate bound as 1 would have matched nothing — silently, which is worse than the error it replaced, since a pushdown that is too narrow returns rows the in-Go filter never sees — and the per-index size query behind collStats was not valid SQL.

Date and BSON-timestamp RANGES are no longer pushed down on this backend: one answered with no documents where every other backend answered with two, and until a live EXPLAIN shows the expression MySQL needs, the Go filter is the honest answer.

The fixes are in the WeKan fork of FerretDB v1 (wekan/FerretDB, main-v1), which is what docker-compose-ferretdb-v1-mysql.yml and -mariadb.yml run.

</details> <details> <summary><a href="https://github.com/wekan/FerretDB/commit/ac8c59cf">The conformance run found five FerretDB gaps and two that stopped MySQL and MariaDB dead</a>. Thanks to xet7.</summary>

The new "All databases (sequential)" test — one query catalogue, every backend that has an image for this CPU — was run for the first time, and it earned its keep. Everything is fixed in wekan/FerretDB, which WeKan's default database is built from.

SQLite and PostgreSQL agree on 98 of 100 cases, and the two they do not are $slice and $elemMatch projections, which neither implements — agreement about a limitation rather than a difference between them.

Seven $group accumulators answered only "not implemented yet", on every backend: $avg, $min, $max, $first, $last, $push and $stdDevPop. Only $sum and $count existed. All of them are implemented now, plus $addToSet and $stdDevSamp.

MySQL and MariaDB could not store anything at all. MySQL rejected every statement — the backend quoted identifiers with double quotes, which MySQL reads as string literals, so every INSERT was a syntax error. MariaDB never got that far: the driver was configured with a struct literal whose zero value refuses the native password handshake, which is what every default MariaDB root account asks for. Both fixed, with tests.

</details>

and fixes the following test-harness bugs:

<details> <summary><a href="https://github.com/wekan/wekan/commit/7b017c56a">The login helper logged every browser test out again, so the whole suite failed</a>. Thanks to xet7.</summary>

The Chromium run failed 16 of its first 17 tests, each burning the full 60-second timeout inside the fixture — "Test timeout of 60000ms exceeded while setting up boardPage" — with the page showing "Board not found".

loginWithToken installed a page.addInitScript that removes Meteor's three Accounts keys from localStorage, so a previous session cannot resume and race the new login. But an init script runs on EVERY navigation of that page: the goto right after the login, the one that opens the board the test just logged in FOR, also started with the token removed. The client was anonymous, the seeded board is private, and the router answered "Board not found" — five times, 20 seconds each, until openBoard gave up. The one test that passed in that stretch is "user NOT added to board cannot see it", which is what being logged out looks like too.

The clear is one-shot now: the init script does nothing unless a flag is armed, and it consumes the flag; the login arms it and reloads, so exactly the one page load the login happens on has nothing to resume, and every later navigation keeps the session. It still touches only those three keys.

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/9eedab13b">Every node suite runs and is reported, instead of the run stopping at the first failure</a>. Thanks to xet7.</summary>

test:unit:node was node tests/a.cjs && node tests/b.cjs && …, 260 suites long, and npm's && stops at the first failing suite — so everything after it never ran, and nothing said so. One run printed "tests:508 fail:1" having skipped about 200 suites: one stale guard hid the next, one full test run at a time.

tests/run-node-suites.cjs replaces the chain. It DISCOVERS the suites (tests/*.test.cjs|js, tests/unit/*), so writing the file is registering it; it runs every suite even when an earlier one failed and lists the failures together at the end; each suite still runs in its own node process; a per-suite timeout means a hanging suite fails that suite instead of the run; and --list, --bail and substring filters are there for working on one. Discovery was compared against the old chain before switching — the same 260 suites, nothing gained or lost.

build.sh now reads the runner's own ===== node suites: N run, M failed line for the count, instead of guessing from error text.

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/b46eecf58">The 20 suites the chain had been hiding, and the four real defects among them</a>. Thanks to xet7.</summary>

With the runner in place, all 260 suites ran for the first time: 20 failed.

Four were defects in the app. accentOf('constructor') returned Object's constructor — a FUNCTION — because a plain lookup answers from Object.prototype, so a board colour named after any prototype member would have written a function into a stylesheet. And in RTL: the dependency overlay and its connect handle each set a physical left/right AFTER the logical property, so the physical one won and both stayed on the LTR side in an Arabic or Hebrew layout; the skip link, the avatar's account badge, the stats value column and the theme-category label were physical too. All logical now.

The other sixteen were guards pinning a spelling or a design that had since changed — a fixed-size slice a grown comment pushed the subject out of, the Grey Icons feature that is gone, a separator count the site-theme picker made four, the board-member restriction that moved into Organizations and Teams, the hamburger that is now the last button in the flow, the report page the SERVER names. Each was corrected to what the code does now, with the reason written down. Two of them could never have passed: the "no hand-written table" regex matched the +tablePage include the same test requires, and the RTL scanner read CSS COMMENTS as declarations — the comment explaining why left: 50% needs no RTL variant was reported as a violation of the rule it documents.

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/ca8983d99">A guard about ordering failed because of how a process is started</a>. Thanks to xet7.</summary>

tests/sandstormMigrationBridge.test.cjs failed the whole node suite with "bridge is released BEFORE the importer binds the port". The ordering in sandstorm-src/start.js is correct and unchanged; the guard was anchored on spawnSync(NODE, [IMPORTER], and every spawn in that file goes through cpuExec() now — spawnSync(...cpuExec(NODE, [IMPORTER]), …). indexOf returned -1, and "stopBridge is before -1" is false. It anchors on WHAT is spawned now, and asserts each anchor was found.

The summary was also counting that one failure twice, matching both the AssertionError line and the throw err line of the same dump.

</details>

and adds the following test menu entries:

<details> <summary><a href="https://github.com/wekan/wekan/commit/24ffa4bd3">Run everything, or all FerretDB tests, sequentially</a>. Thanks to xet7.</summary>

Two entries in ./build.sh → Tests, and in build.bat.

Run all FerretDB tests - SEQUENTIAL runs the FerretDB subdirectory's own build.sh test-all: unit, vet and the integration suite, one at a time. FerretDB is expected inside this repo — the "All databases" entry clones wekan/FerretDB when it is not there — and if it is missing this says so, with the clone command, rather than failing obscurely.

EVERYTHING (sequential) runs the three suites one stage at a time: WeKan's own tests (which build a fresh bundle and start a server), then the database conformance run (which builds FerretDB from source and runs one query catalogue against every database with a Docker image for this CPU), then all of FerretDB's tests. They share ONE ../log/<datetime>/ directory, so a run that touches three test systems still leaves its logs in one place, and it ends with a three-line verdict. Nothing runs concurrently, and the menu text says so: this takes a long time, and the reason to run it is to find out what is broken, which needs readable output more than speed.

build.bat runs releases/run-everything.sh rather than reimplementing any of it — the WeKan stage needs a POSIX shell throughout, and a second implementation would drift. That script is also the non-interactive way to run it anywhere.

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/69f2cb1ab">Tests menu runs everything first, and says what each option really tests</a>. Thanks to xet7.</summary>

The menu offered "ALL tests, parallel", "ALL tests, sequential" and, eleven entries below them, "EVERYTHING (sequential)", and nothing said what the difference was. "ALL tests" is WeKan's own suite only — Mocha, the node unit suites, the import regression, the node E2E harness and the three browsers. "EVERYTHING" is that, then the database conformance run for every database with a Docker image for this CPU, then all of FerretDB's own tests.

The everything-run is now entry 1 in ./build.sh and in build.bat, the other two are named WeKan's own tests only, and their descriptions end with what they do NOT cover.

Every option now also writes its log to ../log/<datetime>/, beside the whole-suite runs — Mocha, the import regression, node E2E, each Playwright browser, the floating-promises guard and the test counts. The new helper reuses WEKAN_LOGDIR when a larger run set one, so a whole run stays in ONE directory. Until now, running a single option left nothing to read afterwards.

The guard test pins both: entry 1 must be the everything-run, entries 2 and 3 must not claim to be ALL tests, and both scripts must log every option.

</details>

Thanks to above GitHub users for their contributions and translators for their translations.

v10.44 2026-07-28 WeKan ® release

This release adds the following test:

<details> <summary><a href="https://github.com/wekan/wekan/commit/7a3783fbe">Tests menu runs every database this CPU can, and checks they answer the same</a>. Thanks to xet7.</summary>

FerretDB v1 translates one MongoDB query into five different SQL dialects, so "WeKan starts on MariaDB" says very little. The question that decides whether a backend can be trusted with a board is whether {n: {$gt: 5}} returns the same documents, in the same order, as it does on SQLite — and nothing was asking it. ./build.sh → Tests → All databases (sequential) now does, and build.bat has the same entry, running the same script rather than a second implementation that would drift.

It builds FerretDB v1 from source first: the FerretDB subdirectory is cloned from [email protected]:wekan/FerretDB if it is not there, updated if it is, and built through its own build.sh, which installs the Go toolchain and the module dependencies when they are missing — so the tests run against the newest code, not a downloaded release. Then, for each backend whose database image has a build for THIS CPU — asked of the registry with docker manifest inspect, so no table can go stale — it starts that database, runs the freshly built FerretDB against it, runs the whole catalogue and stops everything. Sequentially, because they all use the same FerretDB port and a database under test should not be competing with three others.

The catalogue is 100 cases in 15 groups, taken from FerretDB v1's own handler sources rather than from MongoDB's manual: every query, update and bitwise operator, the aggregation stages and accumulators, projection, sorting, paging, count, distinct, indexes and uniqueness, capped collections — which is how the OpLog exists at all — and the commands whose answers may legitimately differ. The seed data is deliberately awkward, because tidy data lets a broken translation pass. Answers are normalised and compared byte for byte against SQLite, so document ORDER counts; two backends failing the same way is agreement about a limitation, one answering where another fails is a difference.

Everything lands in ../log/<datetime>/ with every other test run, including db-conformance-report.md. SAP HANA is opt-in behind WEKAN_CONFORMANCE_HANA=1 — amd64-only, ~16 GB of RAM, SAP's licence — so a menu choice cannot start it by accident.

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/4541fa99e">The database tests run on their own ports, beside whatever else is running</a>. Thanks to xet7.</summary>

Three things the first real run, on arm64, found.

The FerretDB it built could not start: panic: commit.txt value ... != vcs.revision value .... Go stamps the VCS revision into the binary and FerretDB's build/version panics when it disagrees with the committed commit.txt, which only its generator refreshes — so every commit made after the last refresh built a binary that panicked, whatever the change was. Fixed in the fork: its build.sh build regenerates them now, as the release build always did.

Ports: FerretDB listened on 27017, which is where a dev server's database lives and where the compose files publish FerretDB. So this could not run beside anything else and, worse, could have pointed the tests at somebody else's database and rewritten it. It listens on 37017 now and publishes the database server on 35432, both moved on if something is already listening, both settable with WEKAN_CONFORMANCE_PORT and WEKAN_CONFORMANCE_DB_PORT, and its containers are named per run so a stack started with docker compose up is never reused or stopped.

And Ctrl-C only killed whatever was in the foreground — a registry lookup, a sleep — after which the loop carried on and reported the interrupted lookup as "NO linux/arm64", which is a lie about the image. An interrupt ends the run now, and the image check has three outcomes rather than two: has it, does not have it, could not ask. A FerretDB that will not start also prints the last lines of its log, because the reason is usually one line.

</details>

Thanks to above GitHub users for their contributions and translators for their translations.

v10.43 2026-07-28 WeKan ® release

This release adds the following new features:

<details> <summary><a href="https://github.com/wekan/wekan/commit/fbf88fa19">A Docker Compose file for every FerretDB v1 backend, generated from one source</a>. Thanks to xet7.</summary>

docker-compose.yml runs FerretDB v1 on its embedded SQLite. The same FerretDB can store into PostgreSQL, MySQL, MariaDB or SAP HANA instead, and there was no compose file for any of them — those backends were reachable only by editing the default file by hand. There is one per backend now: docker-compose-ferretdb-v1-postgresql.yml, -mysql.yml, -mariadb.yml and -sap-hana.yml.

They differ ONLY in the database. The same WeKan image, the same ~700 lines of environment and the same comments explaining them — they are generated from docker-compose.yml rather than copied, and tests/dockerComposeBackends.test.cjs compares each file's WeKan service against the default one line for line. A copy drifts on the first setting somebody adds to one file, and then a user following the PostgreSQL file gets a differently configured WeKan than the SQLite file gives them.

Each names its own database service, runs FerretDB with the matching --handler, and says at the top how to start, follow and stop THAT file with docker compose -f. The images are the newest published — postgres:18, mysql:9, mariadb:12 and SAP's saplabs/hanaexpress — with the LTS alternative named in a comment where there is one. PostgreSQL is confirmed working with Meteor 3; MySQL, MariaDB and SAP HANA say in their own headers that they are experimental, because they are. SAP HANA also needed the wekan/FerretDB release binaries to be built with the ferretdb_hana build tag — without it --handler=hana is an unknown handler — so that build now passes the tag.

build.sh and build.bat offer every docker-compose*.yml in the repo now, not four of the eight, and the test fails if a file exists that neither menu can start, or if a menu offers a file that does not exist.

</details>

and fixes the following bugs:

<details> <summary><a href="https://github.com/wekan/wekan/commit/6c1d62cde">Admin Panel reports show the whole instance, not the admin's own boards</a>. Thanks to xet7.</summary>

Boards Report was empty while the Cards report beside it listed cards from thousands of boards. It published userBoardIds(this.userId) — the boards the ADMIN is personally a member of — and on an instance whose admin is not a board member that is nothing at all. Until the pagination fix below, the pane hid it: it rendered every board in minimongo, which the All Boards page had already put there. The Files report was scoped the same way, to the cards that admin can access.

Both cover the whole instance now, as Cards, Broken cards, Rules, Impersonation and Recovery already did. That membership selector was also what kept those two publications honest, so both gained an isAdmin guard at the top — boardsReport asked only for a logged-in user before — and each count method counts the same set its publication pages. Files and Rules name their page now too: opening one card puts its attachments in minimongo, opening a rules editor puts that board's rules there, and neither belongs in a report page. The Files page is sorted by name, because paging with no sort is paging over natural order — where a document can appear on two pages or on none — and that sort has an index.

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/c1add5bc5">The admin lists paginate honestly: one page of ten rows, index-backed</a>. Thanks to xet7.</summary>

Admin Panel / People showed the admin on every one of its 578 pages, and Admin Panel / Problems / Broken cards was one endless page under a pager that said "1 / 1". The same bug in two shapes: the pane subscribes to a publication that sends exactly one page, then reads that page back out of minimongo — which holds far more than the page. The logged-in user's own record is always there, because accounts publishes it, and so is every card of every board the admin has opened; a plain find(query) cannot tell those apart from the page. So the server names the page now: getPeoplePageIds returns the ids of the People page with the publication's own selector, sort and window, and the Broken cards, Cards and Boards report publications send the ids of the page they just sent alongside it. Each pane renders that list, in that order, and nothing else.

Every paginated page in WeKan loads ten rows at a time, from one constant, including the pages that draw a pager of their own — the reports and the event streams, the four People panes, Translation, the search pages and the archive. Table.md records the rule and its two deliberate exceptions.

And the counting behind those pagers is index-backed. Every count already asked the database for a count rather than fetching the rows, but several counted and paged on unindexed fields: People sorts users newest-first, so on an instance with 14000 users every page of ten sorted all 14000 first, and Broken cards asks an $or over boardId/swimlaneId/listId/type, which uses no index at all unless every branch has one. The missing indexes are created at startup by the idempotent ensureIndex, so an upgrade adds them by itself. tests/adminPageRows.test.cjs and tests/paginationIndexes.test.cjs pin all of it.

</details>

and has the following release-tooling changes:

<details> <summary><a href="https://github.com/wekan/wekan/commit/ec8f706f7">The release workflow says what worked, what failed, and why</a>. Thanks to xet7.</summary>

release-all.yml could fail in ways that left no explanation, or no failure at all: a version bump that committed nothing, a release published with empty notes, a bundle zip that exists but holds no bundle/main.js, an upload that reports success and leaves no asset behind, and a multi-arch image whose manifest is missing a CPU — which nobody notices until a user on that CPU is told there is no matching manifest. Each of those is checked now, and each check says the same three things when it fails: what was expected, what was actually there, and what to do about it. On success it prints one OK: ... line naming what it verified, so the log shows what worked and not only what did not. Every job also ends with a "Job result" step that runs whatever happened, so the run summary is one line per job — OK, FAILED or CANCELLED — instead of eighteen logs to open.

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/7f1271197">A missing or broken release secret is one named line in the log</a>. Thanks to xet7.</summary>

Only three jobs said anything about their secrets. Everywhere else a missing one surfaced deep inside a tool — a login that was refused, a checkout that 404'd — with nothing naming the secret, and the docker job did it only after the multi-arch build had already spent half an hour on an image it could not push. Every job that needs a secret now checks it in its FIRST step and names what is missing. Where the answer is cheap it also checks that the secret WORKS, which is the other half of the question: WEKAN_REPO_TOKEN is asked whether it can push to the repository it is for, the three registry credentials are decoded and logged in with, and the base64 ones must decode. What only the far end can answer is answered where it is used — the Snap Store uploads name SNAP_AUTH when they are refused, and a Launchpad build that fails with an unauthorized in its log says LP_CREDENTIALS may be why.

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/dcefd4b36">Every disabled release job runs again, so its output is visible</a>. Thanks to xet7.</summary>

snap-qemu (ppc64el, s390x), snap-launchpad (riscv64), snap-variants, ucs and nextcloud were hard-disabled with if: ${{ false }}, so a release never said anything about them at all. They all carry continue-on-error: true, so running them cannot fail a release or hold up another job — the point of running them is to SEE what they report. The three that skip their work while their secrets are unset — snap-variants, ucs, nextcloud — do it with a ::warning:: now instead of a quiet ::notice::, so the missing secrets show in the run summary rather than only in a log.

</details>

and has the following developer-facing change:

<details> <summary><a href="https://github.com/wekan/wekan/commit/7f63f0422">A test pins that a per-tenant admin sees no report data</a>. Thanks to xet7.</summary>

Admin Panel / Problems is instance-wide, so every report publication and count method asks for the SITE admin flag user.isAdmin — not canOpenAdminPanel, which a per-tenant Global Admin passes — and problems is not one of a tenant admin's tabs. That is what keeps the Boards and Files reports honest now that they cover the whole instance instead of the admin's own boards, so it is pinned rather than left to be re-derived. The one admin list a tenant admin does get, People, is tenant-scoped in all three places that decide what it shows: the publication, the page-ids method and the count all go through peopleScopeSelector, so its pager cannot count rows its page may not show.

</details>

and improves the documentation:

<details> <summary><a href="https://github.com/wekan/wekan/commit/fea3c432e">docs/Databases is one directory per database, each with a README</a>. Thanks to xet7.</summary>

It was a flat list in which the database was a filename prefix, in three spellings — MongoDB-Driver-System.md, mongodb-avx-qemu.md, MongoDB_OpLog_Enablement.md — plus FerretDB2-PostgreSQL.md and a ToroDB-PostgreSQL directory. Somebody asking "how do I run WeKan on PostgreSQL" had to read the prefixes to work out which files were even about their database.

Now there is a directory each for Migrations, MongoDB, FerretDB — with 1 and 2 inside it, because v1 and v2 are different products with different backends — and ToroDB, with PostgreSQL inside it. Each file keeps its name minus the redundant prefix, and the old PostgreSQL.md, which was an index of FerretDB and ToroDB rather than a document about PostgreSQL, becomes the directory's README.md.

Every directory has a README.md saying what is in it and where to go next — including FerretDB/1, which had no documentation at all although it is WeKan's default database and had just gained four more backends. A link to a directory points at the DIRECTORY, not at its README.md, because that file is what is opened by default anyway. Every link into the old paths is updated: in the docs, in docker-compose-ferretdb-v2-postgresql.yml and on the wekan.fi website, along with the relative links inside the moved files, which gained a directory level. tests/docsDatabases.test.cjs pins the layout, the READMEs, the directory links, that every relative link resolves, and that nothing outside CHANGELOG.md still names an old path.

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/69f2be116">Which other databases run on many CPUs, and what a FerretDB v1 backend would cost</a>. Thanks to xet7.</summary>

WeKan runs on every CPU Node.js runs on, and this fork of FerretDB v1 exists because MongoDB publishes no server for most of them. Alternatives.md in docs/Databases/FerretDB/1 answers the two questions that keep coming back: which databases even have images for those CPUs, and what would be missing in FerretDB v1 before it could store into one.

The architecture table is read from each registry's own manifest rather than from documentation, and it says plainly what that data shows: PostgreSQL is the only widely-portable database server — the only one publishing ppc64le, s390x and riscv64. MariaDB covers ppc64le and s390x, MySQL neither, and MongoDB itself and upstream FerretDB 2 are amd64 + arm64 only. This fork's own image covers nine platforms.

What a new backend needs is taken from the code: a pure-Go driver, because the binaries are built CGO_ENABLED=0 and that is what makes one build serve nine architectures — which is why Oracle and IBM Db2 are out despite Db2's ppc64le and s390x images; the three internal/backends interfaces, none of them stubbable; a metadata registry; the SQL features the translation actually uses, including the record-id column that capped collections — and therefore the OpLog — are built on; correct MongoDB semantics on top of that; and a live integration run, which is the whole difference between the confirmed and the experimental rows. Plus the shortcut: a database that speaks the PostgreSQL or MySQL wire protocol needs no new backend, only an existing one that survives its dialect — CockroachDB brings s390x that way, for the price of a test run.

A follow-up groups them, so the reader is not left to find the pattern: they are not fifteen questions but seven families — PostgreSQL-wire, MySQL-wire, enterprise SQL, embedded, columnar, key-value, and what already speaks MongoDB — and inside a family the answer is one answer. The page opens with all of it in five sentences and one table, because the conclusion (verify the three backends that already exist rather than write a fourth) was previously visible only to whoever read to the end.

</details>

Thanks to above GitHub users for their contributions and translators for their translations.

v10.42 2026-07-27 WeKan ® release

This release fixes the following bugs:

<details> <summary><a href="https://github.com/wekan/wekan/commit/0c9b7b7fa">Every popup had 10px of empty space above it that nothing asked for</a>. Thanks to xet7.</summary>

The space above the first row of Board Settings, Board View, Member Settings, Sort Cards, Change Watch, Change Visibility and Swimlane Actions — and above the Title field of Rename Board — was much larger than the space beside it. None of it came from popup.css: peopleBody.css and translationBody.css each carried a bare .content-wrapper { margin-top: 10px }, and .content-wrapper is not a settings class at all — the only element in WeKan with that class is the scrolling body of a POPUP, so those two strays pushed the content of every popup in the app down by 10px. They are gone, and the gap that is left is 6px under the header plus the content's 12px of padding = the same 18px the content has beside it. Change Visibility and Change Watch also lay their rows out as three left-aligned columns now — icon, name, description — with the check mark of the active row in a track of its own, so it can no longer push that row's description out of line with the others. tests/popupSpacing.test.cjs pins both, and fails again if any stylesheet styles a popup-internal class without saying which popup.

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/0c9b7b7fa">The board bar centers its items, and the hamburger sits where its space middles</a>. Thanks to xet7.</summary>

The second header bar centred its items inside a content box that had 7px of padding above and none below, so everything sat half that distance high — most visibly the board title, the tallest item in the row. The button groups then carried a 3px top margin, which is a shift and not centring, so they hung below the title's centre line. The padding is symmetric now, wrapped rows are separated by row-gap instead of that margin, and the title centres on its own text rather than on a line box far taller than it. The sidebar hamburger had 2px of margin on one side and 8px on the other, which left an empty strip between it and the end of the bar; equal margins put it in the middle of the space between its divider and that edge.

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/c2fa9e0b3">Every upload threw in getFileStrategy, so no new file was mime-checked</a>. Thanks to xet7.</summary>

From the dev-server log, on a plain attachment upload: [onAfterUpload] filename hardening failed: TypeError: Cannot read properties of undefined (reading 'gridFsFileId'). getFileStrategy read fileObj.versions[versionName].meta.gridFsFileId to decide the storage, and a freshly uploaded file has no meta on its version — only a GridFS one does — so that read threw on EVERY upload. onAfterUpload catches and logs, so the upload succeeded and looked fine while the two things that hook does were silently skipped for every new file: detecting the real mime type and correcting the stored filename from it. All three optional places are read defensively now, and the decision is unchanged where the data is there.

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/1f4bf0be1">Migrated images show in the card view again</a>. Thanks to S0QR2 and xet7.</summary>

Reported after upgrading from WeKan 6: an attached image is visible in board view, downloads correctly from the card, and yet the card view shows an empty white box that cannot be maximized — while cards created after the upgrade are fine. The card view does not look at the file, it looks at the flags Meteor-Files writes at upload time (if(isImage) img … else span= extension), and an attachment that came through a migration can arrive without them, and often without extension or type either. The download button worked because a download needs no flag. models/lib/attachmentKind.js is now the one place that derives the kind — from the mime type where the document has one, in any of the fields that have carried it, and from the file name where it does not; a flag the document states is believed, and the name is consulted only when no type is stated. The gallery, the "add cover" menu and the viewer all ask it, and a startup repair writes the same answer back to the documents so the REST API and the exports agree with the screen. tests/attachmentKind.test.cjs pins the rules.

</details>

and has the following developer-facing changes:

<details> <summary><a href="https://github.com/wekan/wekan/commit/1013c87f7">Two test-harness fixes the newest run found</a>. Thanks to xet7.</summary>

The node suites got four assertions further and stopped in the same file: two more cpuExec expectations still spelled the qemu-wrapped command as a bare name, which cpu-exec resolves to an absolute path now. And WebKit failed 33-board-domains with "Unexpected userId after login" — the admin's id, when the test had just switched to the non-admin: loginWithToken logged the new user in over a session that was still someone else's, so the poll could not tell "not landed yet" from "landed on the wrong user". It logs the previous user out first and waits for an empty session, with a 15s settle deadline for a loaded three-browser run.

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/352672178">cpu-exec hands qemu-user a path it can open, and its test stops assuming the machine</a>. Thanks to xet7.</summary>

First real run of the node suites — they had never run anywhere until the "Run ALL tests" change — and cpuExec.test.cjs failed, which is what that change was for. Two real things behind it. qemu-user does not search PATH: it opens the file it is given, so a bare command name (cpu-exec --features x86_64=avx mongod …) reached qemu as a relative path that does not exist and qemu exited 1 having run nothing — on exactly the CPU this helper exists for. cpu-exec resolves the name with type -P first and hands over an absolute path; an absolute path is passed through as before. And the test asked for "no qemu" with PATH=/usr/bin:/bin, which is only true on a machine without qemu-user installed; it now builds a PATH holding exactly the tools cpu-exec uses and nothing else, so the case is the same everywhere.

</details>

Thanks to above GitHub users for their contributions and translators for their translations.

v10.41 2026-07-27 WeKan ® release

This release fixes the following bugs:

<details> <summary><a href="https://github.com/wekan/wekan/commit/302bd9041">A removed DDP session no longer crashes the server on its 101st message</a>. Thanks to bluetopaz1204, Nissulya and xet7.</summary>

From the comments today, on 10.40 with real users: TypeError: self._pendingRemoveFunction is not a function at Session.send, then Main process exited, code=exited, status=1/FAILURE and Scheduled restart job, restart counter is at 4. It is a state-machine hole in Meteor's ddp-server: _removeSession sets messageQueue = [] and a remove function; the remove function clears itself and deletes the session but leaves the queue, which is truthy — so Session.send keeps queueing for a session that no longer exists and the message past maxMessageQueueLength (100) calls null. It runs from an Immediate with no try/catch above it, so it is an uncaught exception, and synced-cron exits the process on one: every user disconnected, new cards only after a reload, systemd restarting — which reads as "WeKan is slow and the CPU is high". A queue with no remove function is now dropped instead of pushed to, so send() takes its ordinary path; the grace-period queue for a real reconnect and a legitimate overflow both behave as before. tests/ddpSessionSendGuard.test.cjs replays the upstream state machine, including that the unguarded version really does throw on the 101st message.

</details>

and has one icon set:

<details> <summary><a href="https://github.com/wekan/wekan/commit/3487d2519">One icon set: Font Awesome, and the Grey Icons feature is removed</a>. Thanks to xet7.</summary>

WeKan used colourful Unicode emoji as its icon set in 8.00–8.24 only; before and after, the icons are Font Awesome 4.7. A handful had survived that change — every notification type, the board star, the vote thumb, the spent-time badge, the list width toggles, the bookmarks star, the back arrow, the sidebar's plus and hash, the four date badges, the mobile drag handle, the multi-selection tick, the gantt day markers — each a different picture on every platform, at a size and colour the stylesheet does not control. They are Font Awesome glyphs now, and "Grey Icons" in Member Settings, which existed to grey exactly those emoji with a MutationObserver over every rendered subtree, is removed whole: menu entry, handler, method, schema field, publication, stylesheet, the string in all 147 language files, the API-spec field and the docs mentions. The language-picker flags stay: Font Awesome 4.7 has no flags. tests/fontAwesomeIcons.test.cjs fails on an emoji in any template or CSS content:, and on any leftover of the removed feature.

</details>

and has the following developer-facing changes:

<details> <summary><a href="https://github.com/wekan/wekan/commit/fa831e982">"Run ALL tests" now runs all the tests, in build.sh and in build.bat</a>. Thanks to xet7.</summary>

Two gaps that together meant most of the suite never ran anywhere. The flow ran six jobs — mocha, the import regression, the Node E2E harness and the three browsers — and no npm unit script at all, so everything in test:unit:node and test:unit:all (the ~165 .cjs guards plus the sticker, Trello and OAuth2 suites) was never executed by "Run ALL tests". Both scripts now have a unit job that runs meteor npm run test:unit:all, counted, waited for and reported with its own log. And the scripts themselves were incomplete: 72 suite files existed that no npm script mentioned — they are all registered now, and tests/testsAreRegistered.test.cjs fails when a plain-node suite is in no script or when the flow stops running it. Not verified here: with no node in this environment those 72 suites have never been executed, so some may fail on the first real run — that is the information this buys, not a regression it introduces.

</details>

Thanks to above GitHub users for their contributions and translators for their translations.

v10.40 2026-07-27 WeKan ® release

This release fixes the following bugs:

<details> <summary><a href="https://github.com/wekan/wekan/commit/64c1f426a">The right sidebar starts below the header, in both modes</a>. Thanks to xet7.</summary>

Its upper part — the panel title, the tabs, the members row — was underneath the two header bars, so the sidebar appeared to start in the middle of itself. On a phone it is position: fixed, and it has to be: the board behind it is wider than the screen, so an absolutely-positioned sidebar pins to the far right of the BOARD and shows as a narrow strip. But fixed means against the viewport, and it was pinned at top: 0 — where the header bars are. There is no number to put there: the header is the quick-access bar plus a board bar whose buttons wrap to one, two or three rows depending on language and window width. So the header measures itself into --wekan-header-height and the sidebar starts at that, kept current by a ResizeObserver — the buttons re-wrapping does not fire a window resize.

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/3c5c0a16a">A subscription with no board id took the server down; it is answered, not fatal</a>. Thanks to xet7.</summary>

publishComposite('board') starts with check(boardId, String). A subscription arrives with a null board id, check() throws, and a throw inside an ASYNC publisher escapes as an unhandled promise rejection — which this app treats as fatal: SyncedCron: Fatal error encountered (unhandledRejection) and Exited with code: 1. One bad subscription stopped the server for everyone, and subscription arguments come from the client, so any client could send it; the app was sending it itself, from a popup that read Session.get('currentBoard') on a page that has no current board. The publisher TESTS its arguments now instead of checking them — a subscription that names no board publishes nothing and readies — and the same guard is on boardCardsWindow and boardCardsLoadingMode. The client no longer sends a subscription for a board id it does not have. Each guarded publisher still marks its arguments with check(x, Match.Any) (the follow-up that adds it), because this app runs with audit-argument-checks and Match.test alone is not checking — without that, every subscription failed with "Did not check() all arguments". tests/publicationArgumentGuard.test.cjs replays the guard and fails on an unguarded board subscription.

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/e2c6f04a2">The board bar's icons fit two rows, and a desktop-mode board is a desktop board</a>. Thanks to xet7.</summary>

The board bar's icons still took four rows - title, seven, three, hamburger - because the rule that makes each button a flex item of the bar was in boardHeader.css, and the display: flex for those groups in header.css has the same specificity and loads later; it is written in the file that wins now. And lists still stacked in desktop mode: boardBody.css lays the whole canvas out for a phone by WIDTH — display: block on the swimlane, overflow-x: hidden on the wrapper — which makes every list a full-width row and drops the add-list form under the last list. That is mobile mode's layout now; desktop mode gets a swimlane that is a flex ROW scrolling sideways, so the lists run left to right and the add-list form is the last item of the row — the right in LTR and the left in RTL, by writing direction rather than a hard-coded side.

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/75b562327">Mobile mode is the phone layout; desktop mode is the desktop layout, on a phone too</a>. Thanks to xet7.</summary>

Five things, one theme: what belongs to the MODE the user chose and what belongs to the width of the window. The board bar's buttons still took three rows in desktop mode — the metrics were written in boardHeader.css, and Meteor loads components/boards/ before components/main/, so header.css's margin: 0 6px won at equal specificity; they are written in the file that wins now, and eleven icons take two rows in both modes. The sidebar hamburger is the last button at the right of the last row instead of pinned beside the title. The avatar was cut in half in mobile mode only: the drag-handle toggle in the top bar is also a .board-header-btn, so mobile mode gave it 42px of padding for one icon — the width the avatar needed. Lists stacked one per row in desktop mode, because that layout is chosen by width; it is body.mobile-mode now. And the minicard's full-height thumb handle, chosen by pointer: coarse, is mobile mode's too — desktop mode keeps the compact handle in the corner under the menu button.

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/bb882cf42">The board bar's icons fit two rows on a phone</a>. Thanks to xet7.</summary>

Eleven icons took three rows under the title: each button was ~20px of icon inside 10px of icon margin inside 6px of button margin — 52px of a 375px bar for 20px of icon — so five fitted a row. A 44px touch target with 2px between buttons is 48px each, so seven fit a row and the icons take two, the first of them sharing the title's row. 44px is the minimum comfortable touch target, so this is as tight as it goes: one row would need 34px each, smaller than a fingertip. tests/narrowWindowLayout.test.cjs does the arithmetic and fails if the metrics stop fitting eleven icons in two rows.

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/88d776e21">The avatar lines up with the bell, and "My Boards" is back at the left</a>. Thanks to xet7.</summary>

The avatar sat above the bell and against the right edge: its wrapper carries top: -5px from the desktop bar, and #header-user-bar carries 10px of padding on each side — 20px of a 375px bar spent on nothing. Neither applies on a phone now, and the chips behind the mode-toggle icons are trimmed to match. "My Boards" had moved to the middle of the second header bar, which was a scoping mistake: the phone rule that makes the All Boards page a flex column was written for a bare .wrapper, and header.jade puts wrapper on #header-main-bar on every page that is not a board — so the bar became a flex column and centred its items. It is scoped to #content .wrapper now.

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/5741ec7c2">The board bar reads left to right, and the header says which mode you are in</a>. Thanks to xet7.</summary>

Four things on a phone, all the same in mobile mode and desktop mode. The board's buttons started on a row of their own under the title, because every group is one flex item and moves as a block; on a phone the groups are display: contents, so each button is a flex item of the bar and they start right of the title. The sidebar hamburger sat on a row below them although two rules were meant to pin it to the top right corner — they were scoped to body.board-view, a class written in 67 CSS rules and set by NO code, so they had never applied; the width-based copy asks :has(.board-header-sidebar-toggle) instead. The mobile/desktop toggle did not say which mode was on: #000 against #666 at 14px is no difference, so the current side is a filled chip in the active theme with a white glyph and the other is faded. And the avatar sat higher than the bell — a bottom margin lifts an item by half of it in a centred row — while its initials sat low in the circle, at y="11" of a 15-unit viewBox with a font-size taller than the box.

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/0af0912a6">All Boards on a phone: the board list scrolls to its last board</a>. Thanks to mimZD and xet7.</summary>

Reported against 10.10, again against 10.37, and again against 10.38 after two fixes that made the numbers more accurate instead of removing them. An inner scroller only works when every ancestor between it and the viewport has a definite height, and this chain was built out of viewport arithmetic: the wrapper was 100dvh although it begins below the two header bars, so its bottom sat about two bars below the screen; the layout between had no height at all, so height: 100% under it resolved to auto; and the list was `calc(100dvh

  • 120px)— a guess at everything above it, which on a phone is ~226px, so the list box reached ~100px below the screen and its last rows were under the fold where no gesture could bring them. There is no viewport arithmetic belowbodynow: the page is 100dvh and everything under it is a flex chain, so the list ends exactly where the screen does, whatever the bars above it are. Both modes use the same mechanism.tests/boardListScrollChain.test.cjs` fails if "viewport minus a guess" comes back. Not verified on a device: there is no phone here, and Playwright's fixed viewport has no browser toolbar.
</details>

and has the following developer-facing changes:

<details> <summary><a href="https://github.com/wekan/wekan/commit/a84cfa6be">build.sh and build.bat always build WeKan before running the tests</a>. Thanks to xet7.</summary>

Both built the bundle only when .build/bundle was MISSING — exactly the case where a bundle exists and is stale. The :3000 test server runs that precompiled bundle, so Node E2E and every Playwright browser were testing whatever was built last time, and the run passed or failed on code that is no longer in the working tree. Both delete .build and build every time now, and stop with an error if the bundle is missing afterwards. build.bat was also two menu entries behind build.sh — the dev server's custom port + ROOT_URL host, and "Install Playwright browsers" — and both are added, renumbered and dispatched. tests/buildScriptParity.test.cjs fails if either script stops rebuilding, if a menu entry has no counterpart, if a Docker compose file one can start and the other cannot, or if a .bat menu prints a number it does not dispatch.

</details>

and fills the new strings in every language:

<details> <summary><a href="https://github.com/wekan/wekan/commit/b1e4c3b18">"Open many cards at once" is translated into every language</a>. Thanks to xet7.</summary>

The two strings the "Open many cards at once" setting added were the English source in every language, which is what a pull leaves behind for a string that is untranslated everywhere. They are translated directly — written here from each language's own existing translations, with no external service — using the word for "card" that each file already uses, so they read like the rest of the file. 142 languages; English and its eleven en-* variants keep the source by design. Only placeholders were touched, so no human translation could be overwritten, and filled strings stay local: they are never pushed to Transifex.

</details>

Thanks to above GitHub users for their contributions and translators for their translations.

v10.39 2026-07-26 WeKan ® release

This release fixes the following bugs:

<details> <summary><a href="https://github.com/wekan/wekan/commit/f6c83495b">The notification bell and the avatar sit the same way in both modes</a>. Thanks to xet7.</summary>

They were placed by whatever margin happened to win: one auto margin on the zoom pill pushed the pill, the bell and the avatar to the end of the row as a packed group, so the bell sat against the pill with the empty space beyond it, and the avatar's distance from the edge was whatever was left — different in mobile mode and in desktop mode on the same phone. Below 800px the free space is shared around the bell instead: an auto margin on each side puts it midway between the zoom pill and the avatar, and the avatar keeps a fixed 12px gap to the right edge. Both .iphone-device variants are named in the selector list, since that fallback sets these margins with !important and two classes more, and the placement would otherwise apply everywhere except the phone it was reported from.

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/202add9da">All Boards on a phone: one drag-handle size, and a workspace name you can read</a>. Thanks to xet7.</summary>

Two things differed between mobile mode and desktop mode on the same phone. The board tile's drag handle was a 26px circle in one and a 40px circle in the other, and on a ~97px tile the big one covered a good part of the tile it sits on; both are the smaller one now, still well over the ~24px a finger needs. And a workspace had no readable name in EITHER mode: the row's rule said the name "must give way to the fixed items around it", and it gave way completely — the drag handle, folder icon, menu button and count chip come to ~130px at their desktop paddings, the menu column on a 375px phone is ~145px, and the name was laid out in the 15px left over. Those items are trimmed to ~90px and the name has a 3.5em floor it may not shrink below, with an ellipsis when it is long. tests/allBoardsPhoneRow.test.cjs reads the sizes out of the cascade.

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/9f8287749">Admin Panel: /information and /translation showed the page you came from</a>. Thanks to xet7.</summary>

Both old URLs are panes of Admin Panel / Settings now, and both redirected with FlowRouter.go('setting') called from INSIDE triggersEnter. A trigger runs while its own route is still entering, and a go() from there is swallowed - so nothing was rendered at all and whatever page the browser was showing simply stayed. Playwright caught it on every browser: /information showed All Boards. A trigger now redirects with the redirect it is handed, and each URL asks for the pane it used to be a page of, so the bookmark lands where it pointed. tests/adminOldUrlRedirect.test.cjs pins the redirect form and the pane.

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/dea262ef6">All Boards on a phone fits the screen, and the board titles get their width back</a>. Thanks to xet7.</summary>

The board titles were laid out two characters per line. Space for the drag handle - one absolutely-positioned circle - was reserved three times on the way down: on the list item, on the tile and on the text container, which is more than a ~97px phone tile has. It is reserved once now, and not at all when drag handles are off and no handle is rendered. The page could also be dragged sideways, with the avatar past the right edge: the quick-access bar is nowrap with overflow: visible and every item flex-shrink: 0, so a row wider than the screen spilled - and visible overflow is scrollable overflow. The zoom pill, by far the widest item, gives way instead, and html { overflow-x: hidden } on a phone is the guarantee. tests/mobileAllBoardsFit.test.cjs pins both.

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/08f4c80c7">The zoom number is inside its white pill again, and big enough to read</a>. Thanks to xet7.</summary>

On a phone the pill was empty and "100" sat to the right of it in tiny type, half under the notification bell. The pill was allowed to shrink below its own contents, and a flex item that shrinks past its content does not clip it - the white background ends where the width says and the text carries on outside it. The pill is sized by what is in it now, and what is in it is made small instead. The tiny type was font-size: 0.7em of a 12px bar - about 8px, the smallest text on the page - now a plain 14px, which is also why the pill stays narrow. The base rule's 24px height cap, shorter than that text, is lifted so the number is centred. tests/mobileAllBoardsFit.test.cjs pins all of it.

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/eb56ea589">A narrow window gets the narrow-window layout, not only an explicit mobile mode</a>. Thanks to xet7.</summary>

The phone/desktop toggle writes an explicit choice, and Utils.isMiniScreen() returns it as-is, so a phone whose user picked DESKTOP mode is not a mini screen and its body carries no .mobile-mode — while the viewport is still 375px wide. Three fixes written for one of those two therefore did nothing there. "Create board" opened 160px in with its right half off the screen, because the geometry laid it out as a floating box anchored to the button while the CSS made it the full width; the board bar's hamburger was pushed to a third row of its own; and the top bar was still wider than the screen, so the avatar was cut off. The popup is a sheet pinned to the corner at any viewport that narrow, the hamburger leaves the flow by width as well as by mode, and the drag-handle toggle, the mode toggle and the logo give back the ~60px the avatar needed. tests/narrowWindowLayout.test.cjs pins all three.

</details>

and improves the changelog and the documentation:

<details> <summary><a href="https://github.com/wekan/wekan/commit/5f9c32521">The changelog shows a short description, and hides the long one behind it</a>. Thanks to xet7.</summary>

Every entry is a <details> now: the <summary> is a short description of what was done and IS the link to the commit — the hash is in the href, never on the page — and clicking it reveals the long description, wrapped at 80 columns. 977 entries across every release were converted, the ones the old prose format had left malformed were repaired rather than carried over, and no URL or heading was lost. The top of the file became # Platforms (with its collapsible Version list) and # TODO Later, whose blocks carry no Thanks to because nothing there is done yet. The rules are written down in CLAUDE.md, and tests/changelogFormat.test.cjs checks the whole file against them.

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/364837d95">CLAUDE.md records how a Hall of Fame entry is written</a>. Thanks to xet7.</summary>

The wekan.fi Hall of Fame page grew a row of eight cells, one thing in each — CVE, Icon beside it, Vulnerability name, Date, Responsible Security Disclosure by, Stars, Process, Vulnerabilities — with the Process and Vulnerabilities cells collapsed behind their own summary, the stars of a row on one line and the icon beside its red drop on one, the reporter's nickname as the link to their GitHub — checked to exist before it is linked — and no role note after a name. All of it is stated in CLAUDE.md so the next security entry is written to fit rather than reconstructed from the file.

</details>

Thanks to above GitHub users for their contributions and translators for their translations.

v10.38 2026-07-26 WeKan ® release

This release fixes the following CRITICAL SECURITY ISSUES:

<details> <summary><a href="https://github.com/wekan/wekan/commit/f1c89548e">ZipBleed: arbitrary file write when restoring a backup archive (zip-slip)</a>. Thanks to xet7.</summary>

ZipBleed: arbitrary file write when restoring a backup archive (zip-slip) (CWE-22 Improper Limitation of a Pathname to a Restricted Directory). A zip entry carries its own path, chosen by whoever built the archive, and path.join() RESOLVES .. segments instead of rejecting them. The restore in server/methods/backup.js checked only that an entry's first path segment was attachments or avatars, then joined the rest onto the target directory. An entry named 2026-07-25_12-00-00/attachments/../../../../etc/cron.d/wekan therefore passed that check — its first segment really is attachments — and joined its way clean out of the files directory, and the entry's contents were streamed to whatever path came out. A crafted backup.zip could drop or overwrite a file anywhere the WeKan process could write. Restoring a backup is exactly the moment nobody inspects the file they were handed, and restoreBackup is behind requireAdmin(), so the archive arrives the normal way: offered to an admin as a backup to restore. Fixed by resolving each entry to an absolute path and requiring it to sit under the directory it belongs in — compared against the base plus a separator, so a sibling directory whose name merely starts with the same letters (/data/files/attachments-evil) is not accepted either. The data half of an archive names the Mongo collection to restore into, so that is now constrained to a plain name, which also keeps a restore out of the database's internal system.* collections. A refused entry is skipped and reported rather than thrown on, so one hostile entry cannot abort a genuine restore. Found while reviewing the open dependency pull requests — it is WeKan's own code, not any dependency. tests/zipbleed.test.cjs asserts the exact traversal that motivated the fix is refused, that a plain entry still restores where it belongs, and that the restore really calls both guards

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/40de1799a">GHSA-3gcg-g6rf-w2rx: an invalid authToken on a board export endpoint crashed the server</a>. Thanks to laijunyue for the coordinated disclosure, and xet7.</summary>

GHSA-3gcg-g6rf-w2rx: an invalid authToken on a board export endpoint crashed the server (CWE-476 NULL Pointer Dereference; remote denial of service). The board export REST endpoints look a user up by the login token in ?authToken=. A token that matches nothing makes that lookup answer undefined, and the next line dereferenced it — user._id.toString() — throwing a TypeError out of an async route handler with no try/catch. That escapes as an unhandled promise rejection, which this app turns into a full process crash, so one crafted GET against a private board id took the server down for every user. Reachable by anyone who can obtain a private board id, which on an open-registration instance means anyone at all. It was an incomplete fix: models/exportPDF.js and models/exportExcelCard.js already had the if (!user) guard; three handlers in models/export.js and one in models/exportExcel.js were missed. Fixed on both levels: every token lookup in the export models is now followed by that guard — 401 "Invalid token" and return — including the two handlers that did not crash because they hand the user to canExport() instead of dereferencing it; and every export route body is wrapped in safeRoute() (server/apiMiddleware.js), which awaits the handler, answers 500 once and logs the request that failed, so a throw from any other cause is one broken request instead of an outage. tests/exportTokenGuard.test.cjs finds every token lookup in the export models and requires a guard on each, rather than checking four places by hand

</details>

and adds the following new features:

<details> <summary><a href="https://github.com/wekan/wekan/commit/36b899b9d">Multitenancy: one WeKan server for many domains, with Organizations as the tenants</a>. Thanks to xet7.</summary>

Multitenancy: one WeKan server for many domains, with Organizations as the tenants. Hosting n customers meant running n WeKan servers — n Node.js processes, n ROOT_URLs, n upgrades, n backups. One server can now serve them all: an Organization claims its own hostnames (Admin Panel / People / Organizations → Edit), carries its own branding — product name, logos, help link, legal notice and theme colour — and gets its own Organization admins, appointed from the row's ⋯ menu. An Organization admin administers their own Organization's people and backups and nothing else: they can never grant the site-wide Admin flag, never manage a site admin, and never see another Organization. Per-tenant backup and restore is one new control in Admin Panel / Attachments / Backup — Scope — and an Organization's archive holds its boards and their attachments but no accounts and no instance settings, because those are shared; restoring one only ever writes to boards that Organization owns. The whole thing is OFF unless the server is started with MULTITENANCY=true (plus MULTITENANCY_TRUST_PROXY_HOST=true when a trusted proxy sets X-Forwarded-Host), so an instance that has never heard of tenants answers exactly as before. The design — including what this deliberately does NOT isolate, which is why one server per customer stays the recommendation for customers who must not share a process — is docs/Design/Multitenancy/Multitenancy.md, option D, and four new test suites (tenants, tenantAdmin, tenantBackup, tenantWiring) pin the separation: forged Host headers, cross-tenant queries, privilege escalation and cross-tenant restores

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/36b899b9d">Admin Panel / Settings / Visibility has a Change color section, above Logo: the site theme, chosen…</a> Thanks to xet7.</summary>

Admin Panel / Settings / Visibility has a Change color section, above Logo: the site theme, chosen with the same picker as Board Settings / Change Color and Member Settings / Change color — one shared template for all three, the way one shared table page serves every table. The order of themes is now WeKan's default theme, then this site theme, then a user's own override; a board's own colour still owns the board page. An Organization's admin sets their Organization's colour from the same section, and the site admin's is the one every Organization without one of its own inherits, which the line under the title says. The design is the new docs/Features/Page/Theme.md

</details>

and reorganises the Admin Panel:

<details> <summary><a href="https://github.com/wekan/wekan/commit/fa0da9178">Admin Panel / Settings is reorganised so every setting sits with the thing it is about. Layout is…</a> Thanks to xet7.</summary>

Admin Panel / Settings is reorganised so every setting sits with the thing it is about. Layout is now PWA and holds only the PWA settings — its custom head tags, web manifest and assetlinks.json; the branding it used to carry (product name, the login and top-left-corner logos, the text below the logo) moved to Visibility, along with the Wait Spinner, Support, the custom help link and the legal notice URL. The Accounts pane is gone: allow e-mail change went to E-mail, username change and self delete of an account to Login. The sign-in pane is called Login instead of Registration. "Don't show the board activities" became one global setting rather than a write over every board document, and the two All Boards hide settings joined it in Visibility. Translation became a Settings pane below Accessibility instead of a tab of its own

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/e26706799">Every Admin Panel page renders ONE shared left menu, built from a plain item list instead of markup…</a> Thanks to xet7.</summary>

Every Admin Panel page renders ONE shared left menu, built from a plain item list instead of markup that had been retyped 44 times across seven templates — and, in two of those pages, one click handler per entry instead of one per menu. The selected entry is filled with the theme colour with a white label, the same treatment the selected tab gets in the bar above it, so the menu says at a glance which pane is showing. No Admin Panel page has a title bar repeating the section name any more: the tab bar names the section and the menu highlights the pane, so a third line only cost vertical space. The design, and the list of pages that use it, is in docs/Features/Page/Left-Menu.md

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/ccc95bffb">Admin Panel / People's four table panes — Domains, Organizations, Teams and People — render through…</a> Thanks to xet7.</summary>

Admin Panel / People's four table panes — Domains, Organizations, Teams and People — render through the shared table page, so all four page, search and lay out the same way; their rows stay interactive through a row slot, and a column header may carry controls (the "New" link, the select-all pairs) through a header slot. That pane's search box, filter dropdown, action buttons and total became features of the shared controls row, so any table page can have them, and Teams gained a working "previous page" it never had. Locked users, Roles and Shared templates are recorded in the design as what they are — forms and checkbox lists, not tables

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/b72a4996d">The Admin Panel Features tab is removed</a>. Thanks to xet7.</summary>

The Admin Panel Features tab is removed. Its last three panes — Performance, Security and Notifications — moved to Admin Panel / Problems, leaving a tab that opened an empty page. The route, the tab, its active-tab helper, the imports and both files are gone; the three pane templates and their handlers moved to the page that renders them, since deleting them would have left Problems rendering templates that no longer exist — which is not a build error, but a throw when the pane is opened. A new test requires every template include to name a template that exists, the mirror of the existing check that every template handler targets one

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/4df87fd6b">Admin Panel / Problems: the Security report is renamed Security Report and moved above Broken…</a> Thanks to xet7.</summary>

Admin Panel / Problems: the Security report is renamed Security Report and moved above Broken Cards, with Impersonation Report directly between the two; and the Performance, Security and Notifications panes moved here from Admin Panel / Features, below Summary. The rename is what makes that safe — the pane arriving from Features is also called Security, and the two now sit in one menu. Only the English source string changed, so every other language keeps its existing translation until that string is retranslated. The panes brought their helpers and handlers with them, which is the half that fails silently: all twenty-four were registered on the Features page template, and Blaze resolves a helper, and delivers an event, against the template the element is in — left there, each pane would have rendered on Problems with every checkbox reading as unchecked and no click doing anything. Admin Panel / Features is left with no panes; its page and route stay so nothing linking there breaks

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/47b03ef89">Admin Panel: the Login and E-mail panes moved from Settings to People, above Organizations — both…</a> Thanks to xet7.</summary>

Admin Panel: the Login and E-mail panes moved from Settings to People, above Organizations — both are about the people who can sign in and how they are reached, which is what that page is for. The move was only half markup: every handler the two panes need was registered on the Settings template, and Blaze delivers an event to the handlers of the template the element is in, so left there each pane would have rendered on People and then quietly done nothing — no toggle sticking, no Save saving, nothing in the console. All ten moved onto the pane templates themselves, where they work wherever the pane is rendered. Settings now opens on Visibility, the first entry it has left; the pane ids and translation keys are unchanged, so nothing lost its translations

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/d23219407">Every Admin Panel pane opens with the same heading, and the heading is the open left-menu entry's…</a> Thanks to xet7.</summary>

Every Admin Panel pane opens with the same heading, and the heading is the open left-menu entry's own label. Before this only the paginated table pages had a title at all: Domains said "Domains" while Login, Announcement, Accessibility, PWA and Version opened with no heading. Deriving it from the menu is what keeps them identical — a pane cannot end up with a title of a different size, in different words, or with none at all, and renaming a menu entry renames its pane title with it. One class sizes it, and the shared table page's own title carries that class too, so a table pane and a form pane look the same. Described in docs/Features/Page/Left-Menu.md

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/d23219407">Admin Panel / Settings / Translation renders through the shared table page…</a> Thanks to xet7.</summary>

Admin Panel / Settings / Translation renders through the shared table page (docs/Features/Page/Table.md) instead of a hand-written table: the layout, the search box, the themed pager and the total are the shared ones, and the pane keeps only its four columns, its interactive row and its "New" link. It also pages ONE page of 25 rows server-side with limit/skip and a count method, where it used to grow a window by infinite scroll, and the publication publishes the field it is sorted by so the client's order matches the server's page

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/d23219407">Version is the FIRST pane of Admin Panel / Settings and the one that opens with the page, so what…</a> Thanks to xet7.</summary>

Version is the FIRST pane of Admin Panel / Settings and the one that opens with the page, so what an admin sees when opening the Admin Panel is the version, database and system information they usually came for. Its own page, its one-entry left menu and its tab in the Admin Panel bar are gone — a page whose menu had a single entry was a page in name only — and the /information URL redirects to Settings, the same move Translation made

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/671262ddb">Admin Panel / Settings / Visibility is four named groups instead of one long list: All Boards…</a> Thanks to xet7.</summary>

Admin Panel / Settings / Visibility is four named groups instead of one long list: All Boards (boards visibility, board activities, card counter list, board member list, wait spinner), then URL (Support, custom help link, custom legal notice, custom URL schemes), then Product name, then Logo (hide logo and the login / top-left-corner logo fields) — each group after the first separated by a horizontal rule. The settings and their ids are untouched, so the one Save button at the bottom still writes exactly what it did. A group title is smaller than the pane title above it, so the page reads as one heading with groups under it rather than as several pages stacked

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/f0ea1601b">"Add board members only from the same Organization or Team" moves out of Admin Panel / People /…</a> Thanks to xet7.</summary>

"Add board members only from the same Organization or Team" moves out of Admin Panel / People / Login — which is where neither of the two things it restricts lives — and becomes two checkboxes, each in the pane it is about: "Add board members only from the same Organization" in Admin Panel / People / Organizations and "Add board members only from the same Team" in / Teams. A user may be added to a board when they share an enabled kind with whoever adds them; with both ticked that is "an Organization or a Team", exactly the rule the single setting had. An install with the old setting is migrated to both on first start, so no instance's restriction changes by upgrading — and ticking only one is the narrower choice that was not expressible before

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/40c1c1e71">Admin Panel / People / Login is one "Login: Allow" group of checkboxes, each ticked when the thing…</a> Thanks to xet7.</summary>

Admin Panel / People / Login is one "Login: Allow" group of checkboxes, each ticked when the thing is allowed: Forgot password, Self-Registration, Username Change, Self delete user account, Display Authentication Method. The pane used to mix "Disable X" checkboxes, where ticked meant OFF, with "Allow X: Yes/No" radios — half the rows meant the opposite of the other half, and every one repeated the word Allow or Disable. The two settings stored as "disable" flags are only inverted for display; the stored fields are untouched. The three former radios save on click like the checkboxes they now are, so the Save at the bottom keeps only the default authentication method and the OIDC button text. A dead handler that sat later in the same event map — where a duplicate key silently overrides the real one — would have swallowed every click on the Display Authentication Method box

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/f15b37e4e">That pane's "Email domain name" is renamed to say what it does: "Email domain allowed to invite…</a> Thanks to xet7.</summary>

That pane's "Email domain name" is renamed to say what it does: "Email domain allowed to invite people, when self-registration is disabled". It limits nothing about signing in — a non-admin whose address ends with this domain may send board invitations, and only while self-registration is disabled

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/2ef34924d">Admin Panel / Settings / Visibility, All Boards group: every row repeated the group title and the…</a> Thanks to xet7.</summary>

Admin Panel / Settings / Visibility, All Boards group: every row repeated the group title and the word "Hide" — "Hide card counter list on All Boards" under a title reading "All Boards". The title is "All Boards: Hide" and each row says only what is hidden: Public boards, Board activities, Card counter list, Board member list. The three i18n keys used only by this pane were renamed with their values, so no language shows the old sentence under the new title; they fall back to English until retranslated. all-boards (the menu label everywhere else) and tableVisibilityMode-allowPrivateOnly (the message shown on a board you may not open) keep their own strings, so the group title and the row label are new keys

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/9255a6533">Each section of Admin Panel / Settings / Visibility has its own Save, directly above the rule that…</a> Thanks to xet7.</summary>

Each section of Admin Panel / Settings / Visibility has its own Save, directly above the rule that closes it, and it writes only that section's fields. One Save for the whole pane meant pressing it in one group also wrote whatever was half-typed in another. Three buttons are folded into their section's Save and gone with their handlers: the pane-wide one, the single-setting Save under "don't show the board activities", and the one inside the Support block. The Support page's Enabled / Public checkboxes still save on click — they are toggles, not fields

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/bbb1b3ff0">"Product name" appears once in that pane, not twice: the group holds one field, so its group title…</a> Thanks to xet7.</summary>

"Product name" appears once in that pane, not twice: the group holds one field, so its group title is that field's label — at the group title's size, with the translation the field already has in every language

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/fc0fe3177">Admin Panel / Problems' left menu is two named groups: Summary, then a rule and a "Settings" title…</a> Thanks to xet7.</summary>

Admin Panel / Problems' left menu is two named groups: Summary, then a rule and a "Settings" title over the two panes that came from the removed Features tab, then a rule and a "Reports" title over the reports. A title is a new kind of menu item with no id, no icon and no handler class — there is nothing to click, and it can never become the active row — and both use an i18n key the app already has, so no language has to translate anything new. Performance moves down to sit with the Speed, Tests and CPU usage streams it is about, below Impersonation Report

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/d23219407">Admin Panel / People: Domains moves up beside E-mail, the settings it is about, instead of sitting…</a> Thanks to xet7.</summary>

Admin Panel / People: Domains moves up beside E-mail, the settings it is about, instead of sitting at the end after the Roles and Shared templates checkbox lists

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/fc0fe3177">Admin Panel / Problems / Broken cards has the same controls as the Files Report beside it: a search…</a> Thanks to xet7.</summary>

Admin Panel / Problems / Broken cards has the same controls as the Files Report beside it: a search box, the total, "page X / N" and the shared themed pager. It was the one entry in that menu with a different set, because it ran on the global-search machinery instead of the shared table page. It is a column spec like the other reports now, behind an admin-only publication that takes a search term plus limit/skip and a count method for the total; what "broken" means — no board, swimlane or list, or a type that is not a card type — is one selector shared with the standalone /broken-cards page, which is unchanged

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/9a0db4343">No Admin Panel pane repeats the title above it</a>. Thanks to xet7.</summary>

No Admin Panel pane repeats the title above it. Attachments / Backup showed "Backup" twice — the pane heading comes from the open left-menu entry now, and every Attachments pane still printed its own name under it. Those ten headings are gone, and with them the same repeat on Roles, Shared templates and Broken cards. A heading that says something the menu label does not — Limits' "Attachment And API File Size Limits", Locked users' "Brute Force Protection Settings" — is not a repeat and stays

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/a9680fa3d">Admin Panel / Attachments opens on Backup, which is also the first entry of its menu — the first…</a> Thanks to xet7.</summary>

Admin Panel / Attachments opens on Backup, which is also the first entry of its menu — the first row of a menu and the pane that opens are the same one now, on Attachments as on Settings. Landing there asks once whether a backup is already running, so an in-progress backup shows its status instead of an idle-looking pane

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/eba25459a">Admin Panel / Attachments no longer has a Sandstorm pane</a>. Thanks to xet7.</summary>

Admin Panel / Attachments no longer has a Sandstorm pane. It only had content inside a Sandstorm grain — the one-time MongoDB 3 → FerretDB migration status and a button that DELETED the raw MongoDB 3 database files to reclaim grain disk. Compacting the MongoDB database frees that space as well, so deleting raw database files is not something WeKan needs to offer — and anyone who does want those files gone can download the Sandstorm grain, delete the raw MongoDB database files from it, and upload the grain back to Sandstorm. The menu entry, the pane, its helpers, its state and its click handler are commented out rather than deleted, so nothing of it runs and the grain migration report can be brought back if it is ever wanted. Everything else on the page is untouched: the storages and their statistics, moving attachments between storages, the MongoDB ↔ FerretDB database migration and Backup. The grain's own migration, which runs in sandstorm-src/start.js before the app boots, is unchanged

</details>

and updates the following dependencies:

  • @aws-sdk/client-s3 3.1090.0 → 3.1095.0 — the S3 / MinIO attachment storage client (#6530, merge commit). Thanks to dependabot.
  • unzipper 0.12.3 → 0.12.5 — the zip reader behind board import and backup restore (#6529, merge commit). Thanks to dependabot.
  • bson 7.3.0 → 7.3.1 — the BSON codec used with MongoDB and FerretDB (#6527, merge commit). Thanks to dependabot.
<details> <summary><a href="https://github.com/wekan/wekan/commit/f7fb32774">@playwright/test 1.61.1 → 1.62.0</a>. Thanks to dependabot.</summary>

@playwright/test 1.61.1 → 1.62.0 in tests/playwright — the browser test runner, a development dependency that is not part of the shipped bundle (#6528, merge commit )

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/d4684448c">docker/login-action 4.4.0 → 4.5.1</a>. Thanks to dependabot.</summary>

docker/login-action 4.4.0 → 4.5.1 — the GitHub Actions step that signs in to the container registries when a release image is published (#6526, merge commit )

</details>

and fixes the following bugs:

<details> <summary><a href="https://github.com/wekan/wekan/commit/2973240a7">Clicking a card closes the one that was open, and keeping many open is a per-user setting</a>. Thanks to mimZD and xet7.</summary>

Clicking a card closes the one that was open, and keeping many open is a per-user setting. Opening a card APPENDED it to the open-cards list, so the card that was already open stayed open behind the new one — visible the moment the new one was dragged. Clicking another card means "show me that card", not "show me both". Both ways of opening a card — the minicard and its title — go through one helper now, so neither path can drift from the other. Keeping several cards open at the same time is still possible, as the per-user setting it should always have been: Member Settings / "Open many cards at once", off by default, stored on the user profile and toggled by a server method like the settings beside it. A reader who is not logged in gets the default rather than an error

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/89920762a">All Boards on a phone sizes its scrollers by the viewport that is actually there</a>. Thanks to mimZD and xet7.</summary>

All Boards on a phone sizes its scrollers by the viewport that is actually there. The page sized them as 100vh minus a bit — the wrapper, the left menu, the board list, and the mobile body and content area. On a phone 100vh is deliberately the LARGEST the viewport can be: the height with the browser's toolbars hidden. With the toolbar showing, every one of those boxes reaches under it — and the wrapper is overflow: hidden, so what is underneath cannot be scrolled to at all, which looks exactly like "the board list does not scroll". Each rule now states the vh value first, as the fallback, and the same value in dvh — the viewport as it is at that moment — immediately after. The two scrollers also declare overscroll-behavior: contain and touch-action: pan-y, so a vertical swipe is given to them rather than to the page behind. Not verifiable here: there is no phone in this environment and no browser toolbar in Playwright, which is why the earlier layout fix could not have covered it

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/d72675906">Choosing a theme now recolours the whole UI, not just the header</a>. Thanks to xet7.</summary>

Choosing a theme now recolours the whole UI, not just the header. Every named theme publishes its own accent colour, so everything that reads it follows along: the Admin Panel's pane buttons, every Save button, the table-page controls and the popup header — all of which stayed on the stock blue whatever theme was chosen, because the accent was only ever set when a user picked a CUSTOM colour for a flat/clear theme. The Admin Panel's selected left-menu row goes further: it is painted by the theme's own header rule, so it matches the second header bar exactly — including a colour slide, which clearblue has and a single accent value cannot express. The top bars themselves used to spell the order of themes out in the template, which is why the new site theme coloured the buttons and left the bars alone; one helper answers for all of them now, in one order

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/19a5ea0b7">Admin Panel / People / People / Edit User — and the Organization and Team edit popups — are fully…</a> Thanks to xet7.</summary>

Admin Panel / People / People / Edit User — and the Organization and Team edit popups — are fully visible again, and fill the width they are given. They were positioned from the table row that opened them, so opened from a row in a wide table their right-hand side, and the Save button under it, hung off the edge of the window; they are centred on the window now, and no popup can be wider than the window at all. Their fields lay out in as many columns as there is room for — three on a wide desktop, one on a phone — so a dozen-field form no longer has to be scrolled to reach Save

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/19a5ea0b7">Admin Panel / Settings / Visibility / Wait Spinner shows the spinner it names, spinning, beside the…</a> Thanks to xet7.</summary>

Admin Panel / Settings / Visibility / Wait Spinner shows the spinner it names, spinning, beside the dropdown — and below it when the pane is too narrow for both. Choosing one no longer means saving it to find out what it looks like

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/19a5ea0b7">Admin Panel / Settings / Translation puts its New column on the left, where every other Admin Panel…</a> Thanks to xet7.</summary>

Admin Panel / Settings / Translation puts its New column on the left, where every other Admin Panel table page has it — at the far right it read as belonging to the last column rather than to the table

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/d23219407">Admin Panel / People / People showed nothing at all — no table, no search box, no pager…</a> Thanks to xet7.</summary>

Admin Panel / People / People showed nothing at all — no table, no search box, no pager. buildFilters and buildActions were used to declare that pane's filter dropdown and its two action buttons, but were never imported. That is not a build error: it is a ReferenceError thrown inside a helper while rendering, which Blaze answers by rendering nothing. Organizations, Teams and Domains use neither function, which is what made it look like a People-only problem

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/d23219407">The right-hand side of an Admin Panel table is no longer off screen</a>. Thanks to xet7.</summary>

The right-hand side of an Admin Panel table is no longer off screen. Reported on Domains, and just as true of Admin Panel / Version, where the value column was cut off at the window edge: two admin stylesheets forced min-width: 1200px !important; width: max-content !important with nowrap cells on a bare table selector — one of them with no page in the selector at all, so it reached every table in WeKan, and !important beat the shared table page's own width:100% + table-layout:fixed. The same rules made a SHORT table shrink to its content instead of filling the panel. No admin table is forced wide any more: it fits its panel and its cells wrap, at every width, and the shared table page's width and wrapping are !important so a stylesheet in another folder cannot silently override them

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/d23219407">An empty Admin Panel table now still shows its header, so Organizations, Teams, People and…</a> Thanks to xet7.</summary>

An empty Admin Panel table now still shows its header, so Organizations, Teams, People and Translation can create their FIRST row: the "New" link is a column header, and the table used to be hidden entirely when there were no rows — leaving a pane with no way to add anything. The "no items" message sits below the table now

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/d23219407">Every control in an Admin Panel table's controls row sits at the same height, and the action…</a> Thanks to xet7.</summary>

Every control in an Admin Panel table's controls row sits at the same height, and the action buttons follow the theme instead of coming out black. "Unlock all users" was lower and shorter than "Teams" beside it: it carried a 20px top margin and a 28px height from the old hand-written page header, and the buttons that kept the global margin-bottom: 14px of forms.css were centred higher than the ones that did not. The action buttons are themed with the pager, in the one stylesheet that owns those colours, with every state spelled out — a bare button otherwise falls through to the forms.css fallback, which is literally black

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/a4990a474">A long Admin Panel left menu scrolls inside its panel instead of spilling out of it</a>. Thanks to xet7.</summary>

A long Admin Panel left menu scrolls inside its panel instead of spilling out of it. Admin Panel / Problems is the longest — fifteen entries since Performance, Security and Notifications joined it — and its last four sat on the page’s grey with no panel behind them. The panel is stretched to the height of the page, so its background, border and rounded corners end there, and the entries simply kept rendering past that edge.

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/f8793bec4">The build stopped with Can't use the built-in 'if' here on the People page: a comment had been left…</a> Thanks to xet7.</summary>

The build stopped with Can't use the built-in 'if' here on the People page: a comment had been left between an if and its else if, which splits the chain and orphans the else. The comment moved above the chain. The more useful half of the fix is the test: the guard that compiles every template was calling only the PARSE step, and this error comes from the code-generation step that runs after it, so the suite was green while the build was red. It now runs both steps, exactly as the build's own loader does, and a negative test feeds the broken chain through both to show that parsing alone does not see it

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/f15b37e4e">Admin Panel / People / E-mail saved neither of the two settings it shows</a>. Thanks to xet7.</summary>

Admin Panel / People / E-mail saved neither of the two settings it shows. Its Save handler starts by reading the SMTP fields, which are commented out of that pane — the read throws on an input that is not there, and the throw was caught and swallowed, so the handler returned before ever writing anything, including the e-mail domain. "Allow Email Change" was worse: the radios showed the stored value and no handler in the app ever wrote them back. The Save button moves out of the Yes/No row to its own line below both settings and writes both, each field only when its input is actually rendered

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/73cd930a4">The Admin Panel left menu, the page-title bars, the People tables and the Translation pane</a>. Thanks to xet7.</summary>

The Admin Panel left menu, the page-title bars, the People tables and the Translation pane. Every Admin Panel page except Problems drew a bar under the top bar repeating the name of the pane you were already looking at; they are gone, and the controls that lived in People’s bar moved into the shared table page’s controls row. Organizations, Teams, People and Translation drew their search box and pager but no table at all: each referenced a helper that belongs to its parent template, and Blaze never searches an enclosing template for a name, so the data came out undefined and a table with no rows draws its chrome and stops. Domains and the other table pages needed scrolling right to see their last columns, because a rule written years ago forces every admin table to a 1200px minimum with non-wrapping cells — the shared table page is excluded from it now. The Translation search button was black, from the black fallback in the shared button style, and follows the theme like every other admin button. The selected left-menu entry is filled with the theme colour and its label and icon go white, matching the selected tab in the bar above it

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/d34d28159">The Admin Panel left menu is back on every page</a>. Thanks to xet7.</summary>

The Admin Panel left menu is back on every page. Settings, People, Features, Attachments, Version and Problems all rendered an empty panel where their menu belongs. The seam between the two halves of the shared menu was wrong: the template iterates items, and every page handed it the bare array the builder returns, so the lookup found nothing and rendered nothing. Nothing failed anywhere — the template was correct, the builder was correct, every test passed, and six pages simply lost their navigation to one mismatched shape. That shape is now stated once and used by all six pages, and the missing guard checks the seam rather than either half: it reads which variable the template iterates and asserts the data context really has an array under that name

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/696c49038">The password field is back on the Sign In and Register pages</a>. Thanks to xet7.</summary>

The password field is back on the Sign In and Register pages. Removing the Accounts pane from Admin Panel / Settings — its three settings having moved to E-mail and Login — left that pane's save handler registered on the template that went with it, Template.accountSettings.events({…}). Registering a handler on a template that no longer exists throws at MODULE LOAD, and because it threw, no module after it in the client bundle ran: client/features/users.js never executed, so the passwordInput template was never registered, useraccounts logged "Warning no template passwordInput found!", and both password fields — Password and Password (again) — rendered as nothing. One dead reference in the Admin Panel took out the login form for everyone. The handler now belongs to the template that renders the Login pane. A new guard, tests/templateHandlersExist.test.cjs, asserts every Template.X.events/helpers/onCreated/onRendered/onDestroyed has a matching template(name="X"), because this class of bug parses cleanly, passes every unit test, and shows up only in a browser console

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/aebede877">A finished MongoDB → FerretDB migration on Snap no longer leaves WeKan on 503. Upgrading from 6.09…</a> Thanks to S0QR2 and xet7.</summary>

A finished MongoDB → FerretDB migration on Snap no longer leaves WeKan on 503. Upgrading from 6.09 to 10.37 migrated successfully and then served 503 until the admin ran snap restart wekan by hand, with WeKan looping "MongoDB not ready yet, retrying in 5 seconds..." against a MongoDB the migration had just shut down for good. mongodb-control ends with exec bash $SNAP/bin/migration-control, so the migration script IS the wekan.mongodb service process — and the switch stopped that service before restarting WeKan, so systemd killed the script at the stop and the restart on the next line never ran. WeKan is now handed over to FerretDB BEFORE MongoDB is stopped, the stop is the last command in the switch, and the traps are disarmed first so being stopped there no longer logs "Interrupted (snap refresh, stop or reboot)" after a migration that in fact succeeded. Because that restart is the last act of a script being killed, it is no longer the only way out: WeKan's MongoDB wait now re-reads the live database setting each round and re-execs onto FerretDB when it has been switched, so a migration finishing mid-wait recovers on its own within five seconds

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/6837198bf">In mobile mode the fixed-size light grey bands around a swimlane are gone: the one between the blue…</a> Thanks to xet7.</summary>

In mobile mode the fixed-size light grey bands around a swimlane are gone: the one between the blue resize bar and the next swimlane’s dark header, which travelled with the bar as the swimlane was resized, and its twin between a swimlane’s header bar and its first list. They were plain bottom margins on the swimlane header and on each list; on the last list that margin is exactly the band between the content and the resize bar, and being a fixed size it moved with the bar rather than growing. Lists are separated by the border under each of them, so nothing is lost. The board stylesheet restates these selectors in several later blocks, and later rules of equal specificity win, so eight duplicates had been putting the bands straight back; the guard test now checks every block rather than the first one. The swimlane header wrapper is also only as tall as its bar, and the minimum height that held a short swimlane open below the height chosen for it is gone — while the height being dragged is left alone, so the resize bar still resizes

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/a5850296a">In mobile mode the board fits the width again</a>. Thanks to xet7.</summary>

In mobile mode the board fits the width again. The lists area was slightly too wide, giving a horizontal scrollbar with a small amount to scroll right and nothing there to see. Two causes: 100vw sizing, which had been fixed in one stylesheet but was still in two more — including the mobile list rows themselves — and which is the viewport width INCLUDING the vertical scrollbar, so each of those was a scrollbar wider than the box it sat in; and box-sizing, which is globally unset, so every rule setting both a full width and padding came out 32px wider than its parent, at four nesting levels. Viewport width is kept where it is correct — a position:fixed overlay such as the mobile card details, a popover, the sidebar or the notifications drawer is sized to the viewport by definition

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/a5850296a">In mobile mode the swimlane title, the list title, the board title and everything in the top bar…</a> Thanks to xet7.</summary>

In mobile mode the swimlane title, the list title, the board title and everything in the top bar sit on the vertical centre of their row instead of high up. The swimlane title was a block at the top of a band sized by its padding and the icons beside it; the list title was pinned to the bottom of one grid row with the card count pinned to the top of the next, so the pair hugged the middle and, with no card count, the title sat in the upper half; and the two header bars centred their contents inside boxes that were shorter than the touch-sized buttons in them, or padded above but not below

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/df27e349d">In mobile mode, clicking the Add List + no longer opens the list as well</a>. Thanks to xet7.</summary>

In mobile mode, clicking the Add List + no longer opens the list as well. The whole list row is a link that opens the list and the + sits inside it, so one click did both. The handler prevented the link default, which is a different thing from stopping the click travelling up to the row

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/61979c0f6">In mobile mode the "Add List" + moved into each list header, between the caret and the drag handle…</a> Thanks to xet7.</summary>

In mobile mode the "Add List" + moved into each list header, between the caret and the drag handle, and the row it used to occupy is gone — a whole row of a phone screen spent on one button. Desktop had already done this, so the two had drifted apart; the mobile button now uses the same class, and therefore the same handler and behaviour, and the same Font Awesome icon, rather than being a lookalike that can drift again. The list header row mirrors in RTL by itself, being a CSS grid, which lays out along the inline axis — handle, +, caret, then the title — and no control is pinned to a physical side. An empty swimlane and an empty board keep a + of their own, since there is no list header to host one and otherwise the first list could never be created. The template that rendered the old row is removed along with its handlers, mobile mode having been its last caller

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/7dcd9da67">Checked for the same window-resizing bug everywhere else it could hide, and closed the gap that…</a> Thanks to xet7.</summary>

Checked for the same window-resizing bug everywhere else it could hide, and closed the gap that made the question unanswerable: the guard test only ever looked at the client stylesheets. A viewport unit renders exactly the same from an inline style= in a template, from CSS built as a JavaScript string (the HTML export builds a card modal that way, the Sandstorm migration bridge builds a whole page) and from the stylesheets in our own packages and public/css. All three are now scanned, and all three are clean — what they contain is the allowed shapes. One further test proves the new scans are not silently reading nothing, since a scan that finds no files passes forever

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/d03cd0300">Elements no longer change size when the browser window is resized — this time the ones hidden…</a> Thanks to xet7.</summary>

Elements no longer change size when the browser window is resized — this time the ones hidden inside clamp(). Reported on a board in mobile mode: widening the window made the swimlanes, lists and cards taller, and narrowing it shrank them again. Same complaint as the Sign In and Register pages, and the same cause. The earlier sweep converted 355 bare vh/vw values but allowed clamp() as a cap — which it is only at its two ends; between them it tracks the window exactly like a bare vw. So a swimlane header, a list header and a minicard sized with font-size: clamp(18px, 2.5vw, 32px) grew with the window, and the rows grew with the text. All 75 of these, across 17 stylesheets, now use the size they already rendered at on a phone. The board sidebar, a collapsed list, the settings body, the search popover and the minicard custom-field chip had the same behaviour spelled other ways and are fixed too. The guard test no longer accepts clamp() or a viewport minimum; a full-viewport box, a max-height/max-width cap, and a shrink-only min(400px, 52vw) — flat above the crossover width — are still allowed

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/72315d8f9">The second header bar in mobile mode is the same on a phone as in mobile mode on a desktop window</a>. Thanks to xet7.</summary>

The second header bar in mobile mode is the same on a phone as in mobile mode on a desktop window. On an iPhone it held a handful of icons and no board title, while a desktop Firefox window in mobile mode showed the title and the full set of buttons. Mobile layout is driven from three switches that did not agree — the mobile-mode toggle, the mini-screen check that picks which template branch renders, and the 800px-wide media query, which is true on a phone and false on a wide desktop window even with mobile mode on. The rule hiding the left button group deleted edit title, visibility, watch, star and sort from the bar with no replacement whenever those switches disagreed, and is removed; the bar's fixed height on a phone, added for bigger touch targets, clipped the second row it wraps to when the title and the buttons do not fit, and is now a minimum height, so the bar still sits on one row whenever the content fits

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/72315d8f9">The list title is visible in mobile mode again, both on a phone and on a desktop</a>. Thanks to xet7.</summary>

The list title is visible in mobile mode again, both on a phone and on a desktop. The list header has two markup shapes — on a mini screen the title is a direct child of the header, otherwise it is wrapped in the div that rotates when a list is collapsed. Mobile mode lays that header out as a grid and places the title by grid row and column, which only applies to a direct grid item, so inside the wrapper the placement was ignored and the title landed in the 30px first column, squeezed to nothing. The wrapper is now dropped from the layout so the title is placed in both shapes, while a collapsed list keeps the box it needs to rotate

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/72315d8f9">The board in mobile mode reflows sideways again instead of overflowing by a sliver</a>. Thanks to xet7.</summary>

The board in mobile mode reflows sideways again instead of overflowing by a sliver. The canvas, swimlanes, lists and list headers were sized with 100vw — the viewport width including the vertical scrollbar. The canvas scrolls vertically, so each of those was about a scrollbar's width wider than the box it sat in, at every nesting level, and a matching min-width meant it could not shrink. They now use 100%, which resolves against the parent's content box and already excludes the scrollbar

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/114a0af31">The minicard drag handle is below the minicard menu button, on the same edge</a>. Thanks to xet7.</summary>

The minicard drag handle is below the minicard menu button, on the same edge. On a touch pointer it was a full-height strip down the leading (left in LTR) edge — added because on a touch device the handle is the only way to drag a card, the card body deliberately panning the board instead, so the small corner icon was too small a target. That reached the size goal but put the handle on the opposite side of the card from every other card control, and from where a mouse user's handle already sits. Both pointer kinds now use the same column: details menu on top, drag handle directly below. The touch target is not given up — the handle is 44px wide and runs from under the menu button to the bottom of the card, with a floor for a card holding nothing but a title, and the card reserves that column so its text never runs underneath. It mirrors to the other edge on an RTL board

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/a5246fbf5">Drag-to-scroll now works on both top header bars, not only below them</a>. Thanks to xet7.</summary>

Drag-to-scroll now works on both top header bars, not only below them. Everywhere else a drag scrolls — the board canvas on a board, the page on All Boards / My Cards / … — but the two header bars were dead, and on a phone they are a large share of what is on screen. They cannot simply be tagged dragscroll: that class scrolls the element carrying it, and neither bar is a scroll container — #header sits outside #content and outside the board canvas. A new module, client/lib/headerDragscroll.js, forwards the drag to whatever actually scrolls: a scroller inside the header first (the starred-boards list scrolls sideways), then the board canvas, then the page. Mouse and touch are both handled, since a phone sends no mouse events and the dragscroll library is mouse-only. A tap is still a tap: scrolling begins only past a 4px threshold, and only then is the click that ends the gesture swallowed

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/a5246fbf5">The board title is shown again in mobile mode, and the second header bar now reads title, buttons…</a> Thanks to xet7.</summary>

The board title is shown again in mobile mode, and the second header bar now reads title, buttons, hamburger. The title was hidden by a display: none rule, which left that bar with nothing but icons — you could not tell which board you were on. A board name is how you know where you are, so it wraps rather than truncating. The buttons start at the right of the title and drop to a second row as a block when the space between the title and the menu button runs out; they are one flex item, so they never split across the two rows

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/a5246fbf5">The sidebar hamburger, with its divider to its left, is pinned in the top right corner of the…</a> Thanks to xet7.</summary>

The sidebar hamburger, with its divider to its left, is pinned in the top right corner of the second header bar in mobile mode. It was lifted out of the right-hand button group into its own flex item, because inside that group it could only ever wrap down together with the other buttons — a flex line breaks in order. On a phone it is taken out of the flow and the bar reserves its width, so a long title or a full row of buttons never runs underneath it; on a wide screen it is ordered back to the end of the bar, visually identical to before

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/a5246fbf5">Lists and cards are full width in mobile mode on a desktop browser too, not only on a phone</a>. Thanks to xet7.</summary>

Lists and cards are full width in mobile mode on a desktop browser too, not only on a phone. Mobile mode was decided in two places that disagreed. Utils.isMiniScreen(), which drives the mobile-view class on each list, swimlane and minicard, looked only at screen width and user agent — the explicit mobile-mode toggle was honoured on iPhone only, and even there through a test that could never be false. An explicit choice now wins on every device, with the per-device branches left as the defaults for when the user has never chosen. And the rule that persists a resized list width outranked the mobile full-width rule (both !important, the other more specific), which showed only in mobile mode on a desktop window because no per-list width is emitted on a phone; a collapsed list still stays narrow

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/45022e907">The Sign In and Register pages have real scrollable space below the form again, so the last field…</a> Thanks to xet7.</summary>

The Sign In and Register pages have real scrollable space below the form again, so the last field and the button are reachable on a short window instead of being cut off by a container that never scrolled

</details>

and has the following developer-facing changes:

<details> <summary><a href="https://github.com/wekan/wekan/commit/b0bffe8c1">The Playwright tests address an Admin Panel menu entry by its data-id, not by a per-page class</a>. Thanks to xet7.</summary>

The Playwright tests address an Admin Panel menu entry by its data-id, not by a per-page class. Fourteen tests were failing in every browser on locator('.js-people-menu'), which no longer exists: Admin Panel / People renders the shared left menu, whose entries are a.js-left-menu-item(data-id="…"), and data-id is what the design says a page's click handler reads — so a conversion cannot take it away the way it took the per-page class. The same sweep updated the Problems report entries, the per-report search inputs and pagination (one shared table page for all of them) and the People pager in the page object

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/9791993d5">Every paginated admin table — Security, Speed, Tests, CPU usage, Files Report, Rules Report, Boards…</a> Thanks to xet7.</summary>

Every paginated admin table — Security, Speed, Tests, CPU usage, Files Report, Rules Report, Boards Report, Cards Report, Impersonation Report and Recovery — now renders through ONE shared table page instead of ten copies of the same markup, helpers and handlers. They differ only in a column list. That made three long- standing layout complaints fixable in one place: the table is full width with table-layout: fixed, so every column gets the same percentage of the width and a long id or file name can no longer widen the table past the panel and push its right-hand columns outside the browser window; cell text wraps instead of stretching its column; and on windows ≤ 800px the left menu goes full width on top with the table BELOW it, rather than the two being squeezed side by side until the table is a few dozen pixels wide. Rows run title, status, controls, table. Paging still fetches only the current page, and now takes that window from the same helper that renders "page X / N". The design, and the list of every page that uses it, is in docs/Features/Page/Table.md; the History, CPU usage and Recovery design docs link to it and keep only what is specific to them

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/a5246fbf5">Every .jade template is now compiled by a test, using the same compiler the build uses</a>. Thanks to xet7.</summary>

Every .jade template is now compiled by a test, using the same compiler the build uses. Nothing else caught a broken template: the other test suites read .jade as text and grep it, so a file the compiler rejects still passed them all and the failure appeared only when the app was rebuilt. Written after a <body> written inside an indented comment block broke the build — the comment text is still lexed, so the angle brackets opened a tag that never closed. All 107 templates parse in about 0.2s

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/f14dc6a78">The shared left menu is a design of its own — docs/Features/Page/Left-Menu.md — beside the table…</a> Thanks to xet7.</summary>

The shared left menu is a design of its own — docs/Features/Page/Left-Menu.md — beside the table page: pure helpers (buildMenuItems(), paneTitle(), activeCount()), one template, one stylesheet, and one suite, tests/leftMenu.test.cjs, that asserts the helpers, the template, the side the menu is on and its mirroring under a right-to-left language, and that no page re-implements it. tests/tablePage.test.cjs does the same for the table page, asserting against exactly the files that design's own Related files table lists — so a path that moves without the doc being updated fails the suite

</details>

and updates the documentation:

<details> <summary><a href="https://github.com/wekan/wekan/commit/fd12199e9">docs/Design/Multitenancy/Multitenancy.md designs the alternatives to running one WeKan Node.js…</a> Thanks to xet7.</summary>

docs/Design/Multitenancy/Multitenancy.md designs the alternatives to running one WeKan Node.js server per customer. The shipped topology gives each tenant its own ROOT_URL, database, uws.port and upgrade; this page works out what one server serving many domains would cost, from WeKan's own code — the six things such a process must solve and the Meteor 3 API for each (Meteor.onConnection's httpHeaders for the tenant, WebApp.addRuntimeConfigHook for the bundle's baked in ROOT_URL, Meteor.absoluteUrl's rootUrl override), and four alternatives compared in a table: process per tenant, a tenant field with mizzao:partitioner, a database per tenant in one process, and Organizations as tenants — which WeKan already half has. The recommendation is to keep one process per tenant and reach for Organizations when the tenants are groups of one organisation, because the other two trade a merely tedious operational cost for a silent cross-tenant disclosure risk across 46 collections and 66 publications

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/1e9e58dab">The Admin Panel documentation mirrors the Admin Panel menu: a menu path is a docs path, so Admin…</a> Thanks to xet7.</summary>

The Admin Panel documentation mirrors the Admin Panel menu: a menu path is a docs path, so Admin Panel / Settings / Visibility is docs/Features/Admin-Panel/Settings/Visibility.md and Admin Panel / People / Teams is People/Teams.md, with an index per section listing its panes in menu order. The prose is swept with it — Version as the pane that opens, Visibility's four groups (absorbing the separate Allow-private-boards-only and Custom-Logo pages), Layout being PWA, Registration being People / Login, the removed Accounts and Features tabs, the invite-domain field, the two board-member restrictions, Backup first in Attachments, and the two named groups of the Problems menu. Every link into the moved pages was updated, and the wekan.fi pages that described the old panes were edited too

</details>

and fills untranslated strings in every language:

<details> <summary><a href="https://github.com/wekan/wekan/commit/78e2adb69">The twelve strings this release's multitenancy and site-theme work added — an Organization's…</a> Thanks to xet7.</summary>

The twelve strings this release's multitenancy and site-theme work added — an Organization's domains and what they do, the domain-already-taken error, the Organization admins popup and its description, the backup scope and its description, and the line telling a site admin their theme applies to all tenants — are translated into all 142 languages, each in that language's own existing WeKan terminology for Organization, backup, admin and theme. They are written through the same tooling as before, so a human translation can never be overwritten and none of them is pushed to Transifex, where a real translation can replace them at any time

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/526905483">The strings that are untranslated everywhere — on Transifex and in git — are written directly…</a> Thanks to xet7.</summary>

The strings that are untranslated everywhere — on Transifex and in git — are written directly, using each language's own existing translations and its kanban terminology as the reference, with no external translation service, API or password involved. 1,191 strings in fi, sv, da, nb, de, nl, fr, es, pt, pt-BR, it, ca, gl, eu, ro, cs, sk, sl, hr, sr, pl, hu, lt, lv, et, ru, uk, bg, el, tr, ar, he, fa, hi, id, vi, th, ja, ko, zh-CN and zh-TW — mostly this release's Admin Panel work, plus the recovery report, the problems status, the broken-card repair, the CPU figures, the import timeout and the map-to-existing-user dialog. A further 1,935 strings reach the regional variants (de-AT, es-MX, fr-CA, pt-PT, ru-UA, zh-Hant, ca@valencia and 57 more) from their base language, which is the same language's own words rather than a translation of anything. Nothing can overwrite a human translation: a string is filled only while it is a placeholder — missing, or equal to the English source — and a key that already holds a translation is skipped and reported

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/cc71cb28c">Every remaining language followed, worked through one at a time from the most spoken to the least…</a> Thanks to xet7.</summary>

Every remaining language followed, worked through one at a time from the most spoken to the least: Chinese, Hindi, Spanish, Arabic, French, Portuguese, Indonesian, Malay, German, Italian, Thai, Tamil, Telugu, Punjabi, Swahili, Dutch, Yoruba, Igbo, Gujarati, Odia, Uzbek, Azerbaijani, Khmer, Uyghur, Zulu, Belarusian, Xhosa, Armenian, Afrikaans, Turkmen, Mongolian, Wolof, Georgian, Tamazight, Macedonian, Welsh, Occitan, Breton, Yiddish, Venda, Asturian, Walloon, Frisian, Acehnese, Esperanto, Flemish, Volapük and Klingon — each in its own kanban vocabulary, and each regional variant taking its base language's words afterwards. What is left untranslated is, in the main, the strings a language spells exactly as English (Action, Format, Status, Server, Menu, Type, the colour names, the poker numbers, LDAP / OAuth2 / GridFS / S3): the tooling ignores a fill equal to the English source, so they can never be recorded as translated — and they are already correct on screen. The long prose of the Volapük and Klingon files is deliberately left in English rather than invented

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/526905483">releases/translations/fill_translations.py joins the .mjs of the same name: the same rules — the…</a> Thanks to xet7.</summary>

releases/translations/fill_translations.py joins the .mjs of the same name: the same rules — the same definition of a placeholder, the same en.i18n.json key order, the same 2-space indent — in Python, for an environment without a node runtime. Either can list what a language still needs and apply the translations

</details>

Thanks to above GitHub users for their contributions and translators for their translations.

v10.37 2026-07-25 WeKan ® release

This release fixes the following SECURITY ISSUES found by GitHub CodeQL code scanning:

<details> <summary>js/incomplete-sanitization (High), alert #428.</summary>

js/incomplete-sanitization (High), alert #428, in tests/boardHeaderOneLine.test.cjs: the CSS rule bodies were split with rule.split('{') and the trailing brace removed with body.replace('}', ''), which strips only the FIRST }. The block is now matched with one regex that captures selector and body as separate groups, so no brace ends up in the text and there is nothing to strip.

</details> <details> <summary>js/incomplete-sanitization (High), alert #427.</summary>

js/incomplete-sanitization (High), alert #427, in tests/testsAreRegistered.test.cjs: a file name was spliced into a RegExp after escaping only dots, leaving every other metacharacter — the backslash above all — able to change the meaning of the pattern. It now goes through an escapeRegExp() that escapes the full set, backslash included.

</details>
  • Both are test helpers, so neither was reachable by an attacker, but both were genuinely wrong string handling (commit).
  • Thanks to GitHub CodeQL (alerts #427 and #428) and xet7.

and resolves the following GitHub Dependabot alerts in npm dependencies:

<details> <summary><a href="https://github.com/wekan/wekan/commit/56cbcdb7e">brace-expansion 5.0.7 → 5.0.8</a>.</summary>

brace-expansion 5.0.7 → 5.0.8 (RUNTIME dependency, transitive via minimatch): fixes CVE-2026-14257 (GHSA-mh99-v99m-4gvg, High, CVSS 7.5, alert #119) — expand() bounds how MANY results it produces (max, 100000) but not how LONG they get, so chained brace groups keep the count under the cap while every result grows one character per group. A ~7.5 KB input ('{a,b}'.repeat(1500)) exhausts memory and kills the Node process with a fatal, uncatchable out-of-memory error that try/catch cannot contain. 5.0.8 bounds the total characters one expand() call may accumulate (maxLength, default 4000000) inside the output-building loops, so intermediate arrays are bounded too and oversized input is truncated rather than fatal. Patch release in the same major line; the only other change is engines.node, which drops Node 18 — WeKan builds and ships on Node 24 (Dockerfile NODE_VERSION=v24.18.0, CI NODE_VERSION: '24'), so nothing is affected (#6523, merge commit )

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/10af0bc2a">postcss 8.5.15 → 8.5.22</a>.</summary>

postcss 8.5.15 → 8.5.22 (dev dependency; enters only through css-loader, which the rspack build uses for .css, and is not itself part of the shipped bundle): fixes GHSA-r28c-9q8g-f849 (High, CVSS 7.5) — postcss follows a /*# sourceMappingURL=... */ comment in the CSS it parses and builds the path with path.join(dirname(from), annotation), which normalises but does not sandbox .., so a crafted comment discloses arbitrary .map files. Affected <= 8.5.17, patched in 8.5.18. nanoid 3.3.12 → 3.3.16 rides along as postcss's own dependency (#6522, merge commit )

</details> <details> <summary>Both are lock-only changes whose tarball integrity hashes were verified against the npm registry…</summary>

Both are lock-only changes whose tarball integrity hashes were verified against the npm registry, and the brace-expansion lock edit was reproduced independently with npm update brace-expansion --package-lock-only, which produced a byte-identical diff. elliptic (alert #55, CVE-2025-14505) is still left pinned for the reason recorded in v10.14: no fixed version is published upstream — the latest release is still 6.6.1 and the advisory lists no patched version. It is a dev-only transitive polyfill (@meteorjs/rspacknode-stdlib-browsercrypto-browserifybrowserify-sign/create-ecdh), no WeKan client module imports node crypto, and the built client bundle contains no elliptic/secp256k1/crypto-browserify code at all, so it never reaches users.

</details>
  • Thanks to GitHub Dependabot and xet7.

and has the following developer-tooling fix:

<details> <summary><a href="https://github.com/wekan/wekan/commit/e876b6029">docs/Security/gh/pull-security-reports.sh reported OK for every endpoint and then wrote empty split…</a> Thanks to xet7.</summary>

docs/Security/gh/pull-security-reports.sh reported OK for every endpoint and then wrote empty split files and blank counts. gh api --paginate --slurp returns ONE ARRAY PER PAGE, so a list endpoint arrives as [[alert,...],[alert,...]]; nothing flattened that, so .[] yielded PAGES rather than alerts and every select(.state == "open") ran against an array. jq failed, its error went to /dev/null, and the > redirect left a 0-byte file — not valid JSON, so every later step on it failed too and printed nothing. A security report that quietly claims there is nothing to see is worse than one that fails. The pages are now concatenated (only when every element is itself an array, so single-object endpoints like the SBOM are untouched), a failed filter leaves a valid empty array plus a readable error instead of truncating the file, the secret-scanning redaction validates the redacted copy before deleting the dump that still holds live secrets (and removes it on the failure path too), and counts always print a number. The same shape had also broken the newest-analysis lookup and the codeql-databases dump

</details>

Thanks to above GitHub users for their contributions and translators for their translations.

v10.36 2026-07-25 WeKan ® release

This release fixes the following bugs:

<details> <summary><a href="https://github.com/wekan/wekan/commit/8e58456c4">The Change profile image window (Member settings &gt; Change Avatar, and the same window in Admin…</a> Thanks to xet7.</summary>

The Change profile image window (Member settings > Change Avatar, and the same window in Admin Panel > People) rendered each avatar row's "Delete" link and file name INSIDE the row's select link. An <a> inside an <a> is invalid HTML: the browser closes the outer anchor, so the file name and the Delete link fell out of the row's flex layout and rendered as bold text floating at the right edge, and a click on Delete also selected that avatar. Delete and the file name were shown only for the avatar currently in use, so no other upload could ever be deleted, and an empty p.sub-name added a stray block in the middle of the row. Each row is now one flex line - thumbnail, file name (truncated with the full name as a tooltip), a check mark when it is the avatar in use, and a delete button that is a SIBLING of the select link, so deleting never also selects and every uploaded avatar can be deleted. The row in use is highlighted like in the other pop-over lists, the initials row shows its "Default avatar" label under the name, and the upload hints (max filesize, allowed filetypes, invalid filename) are grouped into one muted paragraph above the upload button. Deleting the avatar in use left profile.avatarUrl pointing at a file that no longer existed - a broken image everywhere - so both windows now fall back to the initials when the deleted file is the one in use. The Admin Panel window also passed userData= to userAvatarInitials, which reads this.userId, so its initials rendered empty

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/1551e142f">On iPad Safari a full-screen "Uncaught runtime errors: ERROR Script error." panel appeared after…</a> Thanks to xet7.</summary>

On iPad Safari a full-screen "Uncaught runtime errors: ERROR Script error." panel appeared after login, and again when the Safari Share sheet was opened on All Boards to add WeKan to the home screen. That panel is webpack-dev-server's error overlay, shipped by the rspack DEV server only - a production build has no dev server and no overlay. It opens for every window error event, including ones that carry no information: a browser sanitises an error thrown by a CROSS-ORIGIN script to the bare message "Script error." with no error object and no stack, and iOS Safari raises those from its own machinery and from content blockers and extensions, which is what the Share sheet triggered. It cannot come from WeKan's own code, because the bundle is served same-origin (no CDN_URL), so a real WeKan error always reaches the overlay with its actual message and stack. The overlay now ignores exactly that one message and still shows every other runtime error and all compile errors

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/f75b28cc8">The board bar (the second header bar) fits on one line on iPad</a>. Thanks to xet7.</summary>

The board bar (the second header bar) fits on one line on iPad. In landscape it wrapped onto two rows - board title, edit, visibility, watch, star and sort on the first, Filter / Search / view / dependencies / multi-selection / hamburger on the second. Measured at a 1180 CSS px viewport the two rows needed about 1304px on one line, ~124px more than the viewport, and nearly all of that is per-button empty space rather than text: each button carries 12px side margins, 10px on each side of its icon and a 10px gap after its label, about 54px of chrome around a ~60px icon+label. So nothing has to be hidden at that width: halving the chrome between 801px and 1400px frees ~295px, which fits the bar on one line with room to spare for languages with longer labels, and icon and label sizes are untouched so the buttons stay comfortable to tap. In tablet portrait (820px), where even the halved chrome cannot fit the labels, they drop to icons only between 801px and 1000px - the same treatment phones already get below 801px, with titles, tooltips and popups unchanged. Wide desktops, which never wrapped, look exactly as before, and phones keep their 44px touch targets

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/9df568c0c">A long board title now moves ALL of the board bar's buttons to its second row, under the title</a>. Thanks to xet7.</summary>

A long board title now moves ALL of the board bar's buttons to its second row, under the title. Before, the controls were split across the two rows: the title kept the left group (edit, visibility, watch, star, sort) beside it and only the right group (Filter, Search, view, dependencies, multi-selection, hamburger) wrapped underneath. That is what separate flex items do - the title and the three button groups were four children of the header row, so the row broke wherever it ran out of width. The three groups now live inside one wrapper, so the row has just two items, the title and the buttons, and either the buttons fit beside the title or the whole set moves down together. A short title is unaffected, because the wrapper measures as its own content width and only moves down when it genuinely does not fit. A title longer than the whole bar now wraps inside the title instead of overflowing the viewport

</details>

Thanks to above GitHub users for their contributions and translators for their translations.

v10.35 2026-07-24 WeKan ® release

This release fixes the following bugs:

<details> <summary><a href="https://github.com/wekan/wekan/commit/f92b49d1b">Copying a board works again (POST /api/boards/:boardId/copy). swimlane.copy(), which runs on the…</a> Thanks to xet7.</summary>

Copying a board works again (POST /api/boards/:boardId/copy). swimlane.copy(), which runs on the server, called the SYNCHRONOUS getDefaultSwimline() on the source board to detect its default swimlane; for a board with no pickable swimlane that getter self-heals via a synchronous Swimlanes.upsert(), which Meteor 3 rejects on the server (update is not available on the server. Please use updateAsync() instead.), so the whole copy threw. It now calls the existing getDefaultSwimlineAsync() (same pick + idempotent upsertAsync self-heal). Two related hardenings landed with it: the copy REST route caught the error with sendJsonResult(res, { data: error }), and that helper defaults the HTTP status to 200 when no code is given — so a copy that THREW was returned as 200 with the error object as the body, indistinguishable from success (that is what the REST test caught); the route now returns 500 with the message and logs the stack, so a failed copy is a clean, diagnosable error. And board.copy()'s custom-field remap did card.customFields.map(...), which throws for a card whose customFields is absent (the schema defaults it to [], but a card copied via .direct or seeded raw can lack it) — it now skips cards that have none, matching the !Array.isArray(this.customFields) guard used elsewhere in the model

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/e28181734">All Boards header on a phone, follow-up: the section icon and the Multi-Selection / Sort buttons…</a> Thanks to xet7.</summary>

All Boards header on a phone, follow-up: the section icon and the Multi-Selection / Sort buttons really do share ONE row now, with the Search box on the row below, and a workspace name no longer hides under its "bars" menu button. The buttons and search box live inside a .path-right wrapper, so the v10.34 attempt (giving that wrapper flex) only made the WRAPPER wrap as a two-line block beneath the section icon, leaving the icon alone on row 1. On a phone .path-right is now display: contents, which dissolves the wrapper so its buttons and search field become direct items of the header's own flex row; the header then wraps them itself — section icon + buttons on row 1, full-width search on row 2. And the narrow-left-menu workspace NAME had a min-width floor that stopped it shrinking, so in the cramped row its box overflowed onto the "bars" workspace-menu button to its right and the hamburger looked like it sat on top of the name; the floor is removed and the name's box is clipped, so the name truncates with an ellipsis and can never cover the menu, while the menu button and count never shrink

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/52d68be75">Upgrading no longer grows a spurious "Restored Items" column full of cards that look empty but are…</a> Thanks to hmeunier95 and xet7.</summary>

Upgrading no longer grows a spurious "Restored Items" column full of cards that look empty but are not. Four obsolete per-swimlane-lists-era board migrations fought today's data model and are removed. restoreLostCards created a "Lost Cards" swimlane and a "Restored Items" list and moved cards into them — it counted a card "orphaned" when its list was merely ARCHIVED (archived lists are excluded from the set it checks) and "lost" when its swimlaneId was '' (which is NORMAL for a board-wide shared card), so it dragged real, healthy cards into "Restored Items", where they rendered oddly. comprehensiveBoardMigration and fixMissingListsMigration converted today's board-wide SHARED lists (swimlaneId '') back into per-swimlane DUPLICATE columns — the exact damage the startup schema step merge-per-swimlane-lists now UNDOES — and restoreAllArchived un-archived every swimlane / list / card at once. All four had NO callers left (their admin migration dashboard was already removed), so they were dead code exposed only as admin-callable Meteor methods that could re-corrupt a board if invoked. The correct paths remain: the board-open self-heal (repairBoardData) relinks a genuinely missing swimlaneId or an orphaned card to the board's real first list/swimlane, and merge-per-swimlane-lists merges any per-swimlane duplicate columns back into one shared list while cards keep their swimlaneId; it still keys off the era's board markers in existing data, so removing the code that once wrote them changes nothing for already-migrated boards. A test keeps the four from coming back

</details>

and has the following developer-tooling changes:

<details> <summary><a href="https://github.com/wekan/wekan/commit/c2ad9dddb">build.sh and build.bat gain a Setup → "Update git" action that makes the working copy current in…</a> Thanks to xet7.</summary>

build.sh and build.bat gain a Setup → "Update git" action that makes the working copy current in one step: git fetch --all --prune, git pull --rebase --autostash onto origin/<branch>, then it repoints any CHANGELOG commit links the rebase made stale, then shows git status. It never commits — if the hash fix changed CHANGELOG.md it says so and leaves it for review — and on a rebase conflict it stops with the exact commands to continue or abort. The commit-link repair that lived inline in releases/release-all.sh is extracted to releases/fix-changelog-hashes.sh so there is ONE implementation, now called by both the release script and the new menu action; behaviour is unchanged (only the unreleased section is touched, stale links are repointed to the same-subject rewritten commit, unresolved links warn and never abort). The Windows .bat runs the shared bash script via Git Bash and degrades gracefully with instructions when bash is not on PATH

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/0cd2b513b">The Playwright WebKit tests no longer fail spuriously from renderer crashes on a headless ARM host</a>. Thanks to xet7.</summary>

The Playwright WebKit tests no longer fail spuriously from renderer crashes on a headless ARM host. A test run left eight core dumps in tests/playwright/, all of WebKit's WPEWebProcess renderer, aborting (SIGTRAP) from the Mesa llvmpipe software-GL stack on this Apple Silicon / Asahi host; a renderer that dies mid-test makes the next click/navigation time out, so it looked like WebKit-only failures (admin users, checklist delete, rules, All Boards sort) rather than real WeKan bugs. playwright.config.js now defaults WEBKIT_DISABLE_DMABUF_RENDERER=1 (unless already set) — the standard fix for WPE WebKit aborting in headless / containerized / VM / software-GL environments; Playwright passes process.env to the browser so it covers every run path, and it is harmless for Chromium and Firefox. .gitignore now ignores core dumps (core.<pid>) so a crashing test browser can no longer leave untracked multi-MB files to be committed by accident

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/c29f5c7a6">The developer build/test helper is renamed rebuild-wekan.sh → build.sh and rebuild-wekan.bat →…</a> Thanks to xet7.</summary>

The developer build/test helper is renamed rebuild-wekan.shbuild.sh and rebuild-wekan.batbuild.bat (a shorter name; the menu and every option are unchanged). All references were updated across the repo — the docs (Build-from-source, Windows, Sandstorm, Raspberry Pi, etc.), README.md, CONTRIBUTING.md, CLAUDE.md, releases/release-all.sh and releases/fix-changelog-hashes.sh, tools/forge-mirror.js, and the tests (the guard suite is renamed to tests/buildDevServer.test.cjs). If you invoke it by the old name, use ./build.sh / build.bat now

</details>

Thanks to above GitHub users for their contributions and translators for their translations.

v10.34 2026-07-24 WeKan ® release

This release adds the following new features:

<details> <summary><a href="https://github.com/wekan/wekan/commit/d168a9d30">Admin Panel → Problems → Summary now has a Repair button for "Broken cards N"</a>. Thanks to xet7.</summary>

Admin Panel → Problems → Summary now has a Repair button for "Broken cards N". The page reported the count and told the admin to "run the repair migration", but there was no button anywhere to run one — and no repair in the app could actually clear that count. countBrokenCards() counts every card missing a boardId OR a swimlaneId OR a listId, while repairAllBoards() walks only NON-ARCHIVED boards and only ever sets swimlaneId, so three kinds of counted card could never be repaired by anything: cards on an archived board, cards with a missing listId, and cards with no boardId at all. The new admin-only repairBrokenCards method runs the standard per-board pass and then closes those gaps, assigning the affected cards to their board's first usable list/swimlane (creating a default one when the board has none) and resolving each board's defaults once rather than once per card. Cards with no boardId cannot be placed on any board — guessing would drop a user's card onto an unrelated board — so they are reported as unfixable and left alone, never deleted, instead of leaving a count that never reaches zero unexplained. What counts as broken now lives in ONE pure module used by both the count and the repair, so they can no longer drift apart, with unit and negative tests for the rule

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/f04c48451">Admin Panel → Problems → CPU usage now shows the CURRENT CPU usage between the page title and the…</a> Thanks to xet7.</summary>

Admin Panel → Problems → CPU usage now shows the CURRENT CPU usage between the page title and the Search box. The page listed only PAST high-CPU periods, so it could not answer "what is the CPU doing right now?" — the one question an admin opens it to ask. The new line shows the system CPU percentage, core count, 1/5/15 minute load average and the coarse "what WeKan is doing" label, refreshed every 5 seconds and outlined in red while the monitor considers the CPU sustainedly high. It reads the same sample the monitor writes its event rows from, so the header and the rows below it can never disagree, and with the background monitor disabled (WEKAN_CPU_MONITOR=false) it measures between calls using a separate baseline instead of always reading 0%

</details>

and fixes the following bugs:

<details> <summary><a href="https://github.com/wekan/wekan/commit/1fb382f0e">Admin Panel → Problems no longer shows a finished board-repair as still running, nor counts…</a> Thanks to Alishara and xet7.</summary>

Admin Panel → Problems no longer shows a finished board-repair as still running, nor counts informational CPU rows as "new problems". On an idle server with far fewer than 146 boards the Status page showed "Board data-repair — 146/146 boards" running forever (with a matching "Migration or repair" login warning), and the CPU area showed dozens of "new problems". The repair actually finishes and writes running:false, but the startup pass ALSO fired a non-awaited running:true progress write at the last board (boardsDone === boardsTotal); being fire-and-forget it could land after the awaited completion write and pin the doc at "running, 146/146" — and a process killed mid-repair leaves running:true too. The startup pass now persists only intermediate progress (the completion write owns the final state), and the Status page asks a pure isStatusActive() instead of reading the raw flag: a migration/repair counts as in progress only while it is running AND not in a terminal phase AND not already finished by count (done >= total) AND updated recently (a running flag with no recent progress write is a crashed run), so a doc already stuck on an instance heals itself and the "Migration or repair" warning clears with it. The CPU "new problems" count excluded nothing: the monitor writes one detected row when a high-CPU period starts and several severity:'info' rows (remediated when it ends, mitigation and FerretDB-governor rows), so each brief, already-over spike looked like several problems; the count now excludes severity:'info' rows — notices that a problem was handled or cleared are not problems — while rows with no severity still count, so nothing unclassified is dropped

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/642fa8e93">"Map to existing user" for an imported (virtual) member now searches every user instead of listing…</a> Thanks to AmigaAbattoir and xet7.</summary>

"Map to existing user" for an imported (virtual) member now searches every user instead of listing only board members. After importing a Trello board and choosing to map users later, the picker offered nothing but the admin: it listed only the ACTIVE REAL MEMBERS of the board, and right after an import the real people have no membership at all — the placeholders do — so on a board whose only real member is the importing admin there was literally nobody to pick, and no search box either. The picker now has a search box that goes through the same permission-checked searchUsers method the normal add-member typeahead uses, so the same board-membership check and the same #6116 same-org/team restriction apply; with the box empty it still lists the board's real members (which changes no roles at all), and typing searches every real user and labels the ones who are not on this board yet. Mapping onto a user who is not a board member adds them with the PLACEHOLDER's own role — the role the import recorded for that person, never a higher one — and only after passing exactly the checks inviting them would: the Admin Panel "roles allowed to invite" setting, the same-org/team restriction, and the refusal to add a deactivated account. This also fixes sanitizeUserForSearch dropping profile from every search result — it read the Mongo-style paths in its allowlist (profile.fullname) as FLAT keys, so both this picker and the add-member typeahead showed a blank name with only the username in brackets

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/1fb32b226">Admin Panel report styles were never loaded, so every report's prev/next pagination button rendered…</a> Thanks to xet7.</summary>

Admin Panel report styles were never loaded, so every report's prev/next pagination button rendered with a black background. client/components/settings/adminReports.css was not imported anywhere, and package.json sets meteor.mainModule, which disables Meteor's eager loading — a CSS file that nothing imports is simply not in the bundle. The buttons fell through to the global button { background: var(--theme-accent, #000) } in forms.css, which is BLACK when no custom theme colour is set. All prev/next controls in WeKan (admin reports, People/Org/Team/Domain, All Boards, board Table view, cron tables) now share one themed stylesheet that also covers :focus and :activeforms.css styles those at a specificity equal to or higher than a plain .some-pagination button rule, so even a loaded stylesheet lost as soon as the button was clicked and pressing "next" left it dark grey until it lost focus. Disabled is now the same themed outline faded rather than a grey block, and the "3 / 42" page info inherits the surrounding text colour instead of a hard-coded #333 that vanishes on dark backgrounds

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/668802cd7">Admin Panel → Problems → Cards report loads and pages faster</a>. Thanks to xet7.</summary>

Admin Panel → Problems → Cards report loads and pages faster. The report already paginated server-side (25 rows per page via limit/skip), so it never loaded all cards into the browser, but the cardsReport publication sent WHOLE card documents — description, customFields, vote/poker sub-documents, every date field — when the table renders only six columns, so a 25-row page could be hundreds of kilobytes on boards with long descriptions; it now projects only the six columns shown. And every prev/next click re-ran the report's count method as well as re-subscribing, paying for a full collection count plus a second server round trip even though the total cannot change just because you moved to the next page; opening a report and searching in it now recount, plain paging does not. Both changes also apply to the files, rules, boards, impersonation and recovery reports, which share the same loader

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/49e576b3c">Six Meteor methods threw Did not check() all arguments during call to '&lt;method&gt;', because this app…</a> Thanks to xet7.</summary>

Six Meteor methods threw Did not check() all arguments during call to '<method>', because this app runs with the audit-argument-checks package: a method that RECEIVES an argument it never check()s fails the call outright. problemDetailReport(area) had the defect but nothing had ever passed it an argument, so it surfaced only when the new Admin Panel → Problems → CPU usage header started polling problemDetailReport('cpu') every 5 seconds and filled the server log with the exception. Auditing every server method for the same defect found five more that ARE called with arguments from the client, so those features were failing outright: unlockUser (Admin Panel → People, unlocking a locked-out user), runBackup, restoreBackup and saveBackupSchedule (Admin Panel → Attachments) and migrateTextDatabase (the MongoDB ↔ FerretDB text migration), plus getServiceConfiguration, which has no current client caller. Each now checks its arguments against the types its call sites actually pass

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/51116657c">Every bundled binary now runs through cpu-exec on Sandstorm and in the snap, so a CPU missing an…</a> Thanks to xet7.</summary>

Every bundled binary now runs through cpu-exec on Sandstorm and in the snap, so a CPU missing an instruction-set feature falls back to qemu-user instead of killing the app with SIGILL. cpu-exec (#6458) exists for exactly that — AVX masked by a hypervisor (QEMU/KVM/Proxmox), an old CPU, ARMv8.0 on a Raspberry Pi — and with no features declared it is a plain exec, which is why the design is to route everything through it. Sandstorm SHIPPED cpu-exec and qemu-x86_64 but its grain launcher spawned all eight bundled binaries directly (ferretdb, mongod 3.0 twice, niscud, the legacy mongo CLI twice, and node for the bridge and the importer), so the safety net sat in the package unreachable — on the platform with the least control over its hardware, since a grain runs on whatever CPU the host has. The snap routed mongod but not FerretDB (ferretdb-control, both the external-DB and SQLite launches) and not node (wekan-control, the main application start and both maintenance-page starts). Both are routed now, each with a direct-exec fallback so an older deps image or snap revision still works; on Sandstorm the guard also requires /bin/bash, since cpu-exec is a bash script, and the snap's main start keeps its ulimit -s 65500. Docker and the bundle launcher already routed their binaries and were rechecked for other unrouted launches. The wiring guard previously asserted only that the Sandstorm build SHIPS cpu-exec — exactly how the gap survived — and now asserts that each launcher actually uses it

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/8ed253709">build.sh now raises the inotify watch limit when it is too low, and .meteorignore stops Meteor…</a> Thanks to xet7.</summary>

build.sh now raises the inotify watch limit when it is too low, and .meteorignore stops Meteor watching .tools/ and FerretDB/. Meteor's file watcher takes ONE inotify watch per DIRECTORY, and fs.inotify.max_user_watches is per USER, shared with every other watcher — an editor is usually the other big consumer. When it runs out, Meteor fails with a message that blames the disk and sends people looking in the wrong place: inotify_add_watch on '<path>' failed: No space left on device. ENOSPC from inotify_add_watch means the WATCH LIMIT is exhausted, not that the disk is full. Two non-app trees were 72% of this repo's 41,040 watched directories — .tools/ (20,535 dirs, 4.1 GB: a full Node install with headers for every openssl arch, a Go toolchain with its caches, build logs) and FerretDB/ (9,148 dirs, 3.4 GB: the Go fork checkout). Both were already in .gitignore, but the watcher only honours .meteorignore, so Meteor kept walking them; excluding them takes the count to 11,213. And build.sh now checks the limit on every run and raises it to 524288 (plus max_user_instances to 1024) with sysctl, persisting it to /etc/sysctl.d/60-wekan-inotify.conf. It never aborts a build: it is a silent no-op when the limit is already high, uses sudo only when it can, prints the exact commands to run by hand when it cannot, honours WEKAN_INOTIFY_WATCHES=0 to skip, and returns immediately on macOS/BSD, which have no inotify

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/5a1a661e0">Drag handles: the "Show desktop drag handles" toggle now works on touch screens, the card body pans…</a> Thanks to xet7.</summary>

Drag handles: the "Show desktop drag handles" toggle now works on touch screens, the card body pans the board when handles are on, the handle is finger-sized on touch, and tab order no longer jumps to invisible controls. The rule was isTouchScreen() || preference, so on a touch screen the OR was already true and the toggle could never hide the handles — and every handle in the app plus every sortable handle selector goes through that one helper. The setting is now three states (on, off, never-chosen): an explicit choice always wins, including OFF on touch, and only never-chosen lets the device decide, where a touch screen still gets handles by default. Handles also decide what a DRAG MEANS, and that half was broken too: .minicard and .list-header carried nodragscroll unconditionally, so with handles ON a finger dragging across a card neither moved the card (only the handle does) nor panned the board — it did nothing; they now opt out only when handles are OFF, i.e. only when the element IS the drag source. The handle itself was a 20–28px icon in the bottom-right corner, a mouse-sized target that is close to unusable on a wall-mounted infoscreen; on a coarse pointer it becomes a 48px full-height strip down the leading edge with the card content padded to match, while a mouse keeps the compact corner handle. And the keyboard card/list move buttons are focusable but opacity: 0 at the START of the card content, so tabbing went card → invisible → invisible → next card; they are now revealed while focused

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/21be1e57b">The quick-access top bar now fits on ONE row on phones</a>. Thanks to xet7.</summary>

The quick-access top bar now fits on ONE row on phones. On an iPhone 12 mini (375px) it wrapped to two rows — home + logo + mobile/desktop toggle on the first, drag-handle toggle + zoom + notifications + user menu on the second. The mobile CSS chose that deliberately (a nowrap row overflowed and was clipped), but that treated the symptom: the row did not fit because the home link kept its full "All Boards" label — the widest item in the bar, and longer still in most languages — the logo was allowed 120px of a 375px bar, TWO items had margin-inline-start: auto (an auto margin absorbs all remaining space on its line, so the first one filled row 1 and pushed the rest onto row 2), and items carried 8px gaps plus 0.5rem margins on both sides on the iPhone path. The row now fits: icon-only home link, an 84×20 logo cap, exactly one auto margin, 6px gaps and 3px margins — 342px of 375px by the declared sizes. The home label is a bare text node so it cannot be selected directly; font-size: 0 on the link collapses it and the icon's own size brings the icon back, with the text still in the DOM for screen readers. Both the @media path and the .iphone-device path (added by boardBody.js, so present on a board page and absent on All Boards) were changed together, or the bar would have changed height when navigating between those two screens The "All Boards" label is also hidden whenever mobile MODE is on, which is a different thing from a narrow viewport: body.mobile-mode comes from the phone/desktop toggle in that same bar, is a deliberate user choice and applies at any width, so on a wide screen in mobile mode the label was still showing](https://github.com/wekan/wekan/commit/b9f178ef5). Hiding the label needed !important and the last word in the file: ELEVEN rules in header.css set a font-size on that same link, most of them !important, including three scoped to the All Boards page itself (body:not(.board-view) / .wrapper ~) and one setting 16px !important — so the first attempt lost to all of them and the label kept showing on exactly the page it was reported on. The notification bell also sat in 36px of margin around a 28px icon (10px each side of the button plus 0.5rem each side of its wrapper); both layers now give 2px a side, taking the measured one-row budget from 342px to 320px of 375px](https://github.com/wekan/wekan/commit/76516a1a2). Finally, the avatar still dropped to a second line with space left beside the bell, because a width-based rule forced flex-wrap: wrap !important on that bar — added deliberately, "so ~7 items fit in two wrapped rows". An !important wrap beats any nowrap, so none of the one-row work took effect at small width, and a wrapping flex row breaks BEFORE the last item rather than shrinking anything. All eight flex-wrap rules on the bar are now nowrap, and the username/full name beside the avatar is hidden at small width too (the template hides it unless isMiniScreen, but that also weighs mobile mode and the device, so it can be true while the bar is narrow, and a full name is easily 100px+ of a 375px bar). Measured budget: 328px of 375px](https://github.com/wekan/wekan/commit/fff495127)

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/c229285b5">Board tiles and the "+ Add Board" / "+ Add Template Container" tile are now the same, shorter…</a> Thanks to xet7.</summary>

Board tiles and the "+ Add Board" / "+ Add Template Container" tile are now the same, shorter height on mobile. A board tile was 8rem (128px) on a phone while the add tile had no mobile height at all — it fell back to the desktop min-height: 72px plus 42px of padding and then grew further around its wrapped label — so the two were never the same height and both wasted vertical space. Both are now a flat 4rem (64px) with box-sizing: border-box, so the number is the rendered height rather than a floor that padding is added to, and the board tile's 24px/18px vertical padding (what made it tall) is cut to 6px. One rule covers every All Boards tab — Starred, Templates, Remaining and Workspaces all render the same .board-list tiles Only the add tile actually shrank at first, leaving a board icon towering over "+ Add Board" on the Remaining tab: two selectors set that height and they are not equally specific — .board-list.mobile-view .board-list-item (0,3,0) beat .board-list .board-list-item (0,2,0), while the add tile escaped because its own selector is also (0,3,0) and came later. Both .mobile-view shapes are now spelled out, and because that 8rem rule sits outside any media query — .mobile-view comes from Utils.isMiniScreen(), which weighs mobile mode and the device as well as width — the override is repeated outside the media query too](https://github.com/wekan/wekan/commit/000a29129). The tiles STILL differed for a reason unrelated to either height: the mobile board list is a grid with a fixed height and no align-content, so it defaulted to stretch and its single row filled the whole column — "+ Add Board" showed a 4rem grey label at the top of a stretched cell, while a board's li carries the board colour itself and painted the entire cell. align-content: start plus align-items: start keeps rows at their content height, and the list still scrolls because that comes from the fixed height plus overflow-y: auto. The two mobile columns are also rebalanced from minmax(84px, 30%) to minmax(140px, 42%): at 30% of a 375px phone the left menu was ~112px so "Workspaces" and the counts wrapped, while the board column had more width than two 4rem tiles need — it is now ~158px of menu and ~209px of boards, still fitting two ~96px tiles side by side](https://github.com/wekan/wekan/commit/534b80f92)

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/1ad93ef67">All Boards on a phone now matches what a wide screen already does: board titles read left to right…</a> Thanks to xet7.</summary>

All Boards on a phone now matches what a wide screen already does: board titles read left to right instead of breaking one letter per line, "+ Add Board" spans both columns, the folder name and the search box share one row, and the search placeholder is gone. The titles broke because space for the single ~26px absolutely-positioned drag handle was reserved THREE times on the way down — 32px on the li, 30px in the tile padding and 30px again on .details — which on a ~96px tile left about nothing for text; it is now reserved once, on the innermost container, leaving ~52px. The header wrapped because the search box would not shrink (a flex item's default min-width: auto refuses to go below its content width), so min-width: 0 plus flex: 1 1 auto lets it take what is left beside the title. The "Search boards" placeholder filled the whole box on a phone and is replaced by an aria-label, which is what a placeholder should never have been standing in for "+ Add Board" and a board tile are then made identical in size: the add tile is one grid cell again rather than spanning both columns, and — since .js-add-board carries margin: 8px !important while .js-board carries margin: 8px — both are zeroed at small width rather than only one, which would have made the add tile the odd one out instead. The grid's own 8px gap already separates the cells, so every tile is one 96×64px cell at 375px, add and board alike](https://github.com/wekan/wekan/commit/8fd2982ec). The menu title also goes ABOVE the search box on a phone rather than beside it: squeezed onto one row, "Remaining" was ellipsised to "R.." and the search still had barely any width. They stack again, but without the tall empty band that made stacking look wrong before — each part takes exactly one full-width row with a fixed 6px row-gap, so the search sits directly under the title](https://github.com/wekan/wekan/commit/9d0908e4b)

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/66d8709fa">"Show desktop drag handles" now works on the All Boards page too</a>. Thanks to xet7.</summary>

"Show desktop drag handles" now works on the All Boards page too. It governed swimlanes, lists and cards on a board, but board TILES always showed their handle and were always draggable from anywhere, so turning handles off did nothing there. The handle markup is now behind the same helper in both places it appears, and — the half that matters — so is the drag source: with handles ON the handle is the only place a board drag may start, so a finger dragging across the rest of the tile scrolls the list instead of picking the board up, the same contract cards and lists follow. Boards use HTML5 drag-and-drop rather than jQuery sortable, so this could not be done by swapping a handle option: the li keeps draggable="true" (a drag can only start from a draggable element, and moving the attribute onto the handle would drag the handle rather than the board) and dragstart cancels the drag unless it began inside .board-handle. With no handle rendered, the title reclaims the 28px reserved for it

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/c5f40aa58">Adding a workspace now shows a real input field</a>. Thanks to xet7.</summary>

Adding a workspace now shows a real input field. The "+" beside Workspaces called window.prompt(), which a browser is free to refuse — iOS Safari offers "don't allow further prompts" for a page and then suppresses every later one, so the button did nothing at all and no input field ever appeared. It now opens a normal WeKan popup with a text input and a submit button, the same pattern every other create flow uses; submitting it empty keeps the popup open and refocuses the field rather than closing silently, which is what a cancelled prompt looked like. This was the only window.prompt() left in the client

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/33d95e4eb">Admin Panel tables fit the screen and wrap their text on a phone</a>. Thanks to xet7.</summary>

Admin Panel tables fit the screen and wrap their text on a phone. Admin Panel / Version needed a horizontal scroll to read a value that easily fits on screen — "WeKan Version" at the left edge and its version number about a thousand pixels away — because every admin table is forced to min-width: 1200px !important with white-space: nowrap cells, to guarantee a scrollbar for the wide data tables. Reasonable for those, but a two-column table is pushed to 1200px for nothing and nowrap cells can never use a second line. At small width the table is now at most as wide as the screen and cells may use as many lines as they need, with long unbroken values (version strings, paths, URLs) breaking rather than pushing it wide again; the label column keeps a 45% share so it does not collapse beside a long value. Horizontal scrolling still works where a table genuinely cannot fit

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/1d022d3e5">Search All Boards on a phone now fits the width, scrolls down, and drags with a finger</a>. Thanks to xet7.</summary>

Search All Boards on a phone now fits the width, scrolls down, and drags with a finger. Label Colors ran off the right edge because .global-search-page is width: 40%; min-width: 400px — sized for a desktop column — so on a 375px phone the block was already wider than the screen and the palette's flex-wrap: wrap had nothing to wrap into. The page could not be scrolled down AT ALL because .wrapper, the GLOBAL layout element, is given height: 100vh; overflow: hidden by the All Boards stylesheet at this width: fine for the boards page, which has its own scroll container, but it applies to every page and this one had none, so everything below the fold was unreachable. And dragscroll did nothing because dragscrollTouch.js scrolls the nearest .dragscroll ancestor that can actually scroll, and no element here carried the class. The help panel and the results list are now width-constrained, bounded scroll containers carrying .dragscroll; the query form shares the class but is a short fixed header, so it is explicitly excluded from becoming a scroll box of its own Giving each panel its own scroll box turned out not to work — the panels start BELOW the search form, so their bottoms and their scrollbars ended up past the bottom of the clipped wrapper, leaving text cut mid-character after "Notes" on iPhone and the list ending at "Differing operators … red label." on Fairphone 4. The page now has exactly ONE scroll container: its wrapper is tagged global-search-wrapper and is height: auto; min-height: 100vh; overflow-y: auto at phone widths, with the panels back to plain content. .dragscroll moved to that wrapper and was REMOVED from the panels, which is required rather than tidying — dragscrollTouch.js takes the nearest .dragscroll ancestor and gives up if it cannot scroll, so leaving the class on a now-static panel would have disabled finger dragging on the whole page](https://github.com/wekan/wekan/commit/be07356cc). That single-inner-scroller still fought the ancestor: the wrapper box (min-height: 100vh) is taller than its parent #content, which is itself a scroller in mobile mode, so both scrolled — on iOS Safari the page rubber-banded back to "Differing operators" at the bottom, while Fairphone Firefox merely reached the end. The clip is now released WITHOUT adding any scroller (.wrapper for this page is height: auto; overflow: visible), leaving #content — the page's existing scroller, and what a finger scrolls natively on touch — as the single scroller, which cannot fight itself. The .dragscroll class is dropped from the wrapper since native scrolling now handles it. A last touch clears iOS Safari's bottom toolbar, which was still covering the final line: the scroller #content is calc(100vh - 48px) and on iOS 100vh is the large viewport (toolbar hidden), so its bottom edge sits behind the toolbar — the wrapper now has calc(120px + env(safe-area-inset-bottom, 0px)) of trailing space so the last line scrolls clear of it. The bottom stayed unreachable through several attempts because the cause was #content, not the wrapper: #content is a flex child, and a flex child defaults to min-height: auto — it grows to fit its content and never scrolls, so anything past the fold overflowed out of reach. On this page (min-height: 0 on #content, scoped with body:has(.wrapper.global-search-wrapper) so no other page is affected) it becomes a real scroller, inside a 100dvh body so the bottom tracks Safari's toolbar instead of hiding behind the large-viewport 100vh, with overscroll-behavior: contain to stop the landscape pull-to-refresh. A finger drag over the grey content area still did nothing while the header bars scrolled — because the header sits outside #content, that proved the body is the real scroller and #content's own overflow-y: auto was trapping the touch; #content is now a pass-through (overflow: visible; flex: 0 0 auto) so the body is the single scroller, draggable by a finger anywhere](https://github.com/wekan/wekan/commit/762547b92)

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/6580c445c">All Boards in iPhone landscape: the left menu no longer balloons and the folder header no longer…</a> Thanks to xet7.</summary>

All Boards in iPhone landscape: the left menu no longer balloons and the folder header no longer collides. Landscape is ~844px CSS-wide — above the 800px breakpoint but still an iPhone, so the device-width media query applies while the max-width: 800px rules do not. That split made the left menu 42% of 844px (~354px), flinging each count chip ~250px from its label, and left the folder header ("Remaining" + multi-selection buttons + search) on a single row where the buttons overlapped the title. The menu is now minmax(140px, min(42%, 210px)) (portrait unchanged at 158px, landscape capped at 210px) with the count packed next to its label, and the header's stacking media query carries the same device-width branch as the grid so it stacks in landscape too

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/b1435dce7">Board tiles no longer merge on a laptop</a>. Thanks to xet7.</summary>

Board tiles no longer merge on a laptop. The phone equal-tile work had six rule groups whose selector lists carried a bare .board-list ... variant next to the intended .board-list.mobile-view ... one, so they also hit the desktop float layout where .mobile-view is absent — margin: 0 !important on .board-list li.js-board killed the 8px gap that .board-list li { width: 20%; margin: 8px } relies on, so adjacent coloured tiles touched and read as one block. All six are now .mobile-view-only, restoring the desktop layout while leaving the phone layout unchanged

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/940437865">The top-bar zoom %, notification bell and user/avatar menu now stay at the END of the quick-access…</a> Thanks to xet7.</summary>

The top-bar zoom %, notification bell and user/avatar menu now stay at the END of the quick-access bar — the right edge in LTR, the left edge in RTL. They are the last items in the bar and the middle starred-boards list normally pushes them there with flex: 1, but that list is display: none on a phone, so with nothing filling the middle the group bunched at the start with empty bar beside it. A single margin-inline-start: auto (logical, so it flips for RTL automatically) on the first item of the group pushes the whole group to the end, with the row's other auto margin cleared so they do not split apart

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/04e658b03">Board tiles on All Boards are now a responsive grid, so widening the window adds more boards per…</a> Thanks to xet7.</summary>

Board tiles on All Boards are now a responsive grid, so widening the window adds more boards per row instead of stretching the tiles. Both layouts were fixed-column — desktop float: width 20% (5, wrapping to 4) and the mobile grid repeat(2, 1fr) (always 2) — so more width just made the tiles wider. They are now repeat(auto-fill, minmax(…, 1fr)): desktop minmax(200px, 1fr) (~3 columns at 1000px up to ~11 at 2500px) and mobile-view minmax(min(46%, 200px), 1fr) (keeps ~2 columns on a narrow phone but scales up on a wider one), with the 8px grid gap replacing the per-tile margins. Phones still pin two columns via the small-width media queries

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/ab1b2dc4e">All Boards drag-and-drop and touch targets</a>. Thanks to xet7.</summary>

All Boards drag-and-drop and touch targets. A mouse drag can reorder boards again: the board list <ul> is a .dragscroll element so the mouse dragscroll library was panning it instead of starting the tile's HTML5 drag, and a.js-open-board being a link meant grabbing the title dragged the URL — the tile is now .nodragscroll and the link draggable="false". With drag handles ON, dragging the handle did nothing because the gate checked dragstart.target, which in Chrome/Firefox is the whole draggable li, not the handle; the press location is now recorded on mousedown and read in dragstart. And "+ Add workspace" is now a 44px touch target instead of a ~20px "+" a finger kept missing. Reordering by FINGER did not work yet at this point — board reorder uses HTML5 drag-and-drop, which browsers do not fire from touch; that is fixed by the touch drag-and-drop bridge in a later entry below (a separate, tested change, without discarding any of the mouse fixes here)

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/e571420c9">All Boards drag-reorder now persists and can move a board to the end, and the header is decluttered</a>. Thanks to xet7.</summary>

All Boards drag-reorder now persists and can move a board to the end, and the header is decluttered. Dropping one board onto another threw INVALID [400] because profile.boardSortIndex (written on reorder) had no schema entry while the user profile enumerates its subfields — a schema field is added. Dropping on a board can only place it BEFORE that board, so a dashed "drop here to move to the end" placeholder follows the drag: while a board is dragged, an empty gap opens up before or after the tile under the cursor — anywhere in the current view (Starred / Templates / Remaining / a workspace), the end included — showing where it will land, backed by a pure computeReorderedIds(..., after) that inserts on either side and no-ops a drop into the board's own slot; the drop reads the gap's real DOM position (so releasing OVER the gap works — the placeholder is not a board tile, which had made the board snap back to its original position). Hovering anywhere on a board icon opens the gap at THAT icon's slot (drop to take its place), rather than needing to aim the icon's left half; only the last icon's trailing half inserts after, to keep the end reachable. The dragged icon is taken out of the grid flow while dragging (the other icons reflow as if it were gone), so the gap lands exactly where the icon will go rather than one slot to the left, and the drag ghost is centred on the cursor (setDragImage) so the gap opens for the icon the ghost is over rather than needing to aim the target's left edge. And the current folder's NAME beside its icon in the header ("Starred"/"Remaining"/a workspace name) is hidden — redundant with the icon, the highlighted menu row and the "My Boards" title, and it crowded the Multi-Selection / Sort / search controls — kept for screen readers only. The section icon left of the Multi-Selection button is now the same Font Awesome glyph and colour as the matching left-menu item (fa-star / fa-clipboard / fa-folder / fa-folder-open, #4d4d4d) instead of a Unicode emoji

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/1c2e1e204">Board icons can now be reordered by FINGER, not just with a mouse — and the same fix makes every…</a> Thanks to xet7.</summary>

Board icons can now be reordered by FINGER, not just with a mouse — and the same fix makes every other HTML5 drag-and-drop in WeKan work from touch. HTML5 drag-and-drop (draggable="true" + dragstart/dragover/drop) is never fired from touch by any browser, so on a touchscreen the board-icon reorder above, the workspace drag-to-share and the card/rule drags could only be driven with a mouse. Rather than rewrite reorder onto jQuery-UI-sortable (which would have discarded every mouse fix in the two entries above), a small bridge, client/lib/dragDropTouch.js, makes a finger drive the SAME handlers: a long press (250 ms) on a draggable element starts a synthetic drag that dispatches real, bubbling dragstart/dragenter/dragover/dragleave/drop/dragend events with a dataTransfer shim, so the delegated handlers run unchanged. It is deliberately safe beside the existing touch scrolling — a quick swipe on a draggable tile still scrolls, and it only calls preventDefault once a drag is actually under way; it fires a mousedown on the pressed element first so the reorder's handle gate (which records the press location) works from touch too; it honours setDragImage so the ghost is centred on the finger as it is on the cursor; and it leaves form fields and jQuery-UI sortables (lists / swimlanes / cards, already covered by touch-punch) alone. Loaded globally, with source-guard tests pinning the scroll-coexistence and handle-gate properties

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/79c5c278e">All Boards header on a phone: the section icon and the Multi-Selection / Sort buttons now share ONE…</a> Thanks to xet7.</summary>

All Boards header on a phone: the section icon and the Multi-Selection / Sort buttons now share ONE row with the Search box on its own row below, instead of the section icon sitting alone on a wasted first line; the blue Sort button always shows a sort icon (the plain fa-sort glyph, as a direct i.fa child like the Multi-Selection button beside it, rather than the wrapped fa-sort-alpha-asc that showed as a blank blue square); and on the narrow left menu each workspace NAME keeps a small floor of width so at least the first few characters stay visible (with the trailing menu button and count shrinking first), so a workspace can be told apart while it is dragged — an unnamed workspace still shows just its icon

</details>

and removes the following dead code:

<details> <summary><a href="https://github.com/wekan/wekan/commit/a440d44ea">Removed the cron migration subsystem: it never ran and had no UI, yet every logged-in client paid…</a> Thanks to xet7.</summary>

Removed the cron migration subsystem: it never ran and had no UI, yet every logged-in client paid for it. initializeCronJobs() was NEVER called, so its SyncedCron.add() registrations and the 5-second job-queue processor never started; the board-migration detector sat behind a commented-out startup hook noting "Automatic migration detector is disabled — migrations only run when opening boards"; and the Admin Panel had no Cron page at all — no route, no menu button, no template ever included, and every helper and event for it commented out. Despite that, imports/cronMigrationClient.js WAS imported by client/imports.js, so every client subscribed to three publications and polled cron.getMigrationProgress every 10 seconds forever for a feature with no user interface. Migrations already run and are already reported through the paths that work: Admin Panel → Attachments and the on-board-open repair, both driven by the shared migrationProgress overlay mounted app-wide, plus the in-progress list on Admin Panel → Problems → Summary. Removed the templates and styles, the client module, the cronJobStatus model, the cron migration manager (28 admin-gated cron.* methods no UI called), the job storage, the board-migration detector and the three publications that fed only the deleted client module. server/cron/syncedCron.js is KEPT — it is SyncedCron itself, used by the backup schedule and scheduled rules. The Problems in-progress list no longer reads cronJobStatus, because a leftover status:'running' doc from an older version would now have nothing to clear it and would report a migration running forever

</details>

and has the following developer-tooling changes:

<details> <summary><a href="https://github.com/wekan/wekan/commit/a3dfc2b19">build.sh Dev server menu can now run on a custom port with a custom ROOT_URL host</a>. Thanks to xet7.</summary>

build.sh Dev server menu can now run on a custom port with a custom ROOT_URL host. It could only offer localhost:3000, the current IP, or a custom IP:PORT, so running on a different local port with a different ROOT_URL host meant editing the script. ROOT_URL is not cosmetic — Meteor builds absolute URLs from it (e-mail links, OAuth redirects, attachment URLs) — so a subdomain setup has to be told about it. The host answer takes either form: a bare label becomes <label>.localhost (browsers and systemd-resolved resolve *.localhost to 127.0.0.1 with no /etc/hosts entry), and anything containing a dot is used as-is with a note that it must resolve locally. A a bad port falls back to 3000, the chosen port is freed first like the other dev options, and WEKAN_DEV_PORT / WEKAN_DEV_ROOT_URL skip the prompts so it can be scripted. The listen port and ROOT_URL are treated as the different things they are: the port is appended ONLY when the dev server is browsed directly, so https://wekan.example.com stays exactly that instead of becoming http://wekan.example.com:4000 — behind a Caddy proxy the public URL has no port, is https, and nothing listens on that host:port at all

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/2f3c2d49a">releases/release-all.sh now repoints stale CHANGELOG commit links before releasing</a>. Thanks to xet7.</summary>

releases/release-all.sh now repoints stale CHANGELOG commit links before releasing. Every bullet links the commit it describes, and those links are written BEFORE the release, so anything that rewrites history in between — a rebase onto an upstream change, an amend, a squash — changes the hashes and every link in the not-yet-released section 404s once pushed (which is exactly what happened to all eight links of this section). As its first local step, before the Upcoming heading is renamed and before the release commit is made, the script now leaves alone any hash that is still an ancestor of HEAD, and resolves a stale one by reading the OLD commit's subject (the pre-rewrite object is still in the clone) and finding the commit on this branch with exactly that subject — the rewritten copy of the same commit. The substitution is bounded to the section being released, so the same hash quoted in an older, already-pushed entry is never touched; it works whether or not the heading has already been renamed; and anything it cannot resolve is reported as a WARNING and left as-is, because a broken doc link is never a reason to abort a release

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/f793c4bac">Added docs/Security/gh/pull-security-reports.sh, which downloads every security and code-quality…</a> Thanks to xet7.</summary>

Added docs/Security/gh/pull-security-reports.sh, which downloads every security and code-quality report GitHub exposes for a repo — open AND closed — into a timestamped run directory with one subdirectory per category of the GitHub Security tab (CodeQuality/StandardFindings, CodeQuality/AIFindings, CodeScanning, Dependabot/Vulnerabilities, Dependabot/Malware, SecretScanning, Advisories, DependencyGraph), each split into open/ and closed/, as raw JSON plus readable per-rule / per-file / per-severity text summaries. Each category is fetched ONCE with no state filter and split locally, so open + closed are a partition of one snapshot rather than two requests taken at different moments, and a failing endpoint is recorded and skipped instead of aborting the run. The live credential the secret-scanning API returns is deleted before anything is written, and the reports directory is gitignored because these files describe unfixed vulnerabilities. Needed because the VSCode Flatpak sandbox has neither gh nor network access to api.github.com

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/aa356d715">Five mocha suites that sat in the tree looking like they ran, but were not, are now actually run…</a> Thanks to xet7.</summary>

Five mocha suites that sat in the tree looking like they ran, but were not, are now actually run, and a guard keeps it that way. meteor.testModule points at the two lib/tests/index.js files, which list their suites with explicit imports, and five suites were never added to those lists (server/lib/tests had 49 suite files but 48 imports). Two are *bleed security regression suites — checklistbleed (cross-board checklist / checklist-item move denial) and proxybleed (header-login trusted-source and request-IP handling) — exactly the kind of test whose silent absence lets a fixed vulnerability come back unnoticed; the other three are filenameTruncation, alwaysShowCodeAsText and renderLinksPlainText. All five are now imported (every symbol they use was checked to still exist first), and tests/testsAreRegistered.test.cjs now fails if any *.tests.js in either directory is not imported by its index — naming the two *bleed suites explicitly so a future tidy-up cannot drop them again quietly. This also deletes three files nothing loads: client/components/main/responsiveFixes.css (imported by nothing, so never bundled — meteor.mainModule disables eager loading) and the empty models/backgrounds.js / models/backgrounds.server.js / server/permissions/backgrounds.js tombstones, left over from board backgrounds becoming board-level Attachments; the live server/boardBackgrounds.js and server/publications/backgrounds.js are different files and are untouched

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/8f6f4a126">Pushing the English source to Transifex works again. tx push -s failed with parse_error: Duplicate…</a> Thanks to xet7.</summary>

Pushing the English source to Transifex works again. tx push -s failed with parse_error: Duplicate string key because en.i18n.json had two cpu-cores keys — the existing "CPU Cores" (already on Transifex with human translations) and a second "cores" added by the CPU-usage feature. A JavaScript JSON parser silently keeps the last, so the app never noticed, but Transifex rejects duplicates — and the collision also made cpu-cores resolve to "cores" everywhere. The CPU-usage string needs a different value, so it moves to a new unique key cpu-cores-suffix; the pre-existing cpu-cores is left untouched, matching every other language file, so no human translation is disturbed

</details>

Thanks to above GitHub users for their contributions and translators for their translations.

v10.33 2026-07-23 WeKan ® release

This release adds the following updates:

and fixes the following bugs:

<details> <summary><a href="https://github.com/wekan/wekan/commit/99e04ec98">List header card count is now scoped to the swimlane it is shown in, so a shared list no longer…</a> Thanks to xet7.</summary>

List header card count is now scoped to the swimlane it is shown in, so a shared list no longer shows the whole-list count over an empty second swimlane (the "5 Cards" header above a swimlane with no cards). A shared list — one whose swimlaneId is empty, the pre-per-swimlane-lists layout — renders under every swimlane, but cardsCount() counted by the list's OWN swimlaneId (''), giving the whole-list total in every swimlane while the cards rendered below were correctly swimlane-scoped. It now takes the container swimlane id (the same ../../_id the card body already uses) and counts that swimlane's cards, matching what is rendered; list.cards() and the lazy (#6480) server count both already scope by that id, including first-swimlane shared/orphaned surfacing

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/98b160c36">Re-attach mouse drag-scrolling (dragscroll) when a board's swimlanes/lists render or change, so…</a> Thanks to xet7.</summary>

Re-attach mouse drag-scrolling (dragscroll) when a board's swimlanes/lists render or change, so lower swimlanes stay reachable by dragging. @wekanteam/dragscroll attaches its mouse handlers per element in reset(), but the board's reset autorun only depended on the touch/permission reads — not the swimlanes/lists — so when they rendered AFTER onRendered (adaptive lazy card loading, the on-open data-repair adding a default swimlane, or a board switch that reuses the template instance without re-firing onRendered) the new .dragscroll containers never got mouse handlers: dragging no longer scrolled and, with the vertical scrollbar hidden, only the top swimlane was reachable (touch still worked via the document-delegated dragscrollTouch). The autorun now reads the board view + swimlanes + lists, so reset() re-runs and re-attaches when the rendered containers change

</details>

Thanks to above GitHub users for their contributions and translators for their translations.

v10.32 2026-07-23 WeKan ® release

This release fixes the following bugs:

<details> <summary><a href="https://github.com/wekan/wekan/commit/79410e73d067723a23d6f5b58f0ad34bdd509db4">Fix #6512 for OAuth / OIDC logins: after "Login with Google" (or any redirect-style social login)…</a> Thanks to xet7.</summary>

Fix #6512 for OAuth / OIDC logins: after "Login with Google" (or any redirect-style social login) the user is now sent to All Boards instead of being left on the sign-in route showing only the language selector until a manual reload. The earlier #6512 fix covered only the password/register flow (onSubmitHook); OAuth logins do not go through it, so a client Accounts.onLogin handler (fires for every login method after Meteor.userId() is set) now navigates home when still on an auth route — guarded so an auto-login on a deep-linked board URL is not redirected away

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/d6908cf4183eecbe8a8a0a54ebbdc660e3c83e54">Fix #6511 and #6514 (and the v10.30–10.31 Docker / Sandstorm / production "board maintenance…</a> Thanks to mueschel, jullbo, akshat-goel, AmigaAbattoir and xet7.</summary>

[Fix #6511 and #6514 (and the v10.30–10.31 Docker / Sandstorm / production "board maintenance spinner", missing top user/settings bar, and login-form-not-rendering reports): the whole client broke with Uncaught Error: ES Modules may not assign module.exports or exports.* followed by Error: No such template: swimlane / notifications / boardButtons. imports/collectionHelpers.js — a side-effect shim imported FIRST in client/main.js — ended with module.exports = {} while referencing the Meteor pseudo-global Package bare; the client rspack build's ProvidePlugin rewrites Package into an injected ESM import, marking the file an ES module, and an ES module that assigns module.exports throws at evaluation time. That threw before any template registered, so Blaze reported "No such template" for the board and header templates, the board stayed on the spinner, the top bar was missing and the board went blank grey after login. It reproduced on the official root-domain boards.wekan.team and on plain Docker with no reverse proxy (ROOT_URL=http://neptun:4001), so it was a global build bug, not a reverse-proxy / sub-path issue. collectionHelpers (the first such file) was fixed to use export {}, and then a transitive walk from client/main.js found and converted EVERY remaining client-reachable CommonJS helper (~55 files under models/lib, client/lib, imports/lib, imports/) from module.exports = { … } to export { … }, so nothing in the client bundle assigns module.exports any more — done in two parts Server-only helpers (the server/lib files Meteor never ships to the client, and the fs-using file-storage helpers) stay CommonJS. Their plain-Node .cjs unit tests now use await import(…js); the full tests/*.test.cjs suite has no new failures. This is NOT an rspack version regression — the lockfile has pinned @rspack/core ~1.7.x since v8.40, unchanged between the working v10.17 and the broken v10.27

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/b9873564a8a1755cfe412543d11a9ce2f03f4faa">Removed the unused jam:offline / jam:method / jam:pub-sub packages</a>. Thanks to xet7.</summary>

Removed the unused jam:offline / jam:method / jam:pub-sub packages. Once the client booted (after the ES-module fix above), jam:offline's startup sync threw can't access property "remove", localCollections[name] is undefined — it tried to reconcile a collection the client never registered, because WeKan uses no jam:offline .keep() and no jam:method / jam:pub-sub APIs anywhere. These were already removed once for the same reason and had been re-added; the offline-warning UI uses Meteor.status(), so it is unaffected

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/52325228f8a1fa0b91aa9bc2d0de8f3fd05a8917">Fixed six pre-existing plain-Node unit-test failures that surfaced while verifying the ES-module…</a> Thanks to xet7.</summary>

Fixed six pre-existing plain-Node unit-test failures that surfaced while verifying the ES-module conversion: five were stale source-guards (a slice window too small, a regex matching a pattern quoted in a comment, a moved template button, a widened valid-width range, a report-controls count that grew) and one was a real cleanup race — streamHeaderToTemp unlinked its partial temp file fire-and-forget while the write stream was still opening, so a caller could see the file after the rejection; it now removes the file only after the stream closes

</details>

and updates the following dependencies:

<details> <summary><a href="https://github.com/wekan/wekan/commit/eca452d6745e48c8d18917cf0d49fa2fa761c1dd">Pinned @rspack/core, @rspack/cli and @meteorjs/rspack to exact versions (removing the ^ caret…</a> Thanks to xet7.</summary>

Pinned @rspack/core, @rspack/cli and @meteorjs/rspack to exact versions (removing the ^ caret ranges) so a fresh npm install cannot silently drift the bundler to a newer release that changes CommonJS/ES-module handling and breaks the client build

</details>

and has the following developer-tooling fix:

<details> <summary><a href="https://github.com/wekan/wekan/commit/d61eba49f1fc3fb992a0cc0e9860e983fbdfd833">build.sh can now stop a running dev server on a minimal Linux that has neither fuser (psmisc) nor…</a> Thanks to xet7.</summary>

build.sh can now stop a running dev server on a minimal Linux that has neither fuser (psmisc) nor lsof installed: free_tcp_port gained an ss fallback that parses the owning pid from ss -ltnpH "sport = :PORT" and kills it. Previously it did nothing on such a host — while port_in_use (which uses ss) kept reporting the port busy — so the menu failed with "Port 3000, 8080 or 3001 is still in use … Stop it manually and retry" and the dev server could not restart

</details>

and improves the translation workflow:

<details> <summary><a href="https://github.com/wekan/wekan/commit/4146c5374">Fix #6494 (Transifex human translations were overwritten and the fetch was omitted): removed the…</a> Thanks to s2ldan and xet7.</summary>

Fix #6494 (Transifex human translations were overwritten and the fetch was omitted): removed the external machine-translation service (machine-translate.mjs, which called LibreTranslate/DeepL and needed a WEKAN_MT_URL / WEKAN_MT_API_KEY). Remaining strings that are untranslated everywhere are now translated DIRECTLY (no external service, key or password), using each language's existing translations and general kanban terminology as the reference, and applied with a new fill-translations.mjs that writes ONLY into English-placeholder keys — so a fill can never overwrite a human translation. In the releases/translations/ scripts a HUMAN translation is ALWAYS PREFERRED and merged: the pull always fetches the newest Transifex translations first, merge-translations.mjs keeps a real Transifex translation and restores a committed human translation the pull reverted to English (leaving English only where a string is untranslated everywhere), and filled strings stay LOCAL — they are never pushed to Transifex as if human. This restores the two-way process; verify-human-preference.mjs proves both directions with 10 cases

</details>

Thanks to above GitHub users for their contributions and translators for their translations.

v10.31 2026-07-23 WeKan ® release

This release fixes the following bugs:

<details> <summary><a href="https://github.com/wekan/wekan/commit/3c6d2b54e388f1c7d455f1f16d15bdd6369d7355">Fix #6512: after a successful login or register, go straight to the All Boards page instead of…</a> Thanks to akshat-goel and xet7.</summary>

Fix #6512: after a successful login or register, go straight to the All Boards page instead of landing back on the login layout that shows only the language selector ("Vaihda kieltä") until a manual reload. onSubmitHook navigated home immediately, but the home route's sign-in guard checks Meteor.userId() non-reactively and bounced back to sign-in while the userId was still propagating (a tick after login); the same guard also stranded a returning user during auto-login from a stored token. onSubmitHook now waits reactively for Meteor.userId() before navigating (with a timeout fallback), and the All Boards guard no longer bounces while a login is in progress (Meteor.loggingIn())

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/337035eb4c09f2ce410b2bb28f1af88738928d01">Fix #6515: the startup / board-open board repair no longer unbinds per-swimlane lists…</a> Thanks to jullbo and xet7.</summary>

Fix #6515: the startup / board-open board repair no longer unbinds per-swimlane lists. repairBoardsOnStartup ran the shared repair over every board and cleared swimlaneId to null on EVERY list that had one — treating any swimlane-bound list as #6484 corruption. But a list with a swimlaneId is a legitimate PER-SWIMLANE list (rendered only in its own swimlane), indistinguishable from a #6484-corrupted board-wide list at the data level, so on upgrade to v10.26+ the routine silently unbound every per-swimlane list (listsUnbound: 24 in the reporter's log) and all lists then rendered in every swimlane; board open re-ran the repair, so even a manual restore was nulled again. The automatic repair no longer clears any list's swimlaneId (the safe card repairs stay); the #6484 code bug is already fixed, and an admin can still deliberately un-bind a specific board. Affected boards keep their bindings now, and a restore from a pre-upgrade backup will stick

</details>

Thanks to above GitHub users for their contributions and translators for their translations.

v10.30 2026-07-23 WeKan ® release

This release updates the following dependencies:

<details> <summary>The bundled FerretDB v1 fork now creates the OpLog ts index on the PostgreSQL, MySQL and SAP HANA… Thanks to xet7.</summary>

The bundled FerretDB v1 fork now creates the OpLog ts index on the PostgreSQL, MySQL and SAP HANA backends too (previously only SQLite), so an idle Meteor OpLog tail resumes with an index range scan instead of re-scanning the whole capped collection on every poll. Best-effort per backend, with a descriptive log if a live engine rejects the index syntax (see the FerretDB fork changelog). Ships with the bundled FerretDB update.

</details>

and fixes the following bugs:

<details> <summary><a href="https://github.com/wekan/wekan/commit/50a7c8fe59c900036460ca778e222baeef7dcca1">Fixed a stale #5623 "select all cards scoped to swimlane" unit test that failed after the…</a> Thanks to xet7.</summary>

Fixed a stale #5623 "select all cards scoped to swimlane" unit test that failed after the doubled-cards render fix: shared/orphaned cards (no swimlane, or a deleted swimlane) now surface ONCE in the FIRST swimlane, so the helper returns them only when the first swimlane's other-swimlane ids are passed; the test still asserted the old behaviour. The test now covers both the first-swimlane (own + orphaned) and non-first-swimlane (own only) contracts; no runtime code change

</details>

Thanks to above GitHub users for their contributions and translators for their translations.

v10.29 2026-07-23 WeKan ® release

This release adds the following new features:

<details> <summary><a href="https://github.com/wekan/wekan/commit/2538e2434406010887bc3d827a15841b35ff1666">Snap: run the bundled FerretDB v1 against an EXTERNAL PostgreSQL / MySQL / SAP HANA server instead…</a> Thanks to xet7.</summary>

Snap: run the bundled FerretDB v1 against an EXTERNAL PostgreSQL / MySQL / SAP HANA server instead of the embedded SQLite backend. Two new snap settings — snap set wekan wekan-ferretdb-handler=postgresql|mysql|hana and wekan-ferretdb-url=… — point FerretDB at a database you run; the default stays the zero-dependency sqlite backend, so existing installs are unchanged

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/cbfe3600fc5602ebe64ffc35f77c9fe05c1d73e5">Translations: machine translation now only fills the English strings that are untranslated…</a> Thanks to xet7.</summary>

Translations: machine translation now only fills the English strings that are untranslated EVERYWHERE, so it can never overwrite a human translation. A new releases/translations/machine-translate.mjs step (run after the per-key Transifex merge, opt-in via WEKAN_MT=1 / WEKAN_MT_URL) translates exactly the leftover English placeholders — which also fully fills any language that has no translation at all — while a string a human already translated is never selected. Machine output stays local and is not pushed to Transifex

</details>

and fixes the following bugs:

<details> <summary><a href="https://github.com/wekan/wekan/commit/d69c83f949291837eb24409562ecd4b42c5d2033">snap run wekan.problems now loads the snap settings before its checks, so ROOT_URL / LDAP and the…</a> Thanks to akshat-goel and xet7.</summary>

snap run wekan.problems now loads the snap settings before its checks, so ROOT_URL / LDAP and the other login-page checks read the SAME configuration WeKan runs with. Without this the command read an empty environment and reported ROOT_URL is not set even after snap set wekan root-url=…, which was confusing when diagnosing a blank login page or missing header controls (#6512, #6514)

</details>

Thanks to above GitHub users for their contributions and translators for their translations.

v10.28 2026-07-23 WeKan ® release

This release fixes the following bugs:

<details> <summary><a href="https://github.com/wekan/wekan/commit/5433dfbc5">Fix #6508 (follow-up): the board member popup now shows "Remap User" for an imported (placeholder)…</a> Thanks to AmigaAbattoir and xet7.</summary>

Fix #6508 (follow-up): the board member popup now shows "Remap User" for an imported (placeholder) member. The action is gated on the member's authenticationMethod === 'imported', but the board publication shipped board members' user docs without authenticationMethod, so the field was undefined on the client and the action never appeared. It is now published with the board members' user fields (already part of Users.safeFields), so the in-board Remap → map-to-existing-user flow works

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/11cb33616">Fix #6511: a board showed NO cards, with Error: Bad index in range.removeMember: 0 in the console</a>. Thanks to mueschel and xet7.</summary>

Fix #6511: a board showed NO cards, with Error: Bad index in range.removeMember: 0 in the console. The card list is a Blaze {{#each}} over a LIMITED, ordered reactive cursor sorted by { sort: 1 }, which has TIES — when several cards share the same sort value (or due date, etc.) their order is non-deterministic across observe/poll cycles, so Meteor's ordered diff computes an out-of-range index and the #each throws, rendering no cards (and cascading into the undefined.remove() teardown error). A unique _id tiebreaker is now appended to the card-list sort (client cursor and server window), making the order deterministic

</details> <details> <summary>Opening a card no longer takes ~40 seconds on FerretDB v1 (SQLite). Thanks to xet7.</summary>

Opening a card no longer takes ~40 seconds on FerretDB v1 (SQLite). A card's attachments are looked up with the dotted key {'meta.cardId': ...}, which the bundled FerretDB dropped from the WHERE (it skipped any dotted-path key), so it full-scanned the whole attachments collection with a per-row decode on every poll while a card was open — slow card content and high idle CPU. The FerretDB v1 fork now pushes down a dotted-path equality/$in as the nested -> expression that matches the existing meta.cardId index (see the FerretDB fork changelog). Ships with the bundled FerretDB update.

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/959cfbb84">Activity feed pushes down on FerretDB: the comment-only feed selected activities with a top-level…</a> Thanks to xet7.</summary>

Activity feed pushes down on FerretDB: the comment-only feed selected activities with a top-level $and, which FerretDB v1 (SQLite) does not push down, so the activities collection was full-scanned on every poll (slow board/card history on a big board). The scope and activityType are now at the top level — exactly equivalent, and both push down to the index

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/0470adcf3">snap run wekan.problems: the login-page checks used affirmative titles, so a FAILING check printed…</a> Thanks to xet7.</summary>

snap run wekan.problems: the login-page checks used affirmative titles, so a FAILING check printed e.g. "[error] ROOT_URL is set: ROOT_URL is not set" — confusing. Titles are now neutral subjects ("ROOT_URL", "Migration or repair") so each line reads correctly whether the check passes or fails

</details>

Thanks to above GitHub users for their contributions and translators for their translations.

v10.27 2026-07-23 WeKan ® release

This release fixes the following bugs:

<details> <summary><a href="https://github.com/wekan/wekan/commit/2f07b2933">Fix #6508: two crashes in the import "map users later" workflow. (A) In the import "map members"…</a> Thanks to AmigaAbattoir and xet7.</summary>

Fix #6508: two crashes in the import "map users later" workflow. (A) In the import "map members" step, clicking a searched user threw Cannot read properties of undefined (reading '_id') — the handler read Template.currentData()._id, which is undefined when the click lands on a child of the row (the avatar or the name span); it now reads the user id from the row's data-id. (B) In Admin Panel / People, saving an imported (placeholder) user as an active person with an email threw Cannot read properties of undefined (reading '0') because the submit handler read user.emails[0] and an imported placeholder (and some SSO) user has no emails array; the primary email is now read defensively

</details>

Thanks to above GitHub users for their contributions and translators for their translations.

v10.26 2026-07-22 WeKan ® release

This release fixes the following bugs:

<details> <summary><a href="https://github.com/wekan/wekan/commit/72768e05f">Fix #6507: on a fresh install the Admin panel would not open, the title bar sometimes failed to…</a> Thanks to AmigaAbattoir and xet7.</summary>

Fix #6507: on a fresh install the Admin panel would not open, the title bar sometimes failed to load or showed "You are not authorized to view this page", and the browser console looped Uncaught (in promise) TypeError: Cannot read properties of undefined (reading 'remove'). ReactiveCacheClient.getCurrentSetting() / getCurrentUser() guarded their DataCache with !this.__x || !this.__x.get() — so while the Settings document did not exist yet (a fresh install) or Meteor.user() was briefly null (right after the first admin logs in), a brand-new DataCache and Tracker computation were rebuilt on EVERY reactive read, churning computations app-wide and racing Blaze's view teardown into an infinite reactive loop + removed-DomRange crash. Every other getter creates its DataCache once; these two now do the same, returning a stable value until the setting / user exists

</details>

Thanks to above GitHub users for their contributions and translators for their translations.

v10.25 2026-07-22 WeKan ® release

This release fixes the following bugs:

<details> <summary><a href="https://github.com/wekan/wekan/commit/a51afa5a1">Fix boards that loaded their lists/columns but never their CARDS on FerretDB v1 (SQLite) (10.22…</a> Thanks to xet7.</summary>

Fix boards that loaded their lists/columns but never their CARDS on FerretDB v1 (SQLite) (10.22: pages took minutes, columns appeared, cards never did). Every board card query used { boardId: { $in: [board._id, board.subtasksDefaultBoardId] } }, and subtasksDefaultBoardId defaults to null; on FerretDB a $in containing null does not push down at all, so the WHERE was dropped and the whole cards collection was full-scanned and decoded on every poll-and-diff cycle (across ~7 card cursors per board open), while lists/swimlanes used a plain boardId equality that stayed index-backed. A new models/lib/boardCardScope.js builds the scope WITHOUT null (a plain equality — an index seek — when only the board is in scope, else an all-string $in), applied to every card-scope site in server/publications/boards.js, cardsWindow.js and cards.js; the lazy card-window selector is also flattened out of a top-level $and (which FerretDB does not push down) so boardId/archived push down too. The FerretDB v1 fork is hardened separately so a mixed $in (including null) uses the index instead of full-scanning

</details>

Thanks to above GitHub users for their contributions and translators for their translations.

v10.24 2026-07-22 WeKan ® release

This release fixes the following bugs:

<details> <summary><a href="https://github.com/wekan/wekan/commit/0e0391f6c">Fix #6506: board import shows the "map members" step again, and unmapped members become virtual…</a> Thanks to AmigaAbattoir and xet7.</summary>

Fix #6506: board import shows the "map members" step again, and unmapped members become virtual users instead of the importer. A regression in v10.12 dropped the importMapMembers step from the import wizard and sent an empty member mapping, so the map-users screen never appeared and every imported card / member / comment was assigned to the importing user even when that person already existed in WeKan. The (auto-suggesting) map-members step is restored and stays OPTIONAL (a Skip button and the textarea "import without mapping" button bypass it); finishImport builds the mapping from the members the user mapped. Members left unmapped — or skipped, including the Trello API all-boards background import which has no interactive step — are now brought in as inert virtual (placeholder) users (mapped to an existing user by username where one matches), so board membership and authorship keep the original person instead of collapsing onto the importer. Covers the single-board Trello JSON import, the Trello API import, and the WeKan JSON import

</details>

Thanks to above GitHub users for their contributions and translators for their translations.

v10.23 2026-07-22 WeKan ® release

This release fixes the following CRITICAL SECURITY ISSUES:

<details> <summary><a href="https://wekan.fi/hall-of-fame/exportbleed/">ExportBleed</a>.</summary>

** ExportBleed : stored XSS in HTML board exports through a card-title second parse** (CWE-79 Cross-site Scripting; GitHub Security Advisory GHSA-8r5p-4q9j-f5jx, severity High; client/lib/exportHTML.js). A board member could store entity-encoded markup in a card title (e.g. &lt;img src=x onerror=...&gt;). It stays inert on the live board — Blaze escapes it and the +viewer sanitizer neutralizes handlers, keeping the entity payload as text — but the exported index.html embedded a card-click handler that read the card title/body via .textContent (which DECODES HTML entities) and then concatenated those values into content.innerHTML. That SECOND parse revived the tag and ran it when a recipient clicked the card in the export, disclosing all data in that document (including cards added AFTER the attacker's board membership was removed)

</details>
  • Fixed by building the modal with DOM nodes and assigning the card title/body through textContent, never innerHTML, so they are inserted as inert text (commit).
  • Thanks to koyokr (report) and xet7 (fix).
<details> <summary><a href="https://wekan.fi/hall-of-fame/splicebleed/">SpliceBleed</a>.</summary>

** SpliceBleed follow-up: incomplete multi-character sanitization re-flagged in the filename markup strip** (GitHub CodeQL code scanning alert #426, rule js/incomplete-multi-character-sanitization, CWE-116 Improper Encoding or Escaping of Output; imports/lib/fileNameDisplay.js). The SpliceBleed fix (#425) looped a CHAIN of six replaces to a fixpoint — runtime-safe, but CodeQL (a local check) could not attribute the fixpoint to the individual <[^>]*>? tag replace, so it kept flagging it ("this string may still contain <script")

</details>
  • Fixed by restructuring to the proven-complete pattern used by client/lib/importDependencies.js stripHtml() (which cleared the sibling alert #421): remove template/PI/CDATA tokens (looped), then strip HTML/XML tags with a SINGLE replace(/<[^>]*>/g, '') looped to a fixed point, then drop any stray angle bracket so even an unclosed tag (a trailing <script) cannot survive. Behaviour is unchanged, and Blaze {{ }} still HTML-escapes every rendered filename, so this stays defence-in-depth (commit).
  • Thanks to GitHub CodeQL (code scanning alert #426) and xet7 (fix).

Thanks to above GitHub users for their contributions and translators for their translations.

v10.22 2026-07-22 WeKan ® release

This release adds the following features:

<details> <summary><a href="https://github.com/wekan/wekan/commit/5a1c9a61e">Admin Panel / Problems "Status" overview + snap run wekan.problems</a>. Thanks to xet7.</summary>

Admin Panel / Problems "Status" overview + snap run wekan.problems. Problems / Summary now shows everything in progress (database text migration, board data-repair, recovery, cron migration, attachment migration), detected problems (broken cards), and a login-page checklist of the causes that make the login page show "Must be logged in" or sit on the logo + "Loading, please wait." spinner (a migration/repair running, wrong/unset ROOT_URL, LDAP, Sandstorm). The same overview is available WITHOUT the Admin Panel from the server command line — snap run wekan.problems (with migrations / login / broken-cards / cpu sub-commands) — so an admin with only server access can read it. Migration/repair progress is persisted to the database so a separate process can see it. Docs: docs/Features/Admin-Panel/Problems/

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/136a14c88">Shared board data-repairs run on board open, on server startup, AND during the MongoDB &lt;-&gt; FerretDB…</a> Thanks to xet7.</summary>

Shared board data-repairs run on board open, on server startup, AND during the MongoDB <-> FerretDB v1 (SQLite) text migration — shown in one blue, Product-name-branded progress dashboard. Beyond the #6484 list-unbind, the shared set now also fixes cards with no swimlane and orphaned cards whose swimlane was deleted (invisible cards), reassigning them to the board's first swimlane. The repairs run SERVER-SIDE and do not depend on a user watching a progress page: a version-gated background pass runs once at startup, board open repairs per board, and the database migration repairs the live data before copying so the migrated copy is clean too. The progress dashboard is mounted app-wide (board + Admin Panel), styled in WeKan blue (#2980b9), and branded with the configured Product name

</details>

and fixes the following bugs:

<details> <summary><a href="https://github.com/wekan/wekan/commit/fae1ccd17">FerretDB high CPU: run FerretDB standalone (no OpLog) by default</a>. Thanks to xet7.</summary>

FerretDB high CPU: run FerretDB standalone (no OpLog) by default. Several sites reported the ferretdb process pegging ~2 CPU cores for a long time while the WeKan node process sat idle — pages taking 5+ minutes, All Boards counts stuck at 0, raw i18n keys, and the login page showing "Must be logged in" or a stuck "Loading, please wait." spinner. That idle-node / pegged-ferretdb split is the signature of an OpLog tail: Meteor holds a tailable cursor on local.oplog.rs and FerretDB re-scans it server-side. wekan-control already defaulted to polling-only, but ferretdb-control passed --repl-set-name unconditionally, so FerretDB always maintained the capped OpLog even when WeKan would not tail it. It now runs STANDALONE in the default polling mode and enables the replica set / OpLog only when wekan-ferretdb-oplog=true. Opting into OpLog is also cheaper now on the FerretDB v1 fork side (the {ts: {$gt}} tail filter is pushed down to SQL and the OpLog Timestamp is indexed). See docs/Features/Admin-Panel/Problems/Migrations.md

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/a4460b3f6">Fix #6504: a rule that moves a card to another board failed with "newBoard.getNextCardNumber is not…</a> Thanks to ChristianMa97 and xet7.</summary>

Fix #6504: a rule that moves a card to another board failed with "newBoard.getNextCardNumber is not a function". On the server ReactiveCache.getBoard() is async; the cross-board branch of Cards.move() used it without await, so the destination board was a Promise (its methods missing) and label carry-over from the source board was silently dropped. Both getBoard() calls are now awaited (same fix as PR #6505)

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/c90d6eaa4">Swimlanes view: a shared card no longer appears in every swimlane ("doubled cards")</a>. Thanks to xet7.</summary>

Swimlanes view: a shared card no longer appears in every swimlane ("doubled cards"). A card with no swimlane (swimlaneId null / '' — a shared / pre-migration card) was surfaced in EVERY swimlane, so on a board with several swimlanes the same card rendered once per swimlane (and the extra DOM contributed to the search hang / can't-scroll on big boards). A non-first swimlane now shows only its own cards; shared and orphaned cards surface ONCE, in the first swimlane, so a card is never shown in more than one swimlane. Board-wide label filtering (#6441) is preserved

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/d35532f0f">Repair for boards where a list "disappeared" from swimlanes (the #6484 corruption)</a>. Thanks to xet7.</summary>

Repair for boards where a list "disappeared" from swimlanes (the #6484 corruption). The #6484 bug — nudging a board-wide list in swimlanes view bound it to one swimlane and moved its cards there, so it vanished from the other swimlanes — was fixed in code, but boards already hit keep the wrongly-set list.swimlaneId. A new board-admin method repairBoardWideLists(boardId) clears those lists back to board-wide (rendered under every swimlane); it is idempotent and scoped to the one board. (Cards the bug moved into one swimlane are not restored — recover those from a backup.)

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/f559b751b">Opening a board now auto-detects the #6484 corruption and repairs it</a>. Thanks to xet7.</summary>

Opening a board now auto-detects the #6484 corruption and repairs it. On board open, a cheap boardListRepairNeeded check reports whether any list is wrongly bound to a swimlane; if so and the viewer is a board admin, repairBoardWideLists runs automatically while a progress modal is shown, then completes. The check runs once per board, fire-and-forget, and never blocks rendering. The migration-progress modal is now branded with the configured Product name (Admin Panel / Settings / Layout / Product name), falling back to WeKan when none is set, so every migration reusing the modal shows the admin's Product name instead of the hard-coded brand

</details>

Thanks to above GitHub users for their contributions and translators for their translations.

v10.21 2026-07-22 WeKan ® release

This release adds the following features:

<details> <summary><a href="https://github.com/wekan/wekan/commit/191f82739">Card links keep working after the card is moved to another board</a>. Thanks to Robdebert and xet7.</summary>

Card links keep working after the card is moved to another board. A card URL is /b/:boardId/:slug/:cardId; after moving the card the link opened the old board and found nothing. The card route now resolves the card's CURRENT board via the permission-checked card publication and, if it differs, redirects there — so a link stays valid across board moves as long as the viewer has access to the destination board (like Trello). If the user cannot see the card's new board, the URL's board is kept; a redirect loop / stale navigation is guarded against (#4758)

</details>

and fixes the following bugs:

<details> <summary><a href="https://github.com/wekan/wekan/commit/27201f642">Copy-code-block button in the card viewer is now reliable</a>. Thanks to C0rn3j and xet7.</summary>

Copy-code-block button in the card viewer is now reliable. It previously ran once with a global query, so it decorated only whichever code blocks were on the page at that instant ("works sporadically"), was lost after editing, and copied the HTML-escaped inner markup (wrong content). It is now added per viewer in Template.viewer.onRendered (so every description / checklist item / comment gets it and it is restored on re-render), de-duplicated, copies the RAW code text via the async Clipboard API (with a fallback), and the button title is translated (#5149)

</details>

Thanks to above GitHub users for their contributions and translators for their translations.

v10.20 2026-07-22 WeKan ® release

This release fixes the following bug:

<details> <summary><a href="https://github.com/wekan/wekan/commit/8f5300053">Public read-only boards: list dragging is disabled for anonymous / not-logged-in users</a>. Thanks to GenericUK and xet7.</summary>

Public read-only boards: list dragging is disabled for anonymous / not-logged-in users. On a public board opened while logged out, lists could still be dragged (the reorder was denied server-side and snapped back, but the drag itself made mobile left/right scrolling almost impossible). Already fixed — every list sortable is disabled: !Utils.canModifyBoard(), and canModifyBoard() requires an active, non-read-only board member, so an anonymous user can never drag lists; this release adds a unit guard so it cannot regress (#1158)

</details>

Thanks to above GitHub users for their contributions and translators for their translations.

v10.19 2026-07-22 WeKan ® release

This release fixes the following bug:

<details> <summary><a href="https://github.com/wekan/wekan/commit/14dd48bb6">Moving a card between boards via the REST API now works (and is unit-tested)</a>. Thanks to rptl and xet7.</summary>

Moving a card between boards via the REST API now works (and is unit-tested). Editing only the raw boardId/listId left the card half-moved so opening it bounced back to the old board; the supported way is the edit-card API's newBoardId + newSwimlaneId + newListId, which the handler recognises as a full board move — it validates the destination list and swimlane belong to the destination board and re-homes the card through cardMove() (updating board, swimlane and list together). A partial set is intentionally NOT treated as a board move, so a card is never half-moved (#3037)

</details>

Thanks to above GitHub users for their contributions and translators for their translations.

v10.18 2026-07-22 WeKan ® release

This release fixes the following bugs:

<details> <summary><a href="https://github.com/wekan/wekan/commit/5234d09d1">Admin Panel: the per-org/team "Propagate Members To Boards" flag now actually propagates…</a> Thanks to xet7.</summary>

Admin Panel: the per-org/team "Propagate Members To Boards" flag now actually propagates. orgPropagateMembersToBoards / teamPropagateMembersToBoards were stored and shown in the Admin Panel, but the method that acts on them had no caller anywhere — dead code — so turning the flag on did nothing. Turning the flag on now adds that org/team's members to the boards that list it (strictly add-only; existing board members are never removed or modified; template boards are skipped), via a pure, unit-tested models/lib/propagateMembers.js (#4737, #5850)

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/16f653108">A user added to a team AFTER it was assigned to a board now gains membership on that board</a>. Thanks to mianbaoshumoon and xet7.</summary>

A user added to a team AFTER it was assigned to a board now gains membership on that board. Assigning a team to a board adds the team's current users to the board's members, but a user added to the team afterwards only got read visibility (via the publications) while every authority gate — board.hasMember(), the board permission checks, protected-attachment access, and export visibility — looks at board.members, so the late-joining team member had strictly less authority than the team's original members. Already fixed in current WeKan (addUserToTeamBoards + the pure models/lib/teamBoardMemberSync.js, called from both the admin People-panel editUser path and the create-user-with-teams path); this release adds source guards so the wiring cannot silently regress (#4593)

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/533edca7d">Add-card: pressing Tab no longer loses the typed card text</a>. Thanks to callahad and xet7.</summary>

Add-card: pressing Tab no longer loses the typed card text. The add-card form's Tab handler is a deliberate power feature — Tab jumps to the next column's add-card form so cards can be added across columns quickly — but it opened that next form while abandoning whatever was typed in the current one, so pressing Tab mid-entry silently lost the card text (the card appeared to "move to the next column" with no content). It now submits the current card first (only when the textarea has non-whitespace content, so Tab in an empty box does not create a blank card), then jumps to the next column's form — preserving both the content and the column-jump feature (#1195)

</details>

Thanks to above GitHub users for their contributions and translators for their translations.

v10.17 2026-07-22 WeKan ® release

This release fixes the following bug:

<details> <summary><a href="https://github.com/wekan/wekan/commit/c0bbacaf9">Due-date reminder mail: honour a midnight reminder hour and notify card assignees</a>. Thanks to SirConfigMgr and xet7.</summary>

Due-date reminder mail: honour a midnight reminder hour and notify card assignees. Two bugs kept due-date reminders from reaching people: the reminder hour was parsed as parseInt(NOTIFY_DUE_AT_HOUR_OF_DAY) || 8, so a configured hour of 0 (midnight) silently became 8 (0 is falsy), and a due-card activity only notified the card's creator and members — a user assigned the card but not a member was never told about it. Midnight is now honoured (and out-of-range hours rejected), and assignees are included in the notification participants (still gated by each user's board tracking level). The built-in reminder still requires NOTIFY_DUE_DAYS_BEFORE_AND_AFTER to be set (#3192)

</details>

Thanks to above GitHub users for their contributions and translators for their translations.

v10.16 2026-07-22 WeKan ® release

This release fixes the following bugs:

<details> <summary><a href="https://github.com/wekan/wekan/commit/b15d89dd0">FerretDB now defaults to polling-only on all platforms, fixing high FerretDB CPU</a>. Thanks to xet7 and the FerretDB high-CPU reporters.</summary>

FerretDB now defaults to polling-only on all platforms, fixing high FerretDB CPU. FerretDB v1 can tail an OpLog for real-time updates, but on its SQLite backend Meteor's tailable+awaitData OpLog tail keeps FerretDB CPU pinned (reporters saw ~190–390% even when idle; a 2-core VPS maxed out and WeKan got stuck on the loading spinner), and a struggling tail also shows up in the log as oplog catching up took too long, stalling board and login loading. Even with the FerretDB-side mitigations the peg persisted on real deployments, while polling-only reliably drops CPU to ~10%. So polling is now the default on every launcher (snap, bundle, Docker/compose, Windows, Sandstorm); WEKAN_FERRETDB_OPLOG defaults to false. The OpLog remains available as opt-in (WEKAN_FERRETDB_OPLOG=true, or on snap snap set wekan wekan-ferretdb-oplog=true). The calmer polling throttle and SQLite pragma tuning keep poll-and-diff cheap (#6503)

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/eb411d9a5">i18n never gets stuck showing raw translation keys if a dynamic language import fails…</a> Thanks to Alishara and xet7.</summary>

i18n never gets stuck showing raw translation keys if a dynamic language import fails. TAPi18n.init() awaited a dynamic import('./data/en.i18n.json') and only marked i18n ready afterwards, so if that lazy chunk hung or failed — e.g. a stale snap client bundle after a refresh — the whole UI showed raw keys (like changeLanguagePopup-title) forever with no recovery. The default English is now statically bundled and registered first (the UI is always readable), and the dynamic load is bounded by a timeout so readiness is reached in every path. Note: this hardens the raw-key symptom; the dead OIDC/Register buttons in that report point to a stale dynamic-import bundle cache on the snap (a hard reload / clean snap rebuild is the confirming test), not a change in WeKan's login code (which is unchanged between 10.11 and 10.13) — and the FerretDB polling-only default above also removes the oplog catching up took too long stalls that make the login page slow to become interactive (#6503)

</details>

Thanks to above GitHub users for their contributions and translators for their translations.

v10.15 2026-07-22 WeKan ® release

This release fixes the following bugs:

<details> <summary><a href="https://github.com/wekan/wekan/commit/e1ebe978b">Rules: the "remove all members" action now actually removes the card's assignees</a>. Thanks to sfahrenholz, mweiss237 and xet7.</summary>

Rules: the "remove all members" action now actually removes the card's assignees. The rule member actions act on a card's assignees (addMemberassignMember, removeMemberunassignMember), but the "remove every member" wildcard branch iterated card.members — a different collection — so it removed nobody a rule had assigned. It now iterates the card's assignees (#2674)

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/52bb15594">Cards added in List view no longer land in an archived swimlane</a>. Thanks to rangersdo, hnb1 and xet7.</summary>

Cards added in List view no longer land in an archived swimlane. When a board's first/default swimlane was archived or deleted, adding a card in List view — or dragging a card to another list — assigned it to that archived/deleted swimlane, so the card was invisible in Swimlane view (visible only in List view until the swimlane was restored). Board.getDefaultSwimline() now prefers a NON-archived swimlane via a pure, unit-tested pickDefaultSwimlane() helper (falling back to the first only when every swimlane is archived); cards already orphaned by a swimlane deletion keep surfacing in the first swimlane via the #6443 fallback (#1971, #1959)

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/2328bcd0f">Case-insensitive @ member autocomplete in the card title</a>. Thanks to rjl9 and xet7.</summary>

Case-insensitive @ member autocomplete in the card title. The add-card-title @ member mention matched with user.username.indexOf(term) === 0 — case-sensitive, prefix-only, username-only, and it threw when the user lookup returned null. So @ann did not suggest Anna, @Anna did not match username anna, and a full-name search never matched. Both the add-card and the card description/comment editor @ mentions now share a pure, unit-tested models/lib/memberAutocomplete.js that matches the term case-insensitively as a substring of the username or full name, and is null-safe (#5116 follow-up)

</details>

Thanks to above GitHub users for their contributions and translators for their translations.

v10.14 2026-07-22 WeKan ® release

This release fixes the following SECURITY ISSUES found by GitHub CodeQL code scanning:

<details> <summary><a href="https://wekan.fi/hall-of-fame/splicebleed/">SpliceBleed</a>.</summary>

** SpliceBleed : incomplete multi-character sanitization when stripping exploit markup from a filename** (GitHub CodeQL code scanning alert #425, rule js/incomplete-multi-character-sanitization, CWE-116 Improper Encoding or Escaping of Output; imports/lib/fileNameDisplay.js). stripExploitPatterns() removed HTML/script/XML/template markup from a shown filename in a single pass — so an input crafted with nested or interleaved fragments (for example <scr<x>ipt> or <scr{{y}}ipt>) could have its inner part removed and the two surviving outer fragments SPLICED together into a fresh <script> token that the single pass no longer re-examined (CodeQL: "this string may still contain <script")

</details>
  • Fixed by applying the removals REPEATEDLY until the string stops changing (a fixpoint loop). Each pass only ever deletes text, so the string strictly shrinks and the loop always terminates; any dangerous token an earlier removal reveals is then removed too. Blaze {{ }} already HTML-escapes every rendered filename, so this is defence-in-depth on the displayed text rather than a live XSS, but the incomplete single-pass strip was genuinely wrong.
  • Thanks to GitHub CodeQL (code scanning alert #425) and xet7 (fix).
<details> <summary><a href="https://wekan.fi/hall-of-fame/identitybleed/">IdentityBleed</a>.</summary>

** IdentityBleed : identity string replacement (a no-op replace)** (GitHub CodeQL code scanning alert #424, rule js/identity-replacement, CWE-116 Improper Encoding or Escaping of Output; tests/securityLog.test.cjs). A test built a menu-id regex with id.replace('report-', 'report-') — replacing a substring with itself, a no-op that CodeQL flags because it is almost always a mistake for a real transformation

</details>
  • Fixed by dropping the dead replace and matching 'js-' + id directly. Test-only code with no runtime exposure, but the no-op was removed for correctness.
  • Thanks to GitHub CodeQL (code scanning alert #424) and xet7 (fix).

and resolves the following GitHub Dependabot alerts in the rspack build-toolchain dev dependencies, without touching the rspack major version so builds are unaffected (both packages enter only via @rspack/dev-server — the rspack serve dev server — and are not used by meteor build):

<details> <summary><a href="https://github.com/wekan/wekan/security/dependabot/114">webpack-dev-server 5.2.5 → 5.2.6</a>.</summary>

webpack-dev-server 5.2.5 → 5.2.6 (npm overrides): fixes CVE-2026-14631 (DoS via a malformed Host/Origin header, alert #113) and CVE-2026-14620 (CSRF via the internal /webpack-dev-server/open-editor and /invalidate endpoints, alert #114). Patch release in the same major line

</details> <details> <summary><a href="https://github.com/wekan/wekan/security/dependabot/107">http-proxy-middleware 2.0.9 → 2.0.10</a>.</summary>

http-proxy-middleware 2.0.9 → 2.0.10 (npm overrides): fixes CVE-2026-55602 (a host+path router key matched by unanchored substring, so a crafted Host header could route to an unintended backend, alert #107). Patch release in the same major line

</details> <details> <summary>The lock resolves with only those two entries changed; @rspack/cli and @rspack/core stay at 1.7.11…</summary>

The lock resolves with only those two entries changed; @rspack/cli and @rspack/core stay at 1.7.11. elliptic (alert #55, CVE-2025-14505) is left pinned because no fixed version is published upstream (latest is still 6.6.1, dev-only, Low). The @rspack/cli 1→2 (PR #6497) and @babel/parser 7→8 (PR #6484, a runtime dependency) major bumps are deferred pending build/ runtime testing.

</details>
  • Thanks to GitHub Dependabot and xet7.

and fixes the following bug:

<details> <summary><a href="https://github.com/wekan/wekan/commit/728cd929c">Case-insensitive # label autocomplete in the card title</a>. Thanks to rjl9 and xet7.</summary>

Case-insensitive # label autocomplete in the card title. Typing #test in the add-card / edit-card-title field did not suggest a label named Testing — the label search matched with a case-sensitive label.name.indexOf(term), so only #Test matched. The match now lowercases both the typed term and the label name/color (matching the @ member autocomplete), extracted into a pure, unit-tested models/lib/labelAutocomplete.js (#5116)

</details>

Thanks to above GitHub users for their contributions and translators for their translations.

v10.13 2026-07-22 WeKan ® release

This release updates dependencies:

Thanks to above GitHub users for their contributions and translators for their translations.

v10.12 2026-07-21 WeKan ® release

This release fixes the following bugs:

<details> <summary><a href="https://github.com/wekan/wekan/commit/930da57fd">Fix: Docker fresh docker compose up showed "Connection reset by peer" and never loaded (#6500)</a>. Thanks to youhajjioui and xet7.</summary>

Fix: Docker fresh docker compose up showed "Connection reset by peer" and never loaded (#6500). The compose ferretdb service downloads its binary on first run, so it is not listening for a while after its container starts, but WeKan's depends_on used condition: service_started (waits only for the container to start, not for the database to accept connections) — so WeKan started against a not-yet-ready database and failed. A healthcheck (a dependency-free bash /dev/tcp probe of 127.0.0.1:27017, with a start_period that covers a slow first download) is added to the ferretdb service and WeKan now waits for condition: service_healthy, matching the intent already documented in the compose file

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/80e87f27b">Fix: the admin Files report showed "No results" on FerretDB (every attachment was hidden)</a>. Thanks to xet7.</summary>

Fix: the admin Files report showed "No results" on FerretDB (every attachment was hidden). Reproduced against a real FerretDB with the Mongo driver: older FerretDB v1 builds reject {members:{$elemMatch:{userId,isActive:true}}} with "(BadValue) unknown operator: userId", and accessibleCardIds used exactly that query while the publication's catch swallowed the error, so the report silently returned nothing. It now matches board membership by the dotted path {'members.userId': userId} (which works on every FerretDB build) and confirms the user's own member entry is active in JS, preserving the exact $elemMatch semantics; the publication also no longer swallows query errors silently

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/c0a1a27847f4e8928da175831d5c75b49fe6f3aa">Import always creates virtual users; map to real users later from the board sidebar; imports can no…</a> Thanks to xet7.</summary>

Import always creates virtual users; map to real users later from the board sidebar; imports can no longer hang. Board import no longer asks for member mapping up front — every imported member is brought in as a virtual (placeholder) user carrying its avatar, username and full name, added to the board inactive with no permissions, and import runs immediately (single step, nothing to get stuck on). Mapping a virtual member to an existing user is a deliberate, later action by a board admin from the sidebar member-avatar popup ("Map to existing user"), and cannot be used to gain privileges: it only maps a virtual member onto an existing ACTIVE, REAL member of the SAME board and never changes that member's role (no new membership, nothing escalated); it reassigns that virtual member's cards/comments/activity on the board to the target and removes the placeholder. Automatic hang mitigation bounds every import so it can never spin forever — a client watchdog surfaces a timeout (and clears the spinner) and the server bounds the import with the same deadline (WEKAN_IMPORT_TIMEOUT_MS, default 2 min) via a new generic withDeadline wrapper. Covered by unit tests (no-escalation + auth matrix, the deadline wrapper) and wiring tests

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/8db9c183a7928ecee4ee014ba477171f4f1e1dae">Fix: board/JSON import "Assign members" — typed suggestions appeared but could not be selected, so…</a> Thanks to xet7.</summary>

Fix: board/JSON import "Assign members" — typed suggestions appeared but could not be selected, so a migrated board's members could not be mapped to existing WeKan users (neither clicking nor Enter did anything). The "Select member" popup mapped Template.currentData().__originalId, a field never set on the search results (they are plain user documents with _id), so it mapped undefined and silently did nothing. It now maps the clicked result by its _id, and Enter selects the first (highlighted) result so a name can be assigned by keyboard. Member mapping stays optional — the step-1 "import without mapping" and the in-step "skip mapping" buttons import immediately (unmapped members default to the current user). Guarded by a test

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/a82bb351e40f3a82af9d1373dd49f40494e25067">Fix: cards not opening — clicking a card did nothing (#6493)</a>. Thanks to mueschel, brlin-tw and xet7.</summary>

Fix: cards not opening — clicking a card did nothing (#6493). The global cleanFilename / downloadFilename template helpers (which show every filename safely) are registered by client/components/main/safeFilename.js, but that module was never imported on the client, so the helpers were never registered. Every template using {{cleanFilename name}} — card attachment thumbnails and the admin Files report — then threw "No such function: cleanFilename" during render, and on a card with attachments that aborted the card's Tracker recompute so the card would not open. client/features/main.js now imports safeFilename.js so the helpers are registered at startup; guarded by a test

</details>

Thanks to above GitHub users for their contributions and translators for their translations.

v10.11 2026-07-21 WeKan ® release

This release fixes the following bugs:

<details> <summary><a href="https://github.com/wekan/wekan/commit/a4c57cf77570ec4a0d7ae1a8d099e1cc6fef1751">Fix: FerretDB high CPU that continued even with no clients connected (#6498)</a>. Thanks to Alishara, bluetopaz1204, mueschel and xet7.</summary>

Fix: FerretDB high CPU that continued even with no clients connected (#6498). Meteor tails the OpLog (local.oplog.rs) with a tailable+awaitData cursor that starts at boot and runs with no clients; on FerretDB v1 that tail was re-running its query every 10 ms (~100 scans/second, forever), pinning CPU. This is a distinct cause from the OpLog bloat capped in the earlier fix — it is the tail's poll rate, not the OpLog size. The bundled FerretDB fork now polls awaitData at a calmer 500 ms (still within the 1 s await budget, so reactivity latency stays low; tunable with FERRETDB_TAILABLE_AWAIT_POLL_MS), cutting idle tail load ~50x — see the FerretDB CHANGELOG. FerretDB OpLog stays ON by default (a reporter confirmed the earlier CPU fix worked), so this only makes the default mode cheap at idle. Additionally, for anyone who explicitly turns the OpLog OFF (WEKAN_FERRETDB_OPLOG=false), every launcher (snap, bundled release, Docker, Sandstorm) now also clears MONGO_OPLOG_URL in that polling-only branch, because merely having it set makes Meteor tail the OpLog regardless of the reactivity order — so polling-only is now truly tail-free. Covered by tests

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/3d7ef849ba4843d69eb009463a8e2de169e99b23">Fix: on Sandstorm, the browser error that a page "can not be displayed embedded in another page"…</a> Thanks to xet7.</summary>

Fix: on Sandstorm, the browser error that a page "can not be displayed embedded in another page" after a grain's first-launch data migration. The grain launcher migrates the old MongoDB data to FerretDB v1 (SQLite) before starting WeKan, and while nothing was listening on the grain's app port (during migration and the handoff to WeKan) sandstorm-http-bridge returned connection-refused, so the browser showed the framed-grain error and the user had to close and reopen the grain. The launcher now runs a tiny child-process bridge that answers the app port with an auto-refreshing "please wait" page across the whole migration and handoff — yielding the port to the migration importer's own progress dashboard and releasing it just before WeKan binds it — so the grain stays framed until WeKan is up. Best-effort (guarded, killed on grain exit) so it never breaks grain startup; covered by wiring/ordering tests

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/8e4d57c21d2f7dcad34b2b1208a68e1b22599af3">The recovery maintenance page now works the same on ALL FerretDB v1 platforms (#6492)</a>. Thanks to bluetopaz1204, mueschel and xet7.</summary>

The recovery maintenance page now works the same on ALL FerretDB v1 platforms (#6492). Besides the in-app spinner (which shows once Meteor serves the client), every launch path now also serves a tiny standalone "recovering your data" page (HTTP 503) on the web port for the brief window while a just-restored FerretDB comes back up and before the app is up — so users never hit a bare connection error: the snap reuses wekan-maintenance-page.mjs with a recovery wording, and the bundled release/Docker paths serve the portable recovery-bridge.mjs. The bridge is time-bounded (WEKAN_RECOVERY_BRIDGE_SECONDS, default 20s) so it can never block WeKan from starting, hands straight over to the in-app spinner, and is skipped if its page or the marker is absent. Only the server clears the RECOVERY_IN_PROGRESS marker (after a real health probe), so the spinner behaves identically everywhere; tests enforce the bridge stays bounded and no launch script deletes the marker

</details>

Thanks to above GitHub users for their contributions and translators for their translations.

v10.10 2026-07-21 WeKan ® release

This release fixes the following bugs:

<details> <summary><a href="https://github.com/wekan/wekan/commit/54b1e9cd17b0ff15f193da4a3db9178b7eb0483f">Fix: high FerretDB CPU (300%+, even idle) from a bloated or corrupt simulated OpLog (#6492)</a>. Thanks to bluetopaz1204, mueschel and xet7.</summary>

Fix: high FerretDB CPU (300%+, even idle) from a bloated or corrupt simulated OpLog (#6492). FerretDB v1's SQLite OpLog (local.oplog.rs, in the local database = local.sqlite) is not reliably capped, so it grows/corrupts over time and Meteor busy-polls it — a reporter confirmed that deleting local.sqlite* drops CPU straight back to ~10%. The local database is transient system data (the OpLog + replica-set metadata), NOT user data (boards/cards/attachments live in wekan.sqlite), so every FerretDB launch path (snap, bundled release, Docker) now resets ONLY the local database on start — FerretDB recreates a fresh, correctly capped OpLog — so a corrupt/bloated OpLog can never persist across a restart. Guarded by WEKAN_FERRETDB_RESET_OPLOG (default on; set false to keep the OpLog), and a test enforces that the reset never touches wekan.sqlite

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/10b7487d9baac301661728a7b8a77c89a3428655">Safety measures against SQLite corruption/bloat (#6492). (1) Before FerretDB opens its files, every…</a> Thanks to bluetopaz1204, mueschel and xet7.</summary>

Safety measures against SQLite corruption/bloat (#6492). (1) Before FerretDB opens its files, every launch path keeps a rotating backup of the text-data database (wekan.sqlite*) in a backup/ subfolder of the same data dir, so a known copy is ready to restore if the live database is ever detected corrupt — it only ever COPIES the live database (never moves/deletes it), keeps the previous generation under backup/prev, and does not copy attachments/avatars (they live on the filesystem); disable with WEKAN_SQLITE_BACKUP=false. (2) The bundled FerretDB fork now automatically DETECTS corruption (a fast quick_check on every database open, logged prominently) and automatically REPAIRS bloat (VACUUM when a file is large and its free pages dominate), and caps the OpLog small (16 MiB) — see the FerretDB CHANGELOG. Both WeKan safety scripts are covered by tests that enforce they never destroy the live text data

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/63c77a27f47aeb204219eba1f4a127837413105c">Automatic recovery/remediation for the SQLite text data, with an Admin Panel / Problems / Recovery…</a> Thanks to bluetopaz1204, mueschel and xet7.</summary>

Automatic recovery/remediation for the SQLite text data, with an Admin Panel / Problems / Recovery report (#6492). A pure, unit-tested decision helper (decideRecovery) picks the least-invasive recovery when the database is KNOWN corrupt — latest good backup → previous backup → re-migrate text data from MongoDB → (else) manual — and never acts on a healthy/unknown database. On request (WEKAN_FORCE_RESTORE env or a RESTORE_REQUESTED marker) the startup scripts restore a known-good backup INTO the live database before FerretDB opens it (backups are never deleted, the main wekan.sqlite is only overwritten, attachments/avatars on the filesystem are untouched). Every safety action is recorded and shown newest-first in the new admin-only Recovery report (readies up front so it can't hang), and admins can record a manual event. Covered by unit tests (decision logic, JSONL parser, search selector) and wiring/negative tests (report is admin-gated; restore never destroys the live data or a backup); documented in docs/Features/Admin-Panel/Problems/Recovery.md

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/9ea7f11d27972989ea7ede695461079a3bbe7489">While a data recovery is in progress, WeKan now shows a full-page maintenance spinner instead of…</a> Thanks to bluetopaz1204, mueschel and xet7.</summary>

While a data recovery is in progress, WeKan now shows a full-page maintenance spinner instead of errors or half-loaded data (#6492). A public status document (everyone, including logged-out users on the sign-in page, sees it) drives a full-screen overlay in both the app and sign-in layouts. The startup scripts mark recovery in progress when they restore/re-migrate; the server keeps the spinner up until it verifies the database serves reads, then clears it — or, if it still cannot read, keeps the spinner and records that manual recovery is required. Admins can also toggle maintenance for a server-initiated re-migration. The status publication is public while the toggle method is admin-only, enforced by tests

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/576f44ff592cdae7d3bae65f0486a33d248f1980">Fix: on the phone All Boards layout, board titles were cut off the right edge and workspace names…</a> Thanks to xet7.</summary>

Fix: on the phone All Boards layout, board titles were cut off the right edge and workspace names were hard-cut in the narrow menu. The board column now shrinks to its track (min-width:0) so the tiles and titles fit on screen, the mobile tile's drag handle is smaller so more of the title shows, and workspace names truncate with an ellipsis instead of mid-word

</details>

Thanks to above GitHub users for their contributions and translators for their translations.

v10.09 2026-07-21 WeKan ® release

This release fixes the following bugs:

<details> <summary><a href="https://github.com/wekan/wekan/commit/c68dc3fb29618685214297484ca7a51084451b23">Fix: on phone-sized touch screens the All Boards page put the board icons BELOW the left menu and…</a> Thanks to xet7.</summary>

Fix: on phone-sized touch screens the All Boards page put the board icons BELOW the left menu and search bar (a single stacked column), and because dragscroll does not work over the menu / search area you could not drag-scroll down to the boards below the fold. The board icons now stay in their own column on the RIGHT (narrow left menu, boards right) in both mobile and desktop mode, and BOTH columns are their own drag-scrollable areas — a tall left menu and the boards can each be scrolled down to. In RTL languages the layout mirrors automatically (menu on the right, boards on the left) via the direction-aware grid and logical borders

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/00ad2c5a688d500e3a22f43b0625498234ef0f6c">Fix: the Files admin report (Admin Panel / Reports / Files) was stuck on the loading spinner and…</a> Thanks to xet7.</summary>

Fix: the Files admin report (Admin Panel / Reports / Files) was stuck on the loading spinner and never listed its files. An await in the attachmentsList publication never resolved (a ReactiveCache read / the ostrio FilesCollection cursor's old-CFS backward-compatibility fallback), so this.ready() never ran and the subscription never became ready — and the report template only renders once ready. The publication now signals readiness UP FRONT (then streams the page rows, which appear reactively), and computes its data by querying the Boards / Cards / Attachments.collection collections directly with fetchAsync instead of through ReactiveCache, so it always resolves. The client helpers are also defensive so a data error can never blank the report

</details>

Thanks to above GitHub users for their contributions and translators for their translations.

v10.08 2026-07-21 WeKan ® release

This release fixes the following bugs:

<details> <summary><a href="https://github.com/wekan/wekan/commit/88642c3bd439c816e783f62281b651d6839a04a4">Verified that list widths on public boards (#5659) are fixed: uncustomized lists render the one…</a> Thanks to NadavTasher and xet7.</summary>

Verified that list widths on public boards (#5659) are fixed: uncustomized lists render the one shared default width for logged-out public-board visitors and members alike, board-wide widths apply to everyone, and anonymous viewers customize via localStorage. The listWidthDefaults.test.cjs regression test existed but was never wired into npm run test:unit:node, so it did not run; it is now wired in so the fix stays verified

</details>

Thanks to above GitHub users for their contributions and translators for their translations.

v10.07 2026-07-20 WeKan ® release

This release fixes the following bugs:

<details> <summary><a href="https://github.com/wekan/wekan/commit/616304e2d0f043afbd03cd5d2af52ca7eeb79361">Fix: the Statistics board view never rendered. statsView.jade / .js / .css were not imported…</a> Thanks to xet7.</summary>

Fix: the Statistics board view never rendered. statsView.jade / .js / .css were not imported anywhere in the client build, so the statsView Blaze template was never registered — switching a board to the Statistics view left the board canvas empty (the header still showed "Statistics", which only reflects the saved board view). The three files are now imported alongside the other board views, so the view renders its board counts and time summary

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/6ca11cdf8360f8f777fbe9fed22c027d934191cd">Fix: on phones the All Boards list grew to the full height of all its boards instead of staying a…</a> Thanks to xet7.</summary>

Fix: on phones the All Boards list grew to the full height of all its boards instead of staying a bounded, scrollable region, so boards below the fold were clipped by the surrounding overflow:hidden and could not be reached. Two leftover .board-list.mobile-view rules (min-height: 100vh and a phantom ::after) re-introduced the exact bug an earlier fix removed elsewhere; removing them lets the bounded height take effect and the two-column list scrolls

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/5b4443df23e42c11c73da1f664fc1d30daf59948">Files admin report (Admin Panel / Reports / Files): read the underlying reactive…</a> Thanks to xet7.</summary>

Files admin report (Admin Panel / Reports / Files): read the underlying reactive Attachments.collection instead of the ostrio FilesCollection wrapper, whose FilesCursor does not reliably re-run in a Blaze helper

</details>

Thanks to above GitHub users for their contributions and translators for their translations.

v10.06 2026-07-20 WeKan ® release

This release fixes the following bugs:

<details> <summary><a href="https://github.com/wekan/wekan/commit/0376665882267d0e77116a1182010a4f35882a3f">Fix: force changeStreams out of the reactivity order for FerretDB on all platforms, not only the…</a> Thanks to uusijani, mueschel and xet7.</summary>

Fix: force changeStreams out of the reactivity order for FerretDB on all platforms, not only the snap. FerretDB v1 does not implement MongoDB change streams, so a $changeStream aggregate returns "not implemented" and Meteor busy-loops retrying it (high FerretDB CPU, cards not opening). The snap fix already stripped changeStreams from METEOR_REACTIVITY_ORDER; the same strip is now applied to the remaining FerretDB run-paths — the standalone FerretDB release start-wekan.sh, the FerretDB Docker image wekan-entrypoint.sh, and the Windows start-wekan.bat — so changeStreams can never enter the order however it was passed in (OpLog + polling only). The Docker compose files already hardcode safe values, and the real-MongoDB start-wekan.sh / start-wekan.bat are unchanged (#6492, #6493, #6480)

</details>

Thanks to above GitHub users for their contributions and translators for their translations.

v10.05 2026-07-20 WeKan ® release

This release fixes the following bugs:

<details> <summary><a href="https://github.com/wekan/wekan/commit/383b3dd432194d157c84b5ed30972098883cd2fd">Fix: FerretDB high CPU and cards not opening — FerretDB v1 does not implement MongoDB change…</a> Thanks to uusijani, mueschel and xet7.</summary>

Fix: FerretDB high CPU and cards not opening — FerretDB v1 does not implement MongoDB change streams, but the snap default reactivity order put changeStreams first, so Meteor issued $changeStream aggregates that FerretDB rejected thousands of times per second (a busy-loop pinning FerretDB CPU at 100-390% — the "aggregate=…" that dominated the operations summary — and starving board/card loading so cards would not open). The snap now forces changeStreams out of the reactivity order for FerretDB (oplog + polling only); real MongoDB is unchanged (#6492, #6493, #6480)

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/f79c320d121b6f59265a1180d555c6e376255d97">Fix: switching the board view now persists — the setBoardView method did not await the profile…</a> Thanks to xet7.</summary>

Fix: switching the board view now persists — the setBoardView method did not await the profile write, so the client reloaded before it was saved (and a rejected write was invisible), making view switching (e.g. to the new Statistics view) unreliable; and All Boards on phones now lays the board icons out as a real 2-column CSS grid so at least 2 show per row, where the earlier float rule did not (#6488)

</details>

Thanks to above GitHub users for their contributions and translators for their translations.

v10.04 2026-07-20 WeKan ® release

This release adds the following new features:

<details> <summary><a href="https://github.com/wekan/wekan/commit/81dbecfb8719ff99dac367e9921e44282c8b3ded">Safe filename handling everywhere: attachment and avatar names are always shown clean…</a> Thanks to xet7.</summary>

Safe filename handling everywhere: attachment and avatar names are always shown clean — URL-decoded, normalized to generally-used characters (Unicode NFKC plus confusable-homoglyph folding, so a typosquatting pаypal.exe with a Cyrillic а is shown as paypal.exe), invisible/control/bidi characters removed and HTML/JS/XML markup stripped. Uploads are hardened (exploit and EICAR virus-test filenames rejected, extension corrected to the real detected type, length capped to a portable 30 chars), SVG JavaScript and XML-loop content is sanitized at a staging path before storage, existing files can be corrected/sanitized on the fly, storage moves check free disk space, and migrations fix + disambiguate names. One general cleanFileName function via {{cleanFilename}} / {{downloadFilename}}. Design at docs/Features/Filename/Filename.md

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/fdb683892d11ec1ab2b2c75fa865ac2abea6bfe4">Log every filename/content sanitization to Admin Panel / Problems: whenever a name or file required…</a> Thanks to xet7.</summary>

Log every filename/content sanitization to Admin Panel / Problems: whenever a name or file required sanitization (upload, migration, existing-file corrector, viewing), the Security report records WHEN, WHO uploaded it, FOR WHAT REASON (URL-encoding, invisible characters, typosquatting, the exploit kind such as JavaScript / XML code / XML loop, wrong file type, too long), the filename from → to, and WHERE (board › swimlane › list › card, plus organization and team)

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/1c636f09b35eb48f777bef847f4df5f40c0df969">CPU-usage monitor + governor + Admin Panel / Problems / CPU usage report: watch system-wide CPU…</a> Thanks to xet7.</summary>

CPU-usage monitor + governor + Admin Panel / Problems / CPU usage report: watch system-wide CPU, record only the START and END of each sustained high-CPU period with what WeKan/FerretDB were doing, the automatic mitigation taken and whether it helped; a governor pauses long batch operations to yield the CPU. On high CPU WeKan asks the bundled FerretDB (via its throttle command) to slow down in an adaptive feedback loop, FerretDB also self-regulates on its own and reports its own process CPU% so a core-peg is visible even when system-wide CPU looks moderate. Design at docs/Features/Admin-Panel/Problems/CPU-usage.md

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/96c7faa87254529cf2531ff2c6027efd136ee753">Automatic adaptive card loading (no admin toggle): WeKan decides per board by size — a board over…</a> Thanks to xet7.</summary>

Automatic adaptive card loading (no admin toggle): WeKan decides per board by size — a board over the threshold (default 500 cards, CARDS_LOADING_LAZY_THRESHOLD) loads only the visible cards (infinite-scroll windows) plus a live count, so very large boards stay fast; smaller boards keep loading every card. The board publication also publishes comments/attachments with one board-level cursor instead of one per card (an N+1 that pinned FerretDB CPU), with supporting indexes

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/3aefa94a66dd9542e690bccd5aedac73d8a50c11">Statistics board view (Finnish: Tilastot): a full-width board view alongside Swimlanes / Lists /…</a> Thanks to xet7.</summary>

Statistics board view (Finnish: Tilastot): a full-width board view alongside Swimlanes / Lists / Calendar / Gantt / Table showing the board's card-loading mode, counts (swimlanes, lists, cards, archived cards, labels, members, custom fields) and a time-spent summary; counts come from the server so they are accurate even in lazy mode, and its text can be selected and copied with mouse or finger. Design at docs/Features/Board/Sidebar/Status.md

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/f306061273e12958d715452f27d19c8001925a2c">Admin Panel / Problems documentation reorganized under Admin-Panel/Problems/ with new RAM-usage and…</a> Thanks to xet7.</summary>

Admin Panel / Problems documentation reorganized under Admin-Panel/Problems/ with new RAM-usage and Disk-usage design specs (log how much RAM+swap / disk is used and record only the start and end of each sustained high-usage period, mirroring the CPU-usage monitor)

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/dd21cfdc0519bb433737976ea3155f9db18143e0">Admin Panel report improvements: paginated, searchable report tables (Files, Rules, Boards, Cards…</a> Thanks to xet7.</summary>

Admin Panel report improvements: paginated, searchable report tables (Files, Rules, Boards, Cards, Impersonation) that load one page at a time via index-backed sorts instead of the whole collection, theme-following buttons, and the Files report shows every filename decoded and cleaned

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/54cc0ec614fcbb2abf059dd07b75894cf7ea4fe6">Organize docs/Features into categories (Admin-Panel, Board, Cards, Editor, Reports, Automation…</a> Thanks to xet7.</summary>

Organize docs/Features into categories (Admin-Panel, Board, Cards, Editor, Reports, Automation, Lists, Troubleshooting) and update all links

</details>

and fixes the following bugs:

<details> <summary><a href="https://github.com/wekan/wekan/commit/4a3800973a3f93321a7c2f92ade95d91ffb29153">Fix: removing a member from a board now visibly works — the sidebar member list kept showing…</a> Thanks to mueschel and xet7.</summary>

Fix: removing a member from a board now visibly works — the sidebar member list kept showing removed members (removeMember keeps the entry with isActive:false), so "Remove from board" looked like it did nothing; the list now shows only active members (#6479)

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/5d56af56cd419ef09b69ed9cdcbdb30ad1b56ba1">Fix: opening a board no longer pins FerretDB CPU / takes minutes — the board publication opened one…</a> Thanks to mueschel and xet7.</summary>

Fix: opening a board no longer pins FerretDB CPU / takes minutes — the board publication opened one live comments cursor and one attachments cursor per card (an N+1 that pinned FerretDB/SQLite CPU), now one board-level cursor each plus indexes; FerretDB also reports its own process CPU% so Problems shows a core-peg (#6480)

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/b4fd7d851f887930c0640a687847bf49223ee2ab">Fix: All Boards on mobile is scrollable again and shows at least 2 board icons per row — the list…</a> Thanks to mimZD and xet7.</summary>

Fix: All Boards on mobile is scrollable again and shows at least 2 board icons per row — the list was forced min-height:100vh so it grew to fit and was clipped by the overflow:hidden wrapper; it now has a bounded height + overflow-y:auto, and the left menu is narrowed so the boards get room (#6488)

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/e424fb1bba82d168afa6e0cafae57a4fb4ad6b58">Fix: Rules → Workflow view can create rules again — rulesWorkflow.js used TAPi18n without importing…</a> Thanks to xet7.</summary>

Fix: Rules → Workflow view can create rules again — rulesWorkflow.js used TAPi18n without importing it, so building a rule threw ReferenceError and the Add Rule handler aborted before saving (#6489)

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/25f9b468256279d8f483657372e70da379c9e604">Fix: Rules list — Delete and "View rule" act on the clicked rule, not the first — the buttons live…</a> Thanks to xet7.</summary>

Fix: Rules list — Delete and "View rule" act on the clicked rule, not the first — the buttons live in the rulesList child template but their handlers on the parent rulesMain used Template.currentData(), so the rule id was null (delete failed with "Match failed", View always opened the first rule); the buttons now carry an explicit data-rule-id (#6490)

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/e6b001320c27a11fdbd15e17f2ad230bdeab2290">Fix: a rule's action never executed even though its trigger matched — the trigger match required…</a> Thanks to xet7.</summary>

Fix: a rule's action never executed even though its trigger matched — the trigger match required fields a moveCard trigger omits (oldListName), and a Mongo $in does not match a missing field unless null is in the list, so the rule silently never fired; null is now included for every field, like the cardTitle handling (#6491)

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/dded8ed47695092a698ab3e34a8abc5c6f567607">Fix: admin reports load on FerretDB — the paginated report publications returned a sorted+limited…</a> Thanks to xet7.</summary>

Fix: admin reports load on FerretDB — the paginated report publications returned a sorted+limited live cursor, whose limited live observe hangs on FerretDB's OpLog, so the report was stuck on the loading spinner; they now publish the page manually so ready always fires

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/5949e85cfd9996651e1844fa6238d5a0f2da793e">Fix: the paginated admin reports never get stuck on the loading spinner if a report subscription…</a> Thanks to xet7.</summary>

Fix: the paginated admin reports never get stuck on the loading spinner if a report subscription fails — they now handle onStop, clearing the spinner and surfacing the error

</details>

Thanks to above GitHub users for their contributions and translators for their translations.

v10.03 2026-07-19 WeKan ® release

This release fixes the following CRITICAL VULNERABILITIES, all reported by meifukun: RedirectBleed, SourceBleed, LiveBleed, CasBleed, MetricsBleed, ImpersonateBleed and InviteBleed:

<details> <summary><a href="https://github.com/wekan/wekan/commit/1669a1af196ba48395e01376b3a0fc784ad42cc3">Fix RedirectBleed: avatar localization validated only the original host and then followed redirects…</a> Thanks to meifukun and xet7.</summary>

Fix RedirectBleed: avatar localization validated only the original host and then followed redirects with native fetch, so a public avatar URL could 302 to a loopback/private/metadata address (SSRF). Avatars are now fetched via the DNS-pinned, redirect-rejecting fetchSafe, and profile.avatarUrl is scheme-validated (http/https/data:image/local) at write time

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/1669a1af196ba48395e01376b3a0fc784ad42cc3">Fix SourceBleed: a Trello board imported with a javascript: url was stored as an activity…</a> Thanks to meifukun and xet7.</summary>

Fix SourceBleed: a Trello board imported with a javascript: url was stored as an activity source.url and rendered as a clickable activity-sidebar link, so a board admin clicking it ran attacker JavaScript (stored XSS, Meteor.loginToken theft). Only http(s) source URLs are now stored and linked

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/1669a1af196ba48395e01376b3a0fc784ad42cc3">Fix LiveBleed: the live Trello import fetched attacker-controlled attachment/background/avatar URLs…</a> Thanks to meifukun and xet7.</summary>

Fix LiveBleed: the live Trello import fetched attacker-controlled attachment/background/avatar URLs with bare fetch (no SSRF guard) and stored the response for read-back through the attachment API (non-blind SSRF). Every live-import download is now gated by validateAttachmentUrl, matching the offline import path

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/1669a1af196ba48395e01376b3a0fc784ad42cc3">Fix CasBleed: CAS login stored validated user data in a module-global (_userData) shared across…</a> Thanks to meifukun and xet7.</summary>

Fix CasBleed: CAS login stored validated user data in a module-global (_userData) shared across concurrent logins, so two logins could race and issue an attacker a session for a victim's account. The user data is now bound per credential token

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/1669a1af196ba48395e01376b3a0fc784ad42cc3">Fix MetricsBleed: the /metrics endpoint trusted a client-supplied X-Forwarded-For header…</a> Thanks to meifukun and xet7.</summary>

Fix MetricsBleed: the /metrics endpoint trusted a client-supplied X-Forwarded-For header unconditionally, so anyone could forge a whitelisted IP and read operational metrics. XFF is now trusted only when METRICS_TRUST_PROXY is set (parsed spoof-resistantly from the right); otherwise the real socket address is used

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/1669a1af196ba48395e01376b3a0fc784ad42cc3">Fix ImpersonateBleed: several board export endpoints treated any historical ImpersonatedUsers…</a> Thanks to meifukun and xet7.</summary>

Fix ImpersonateBleed: several board export endpoints treated any historical ImpersonatedUsers record as an authorization bypass (canExport || impersonateDone), so a demoted former admin could export private boards forever. The impersonation bypass is removed; export requires real board visibility (canExport) only

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/1669a1af196ba48395e01376b3a0fc784ad42cc3">Fix InviteBleed: invitation registration used a 6-digit Math.random() code (~900k keyspace) with no…</a> Thanks to meifukun and xet7.</summary>

Fix InviteBleed: invitation registration used a 6-digit Math.random() code (~900k keyspace) with no throttling, brute-forceable to hijack an invited account and its private boards. Codes are now a 128-bit crypto.randomBytes value and account creation is rate-limited (DDPRateLimiter)

</details>

The OIDC login shared-serviceData race from the same report was already fixed earlier (#4897 moved the per-login profile/serviceData/userinfo objects inside the OAuth callback); the new source-guard test verifies it stays that way. Thanks to meifukun and xet7.

and adds the following new features:

<details> <summary><a href="https://github.com/wekan/wekan/commit/1d82ef8eae344ca0565d0858fc697f470e471dd7">Added an automatic security/speed/tests remediation-logging subsystem (design docs…</a> Thanks to xet7.</summary>

Added an automatic security/speed/tests remediation-logging subsystem (design docs docs/Security/Remediation/WeKan.md and FerretDB.md). WeKan's runtime guards (SSRF, upload/avatar rejection and sanitization, forged X-Forwarded-For, export authz, invite rate-limit, …) now record each block/sanitize/remediate event into the existing WeKan database (a single eventlog collection, stream security/speed/tests) via normal Meteor JavaScript queries — no new files or databases under WRITABLE_PATH, so it works the same on FerretDB and MongoDB. FerretDB reports its problems to WeKan, which records them. Events use general category names plus the hall-of-fame *Bleed names

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/0085f7fbfa81e820ce0736dd28b2b0786ac6e41c">Added Admin Panel → Problems (a new button on the 2nd header bar to the right of the Info/version…</a> Thanks to xet7.</summary>

Added Admin Panel → Problems (a new button on the 2nd header bar to the right of the Info/version button, with a warning icon that turns red when there are new problems). The old Reports button is removed and its page becomes the Problems page: a left menu with Summary, Security, Speed and Tests above the moved Broken Cards/Files/Rules/Boards/Cards/Impersonation reports. Summary is a checkbox list of problem areas with one Acknowledge button (the only place to acknowledge, which resets the per-area new-problem count); Security/Speed/Tests are read-only, paginated, searchable event tables

</details>

and adds the following updates:

<details> <summary><a href="https://github.com/wekan/wekan/commit/dd21cfdc0">Reworked the Admin Panel → Problems pagination tables (Files/Rules/Boards/ Cards/Impersonation and…</a> Thanks to xet7.</summary>

Reworked the Admin Panel → Problems pagination tables (Files/Rules/Boards/ Cards/Impersonation and the Security/Speed/Tests event tables): the search field and pagination controls now sit in one row (pagination on the right, RTL-aware), the redundant Search button is gone (typing + Enter searches), and the controls follow the current theme — they use var(--theme-accent), so Member Settings → Change color recolors them

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/52acd4b75">Made buttons follow the theme: the global button base and the primary buttons (forms.css) plus the…</a> Thanks to xet7.</summary>

Made buttons follow the theme: the global button base and the primary buttons (forms.css) plus the admin and People-panel buttons now use var(--theme-accent, <original>), so a Change-color theme override recolors buttons across the app while the default look is unchanged

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/fccc4a739">Admin Panel → Files report: URL-encoded filenames (e.g. "%D0%93%D1%80") are decoded for display…</a> Thanks to xet7.</summary>

Admin Panel → Files report: URL-encoded filenames (e.g. "%D0%93%D1%80") are decoded for display, filenames are always shown as plain text (never markdown/ HTML), a name hiding invisible/zero-width/bidi characters gets a red warning triangle on the left and each invisible character is replaced inline by its red Unicode name (e.g. "evil[U+200B ZERO WIDTH SPACE].png"), and a filter lists only such names

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/6d3c550d8">Removed clickable column-header sorting from the board Table view and the Admin Panel → People →…</a> Thanks to xet7.</summary>

Removed clickable column-header sorting from the board Table view and the Admin Panel → People → Domains table; both now show a stable fixed order (and keep their search + pagination). Sorting a server-paginated table means sorting the whole set, which fights the per-page perf fix

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/1039b2e3286135a1749aeab9526b52d40fb82e43">Enabled FerretDB OpLog tailing by default so Meteor stops poll-and-diff, the main fix for FerretDB…</a> Thanks to uusijani, Nissulya and xet7.</summary>

Enabled FerretDB OpLog tailing by default so Meteor stops poll-and-diff, the main fix for FerretDB sitting at 100–390% CPU on busy boards. With FerretDB there is no MongoDB oplog, so Meteor re-ran every live query on a timer; FerretDB v1 now ships an OpLog (auto-created capped local.oplog.rs + replica-set hello handshake), so every FerretDB launch path starts ferretdb with --repl-set-name=rs0 and points WeKan at it via MONGO_OPLOG_URL, and Meteor TAILS the OpLog instead of polling. OpLog is used ONLY when tailing actually works: every platform that defaults to FerretDB — Snap, Sandstorm, the Docker image/compose and the prebuilt bundle — starts ferretdb with --repl-set-name and sets METEOR_REACTIVITY_ORDER=oplog,polling, so Meteor falls back to polling if the OpLog cannot be established (a broken/absent OpLog never blocks startup). Kill-switch WEKAN_FERRETDB_OPLOG=false reverts to polling only. Admin Panel → Version shows which driver is actually live ("Reactivity mode") next to the configured METEOR_REACTIVITY_ORDER and DDP_TRANSPORT, so you can confirm OpLog came up rather than fell back. Also trimmed the client activity feed page from 500 to 50 rows (infinite scroll still loads more on demand)

</details>

and fixes the following bugs:

<details> <summary><a href="https://github.com/wekan/wekan/commit/f7c4a916f">Fix the Admin Panel → Problems → Cards report spinning while it loaded on big sites: it paginates…</a> Thanks to xet7.</summary>

Fix the Admin Panel → Problems → Cards report spinning while it loaded on big sites: it paginates, but sorted by an unindexed { boardId, sort }, so every page full-sorted all cards (11761+) in memory. It now sorts by the existing { boardId, createdAt } index, so one page is a bounded index scan; also added a { stream, at } index for the Security/Speed/Tests tables

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/19870ad52">Fix the Admin Panel → Translation page loading the ENTIRE Translations collection at once: it…</a> Thanks to xet7.</summary>

Fix the Admin Panel → Translation page loading the ENTIRE Translations collection at once: it subscribed with a hardcoded limit of 0 (= no limit). It now uses the infinite-scroll window, loading a page at a time

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/e205f6b7a">Fix the Board Archive → Boards list loading every archived board at once: it now pages server-side…</a> Thanks to xet7.</summary>

Fix the Board Archive → Boards list loading every archived board at once: it now pages server-side (30 per page) with a search box and prev/next controls, so only the current page is loaded

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/7da21c46b">Fix "Did not check() all arguments" server-log spam from the Admin Panel → Problems detail pages…</a> Thanks to xet7.</summary>

Fix "Did not check() all arguments" server-log spam from the Admin Panel → Problems detail pages: the eventLogCount/eventLogPage methods awaited the admin check before check()ing their arguments, so a non-admin call (or one before admin status resolved) made Meteor's audit-argument-checks mask the real error. check() now runs first in every argument-taking event-log method

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/2f7b49d8def4bb03d233b83671ac6c78308124ea">Fix MongoDB 3.x → FerretDB v1 migration failing on non-finite numbers: cards whose sort was…</a> Thanks to Nissulya and xet7.</summary>

Fix MongoDB 3.x → FerretDB v1 migration failing on non-finite numbers: cards whose sort was ±Infinity or NaN (written by old WeKan before #6472) were rejected by FerretDB/SQLite ("infinity values are not allowed") and dropped, so boards stayed stuck on the loading spinner. The migration now clamps every non-finite double to a finite 0 before insert, and a new nonfinite-sort-repair startup schema-upgrade step heals the same corruption in an already-running database (MongoDB or FerretDB)

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/2f7b49d8def4bb03d233b83671ac6c78308124ea">Fix LDAP mail → email field mapping returning undefined (regression since the ldapjs → ldapts…</a> Thanks to Nissulya and xet7.</summary>

Fix LDAP mailemail field mapping returning undefined (regression since the ldapjs → ldapts move): the sync read the mapped attribute case-sensitively while the rest of wekan-ldap reads it case-insensitively, so a directory that returns the attribute with a different case synced no email address. It is now read via getLDAPValue() like everywhere else

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/2f7b49d8def4bb03d233b83671ac6c78308124ea">Fix a memory/listener leak in board and CSV export: each write paused on socket backpressure leaked…</a> Thanks to uusijani and xet7.</summary>

Fix a memory/listener leak in board and CSV export: each write paused on socket backpressure leaked one 'error' listener on the response ("MaxListenersExceededWarning: 11 error listeners added to [ServerResponse]"), which on a large export grew unbounded. Both listeners are now removed once the write settles

</details>

Thanks to above GitHub users for their contributions and translators for their translations.

v10.02 2026-07-19 WeKan ® release

This release adds a new developer documentation set and release automation.

It adds the following documentation, a new docs/Design/Autoupdate/ set describing how software is installed and updated — manually and automatically (Snap-like) — on every operating system, and how to release WeKan to more platforms:

<details> <summary><a href="https://github.com/wekan/wekan/commit/742fc9221a6671dbf072e06a15cd3fd8bf409f32">Added why the ppc64el/s390x snap builds fail on core24 (the QEMU multiarch action caps at core22)…</a> Thanks to xet7.</summary>

Added why the ppc64el/s390x snap builds fail on core24 (the QEMU multiarch action caps at core22), moved to Autoupdate/Forks/Snap-Core.md

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/cf1823b510b4abd67c8c32f605ddcfc956ea8802">Added per-OS install/update comparison docs for all operating systems (Linux, BSD, Haiku, Amiga…</a> Thanks to xet7.</summary>

Added per-OS install/update comparison docs for all operating systems (Linux, BSD, Haiku, Amiga, RISC OS, Solaris/illumos, AIX, HP-UX, OpenVMS, z/OS, ArcaOS, ReactOS, FreeDOS, Plan 9, Redox, SerenityOS, Android, iOS, ChromeOS, HarmonyOS, Ubuntu Touch, Sailfish, postmarketOS, KaiOS, Tizen, webOS and TV/streaming OSes), plus the Snap-Ondra-Gantt.md variant-snap spec

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/58c0e6d1f8bfabc624c870c9e91c6a54f0877cda">Added UCS.md: releasing the Docker WeKan UCS App Center app with automatic MongoDB 3.x to FerretDB…</a> Thanks to xet7.</summary>

Added UCS.md: releasing the Docker WeKan UCS App Center app with automatic MongoDB 3.x to FerretDB v1 SQLite migration

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/8ae0ee54460444f6517fbe5a63f6ba143d5b6277">Added Nextcloud.md: building WeKan as a Nextcloud ExApp, App Store publishing, and AI (Task…</a> Thanks to xet7.</summary>

Added Nextcloud.md: building WeKan as a Nextcloud ExApp, App Store publishing, and AI (Task Processing API) / Deck / OIDC SSO integrations

</details>

and the following documentation reorganization:

and the following release automation:

<details> <summary><a href="https://github.com/wekan/wekan/commit/2054ea2b54a193b9e53267b3dc3fbe15056c9e1e">Added guarded snap-variants / ucs / nextcloud publish jobs to release-all.yml (dormant until their…</a> Thanks to xet7.</summary>

Added guarded snap-variants / ucs / nextcloud publish jobs to release-all.yml (dormant until their secrets are set, so ordinary releases are unaffected)

</details>

and adds the following updates:

Thanks to above GitHub users for their contributions and translators for their translations.

v10.01 2026-07-18 WeKan ® release

This release adds the following features:

<details> <summary><a href="https://github.com/wekan/wekan/commit/b8bb00465037389eb5829157441f2b75819353d9">Member Settings — optional "Submit editors with Enter"</a>. Thanks to xet7.</summary>

Member Settings — optional "Submit editors with Enter". A new per-user setting in the Member Settings (Change Settings) popup, saved to profile.submitOnEnter and off by default: when on, plain Enter saves the card title, description and other inline editors and Shift+Enter inserts a new line; when off, behaviour is unchanged (Ctrl/Cmd+Enter saves). This restores the fast Enter-to-save workflow for users who prefer it without regressing #4236. Setting, translated to all languages

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/1284afc588d6a52f89fdbb80d6f6ede9bc6bdfeb">Right board sidebar — drag the edge to resize its width</a>. Thanks to xet7.</summary>

Right board sidebar — drag the edge to resize its width (desktop). Drag the sidebar's edge like a spreadsheet column; the width is saved to profile.sidebarWidth for logged-in users and to browser localStorage for anonymous users on a public board, then re-applied on load. The handle uses logical positioning, so in LTR it is the left edge of the right-docked sidebar and in RTL the right edge of the left-docked sidebar (drag direction inverts); on phones the sidebar stays full width. Feature, tooltip translated to all languages

</details>

and fixes the following bugs:

<details> <summary><a href="https://github.com/wekan/wekan/commit/e0991d6602c915e13d77776af36ecac410965909">Upgrade migration no longer drops cards/activities whose exported data holds NaN or Infinity</a>. Thanks to Nissulya and xet7.</summary>

Upgrade migration no longer drops cards/activities whose exported data holds NaN or Infinity (#6481). mongo 3.x mongoexport emits non-finite doubles as the bare tokens NaN/+Infinity (e.g. a card's "sort":+Infinity, an activity's "value":NaN), which are not valid JSON, so EJSON.parse threw and the whole document was dropped — leaving boards/cards missing after an otherwise "successful" 6.09 → 10.x migration. Those bare tokens are now rewritten (outside string literals) to the canonical EJSON {"$numberDouble":"…"} form so the document migrates instead of being lost. Fix

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/b71461d90c6a7d9ef9a835cf151181e15c84526d">Add List — the inline composer's previous options are back</a>. Thanks to csonkaoszimt and xet7.</summary>

Add List — the inline composer's previous options are back (#6465). The per-list-header inline Add List composer had been reduced to just a title input; the "add after which list" position selector (pre-selecting the list whose header opened it) and the "or template" link are restored. Fix

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/b8a21e4ce3acca427a25f11bcb8c0fdf6bc26ee3">Right sidebar width no longer doubles on wide screens, and its input fields fill the width</a>. Thanks to xet7.</summary>

Right sidebar width no longer doubles on wide screens, and its input fields fill the width. The sidebar used width: 30vw, so it grew unbounded on very wide screens (looking right only at mid widths); it is now clamped. The filter text inputs (Filter List by Title, Filter by card title, …) had no width and rendered narrow with a big gap on the right; they now fill the padded content width with equal spacing on both sides. Fix

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/72042b7d796ce66c9f9da637c90e84493b67b3c1">The Multi-Selection sidebar "Copy selection" button showed its raw translation key</a>. Thanks to xet7.</summary>

The Multi-Selection sidebar "Copy selection" button showed its raw translation key. The copy-selection key was missing from every language file — including the English source — so i18next had no value to fall back to. It was added ("Copy selection") and translated to all languages. Fix

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/08cfe2d7881aee399dd0e0a05629096e3027b9b2">Follow-up fixes surfaced by the full test run</a>. Thanks to xet7.</summary>

Follow-up fixes surfaced by the full test run. Now that generic Chinese (zh) is its own registered language, a bare zh browser tag resolves to that entry (Simplified Chinese) instead of the old zh → zh-Hans alias, which is removed; and the board-actions end-to-end test opens the board menu from the right sidebar (the header Board Settings cog was removed). Fix

</details>

Thanks to above GitHub users for their contributions and translators for their translations.

v10.00 2026-07-18 WeKan ® release

This release adds the following updates:

and adds the following features:

<details> <summary><a href="https://github.com/wekan/wekan/issues/5778">Member menu / Change Color — global theme override, visible swatches, applies everywhere, no Save…</a> Thanks to xet7.</summary>

Member menu / Change Color — global theme override, visible swatches, applies everywhere, no Save button ( #5778 , docs/Theme/Theme.md). The member menu gets a Change Color entry that works like Board Settings / Change Color but is a per-user global override saved to your profile, so the chosen theme applies to the whole UI — All Boards, Search, Admin Panel, My Cards, Due Cards and board pages — via a board-color-<name> class on <body>/header (while you are on a board, that board's own color still wins). This makes a dark theme everywhere possible. Done differently from the first cut: the picker was initially built as two-level dropdowns (category then theme); that was replaced by visible color swatches grouped by category (Flat / Clear / Dark / Special, category name above each group), and the Save button was removed — clicking a swatch applies immediately. Flat themes allow 1 custom color and clear themes 2 (a gradient) via the native color wheel; dark/special are fixed. Custom colors are validated server-side as #rrggbb and applied as CSS variables (--theme-accent / --theme-accent-2). Category titles are left-aligned and the popup has a title

</details> <details> <summary><a href="https://github.com/wekan/wekan/issues/4759">Member Settings / Font — pick an installed UI font, size and text colors</a>.</summary>

Member Settings / Font — pick an installed UI font, size and text colors (part of #4759 ). A new Font member-menu entry lets you choose a UI font that already exists in your browser (canvas-detected; names are plain text, never HTML/markdown, validated server-side against a curated whitelist — only a known font name and a preset percentage ever reach the DOM), a font size, and custom text and text-background colors (each unsettable). Done differently from the first cut: the font and size were first built as dropdowns; that was replaced by buttons — one font-name button per detected font, each rendered in its own font, and a row of size buttons (smaller … 100% in the middle … larger) — that apply immediately on click (no Save). A preview pangram shows below the font buttons, the Unset links are styled as buttons, and the popup has a title. Stored as profile.uiFont / profile.uiFontSize / profile.uiTextColor / profile.uiTextBgColor. (Only the font part of #4759, so the issue stays open.) Thanks to xet7. Commits:

</details> <details> <summary><a href="https://github.com/wekan/wekan/issues/6478">Soft delete + Undo/Redo + change-History — deleting is reversible, and moves can be undone</a>. Thanks to xet7.</summary>

Soft delete + Undo/Redo + change-History — deleting is reversible, and moves can be undone (#1023, #6478 , docs/Features/Undo/Undo.md). Deleting a list no longer destroys it: the list and its cards are marked deleted (deletedAt/deletedBy/deleteBatchId), hidden from the board, and restorable via lists.restore or Ctrl+Z — the first slice of a general "no permanent delete in ordinary use" principle (physical deletion limited to GDPR/account erasure and an explicit Global-Admin purge behind an off-by-default Admin Panel / Features / Delete flag). Card/list/swimlane moves are now undoable/redoable with Ctrl+Z / Ctrl+Y (the userPositionHistory collection was silently not recording — its guard checked an un-imported global — and had no redo or key bindings; all fixed, selection logic unit-tested). Design unifies this into one universal change-History with scoped views. Activities were confirmed to already load progressively

</details> <details> <summary><a href="https://github.com/wekan/wekan/issues/2220">Home board — set a board that opens automatically after login</a>. Thanks to xet7.</summary>

Home board — set a board that opens automatically after login ( #2220 ). A per-user default "home" board (profile.defaultBoardId) opens automatically after login on all deployments (a once-per-session router redirect), and on Sandstorm a grain with exactly one board again opens straight into it (without saving anything; a saved Home board wins, a many-board grain with no choice shows All Boards). You set the Home board from the All Boards page via Multi-Selection: turn Multi-Selection on, select a board, and click the home action in the new "Selected:" row (which also has a star action); the Home board then shows a home badge and sits at the top of Starred. Done differently: the earlier per-board-tile home toggle was hidden, but setting a Home board was brought back through the Multi-Selection "Selected:" action — so the feature is available, not disabled, and not Sandstorm-only — and the initial Sandstorm "save the single-board choice" was changed to no-save

</details> <details> <summary><a href="https://github.com/wekan/wekan/issues/6465">Add List moved from a standing column to a per-list header button</a>.</summary>

Add List moved from a standing column to a per-list header button ( #6465 ). The Swimlanes/Lists views no longer show a permanent "Add List" composer column; each list header has a far-right add-list button that opens the composer as a column immediately after that list (right in LTR, left in RTL), and an empty swimlane/board shows a + button. Also fixes the composer's Save doing nothing (createListAfter's check() used a non-Optional matcher for nextListId, so the inline composer 400'd with "Match failed"). (First cut — desktop/main views.) Thanks to csonkaoszimt (report) and xet7. Commits:

</details> <details> <summary>All Boards, board header, top bar and popups — a large batch of layout/theming polish. Thanks to xet7. Commits.</summary>

All Boards, board header, top bar and popups — a large batch of layout/theming polish. Highlights: the right-sidebar hamburger is pinned to the right edge at every width with an ~8px gap, and the duplicate Board Settings cog was removed; the All Boards My Boards toolbar wraps (order Multi-Selection then Sort then Search), the Multi-Selection button matches the Swimlanes view, the empty grey band was removed, "+ Add …" tiles match board-tile height, the board-tile drag handle sits at the right middle (transparent, icon only), and the search box is half width with the multi-selection hint beneath it; the mobile/desktop toggle and zoom swapped places; the Change Language popup shows multiple columns; the Change Avatar upload button and the Search All Boards / Archived boards buttons follow the theme colors; the Archived boards modal is full width and starts below the header; the Notifications drawer is full width with a black hamburger + X and black menu icons; and the member menu's duplicate Notifications entry was removed. Done differently: an "icons only — always hide button text labels" board-header change was tried and then reverted, so button text labels stay.

</details> <details> <summary><a href="https://github.com/wekan/wekan/issues/6465">Denser default layout: thinner lists, board tiles and card dock; taller-looking minicards</a>. Thanks to csonkaoszimt (report) and xet7.</summary>

Denser default layout: thinner lists, board tiles and card dock; taller-looking minicards ( #6465 ). List width default 272 to 220px (minimum 270 to 200px), All Boards board tiles min-height 100 to 72px, and the card-detail right dock max-width 800 to 520px — all overridable, user customizations untouched. Minicards no longer reserve a tall empty band under a one-line title (the title viewer gets min-height: 0), and the swimlane header no longer clips at non-100% zoom

</details>

and fixes the following bugs:

<details> <summary><a href="https://github.com/wekan/wekan/commit/f6f021f8cc8de452593f499e1306dca021679325">Confirmation dialogs work again</a>. Thanks to mueschel (report) and xet7.</summary>

Confirmation dialogs work again — clicking any .js-confirm button (e.g. "Remove Member") did nothing because Popup.afterConfirm() stashed the pending action on the Blaze data context (an immutable Minimongo doc that Blaze re-creates on re-render); it is now stored on the Popup instance (#6479)

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/6d5a594b0709ad3456f91afc31d821304539a262">Moving a card between swimlanes is no longer broken with long lists</a>. Thanks to mueschel (report) and xet7.</summary>

Moving a card between swimlanes is no longer broken with long lists — jQuery UI sortable cached geometry at drag-start and never re-cached on WeKan's manual auto-scroll, so the drop re-homed the card in the source swimlane; fixed with sortable('refreshPositions') after a scroll (#6477)

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/b1d33c536239d45ae5df6139beb6b5548f7e03c0">Filter by date -&gt; Overdue no longer lists cards with no due date</a>. Thanks to the reporter and xet7.</summary>

Filter by date -> Overdue no longer lists cards with no due date — $lte/range selectors matched null under both FerretDB and minimongo; DateFilter._getMongoSelector now adds $ne: null (reported by email)

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/c29cf3b985abb670222ec25c76a055e0ff9ba53a">A board-wide list no longer disappears from other swimlanes when nudged</a>. Thanks to the reporter and xet7.</summary>

A board-wide list no longer disappears from other swimlanes when nudged — the drop handler treated any small drag of a board-wide list (swimlaneId === null) as a swimlane change; it now also requires an original swimlane (reported by email)

</details> <details> <summary><a href="https://github.com/wekan/wekan/issues/6476">FerretDB (bundled) — #6476 crash-loop root cause fixed, and label/title/date filters pushed down to…</a></summary>

FerretDB (bundled) — #6476 crash-loop root cause fixed, and label/title/date filters pushed down to SQLite ( #6476 ). A startup SyncedCron upsert hit an orphaned-table error (table "…connections_<hash>" already exists) that became an unhandledRejection and crash-looped WeKan so its port never opened; the bundled FerretDB now creates tables/indexes with IF NOT EXISTS. It also pushes label ($in), title ($regex) and dueAt range filters down to SQLite instead of scanning every card in Go. (Details in the fork's own CHANGELOG.) Thanks to uusijani, a1bert01 and xet7. Changelog commits:

</details> <details> <summary>Snap release, CI and dev tooling.</summary>

Snap release, CI and dev tooling. The exotic ppc64el/s390x snaps now build on GitHub Actions under QEMU instead of Launchpad (which returned exit 0 with no artifact); build.sh CURRENT-IP detection is subnet-agnostic (no more http://:3000); and the Playwright E2E workflow was disabled. Commits:

</details>

Thanks to above GitHub users for their contributions and translators for their translations.

v9.99 2026-07-17 WeKan ® release

This release fixes the following BUG:

<details> <summary><a href="https://github.com/wekan/wekan/issues/6469">LDAP connection leak: WeKan exhausted the directory server with "too many open connections"</a>.</summary>

LDAP connection leak: WeKan exhausted the directory server with "too many open connections" (#6467, #6469 ). Operators reported that after an update WeKan "dies really quickly", that restarting the server did not help, and that it "kills our openldap server with too many open connections"; others saw logins hang for minutes and then fail with "Must be logged in". Root cause: every LDAP login attempt (packages/wekan-ldap/server/loginHandler.js) and every background sync run (packages/wekan-ldap/server/sync.js) created a fresh new LDAP() and called connect(), but the code never called disconnect() on any path — success, failure, or fallback. Each login attempt (including every failed one) and each background-sync tick (every minute by default) therefore leaked one socket to the LDAP/AD server. Over time this grew without bound until the directory server hit its per-client connection limit and started refusing connections, which took it — and, with it, every WeKan login — down

</details>
  • Fixed by guaranteeing the connection is always released:
    • A small shared helper packages/wekan-ldap/server/connectionGuard.js (runWithLdapDisconnect(ldap, fn)) runs the work and disconnect()s in a finally, on every exit path. The disconnect is best-effort and never masks the original result or error.
    • The LDAP login handler now runs its whole flow through runWithLdapDisconnect, so connect() is always paired with a disconnect() — on a successful login, a thrown Meteor.Error, or a fallback to the default account system.
    • The background sync() releases its connection in a finally, and importNewUsers() disconnects only the connection it opened itself (never a connection borrowed from sync()).
    • The admin ldap_test_connection method (packages/wekan-ldap/server/testConnection.js), which had the same leak, now disconnects on both the success and failure paths, so repeated "Test Connection" clicks no longer leak either.
    • LDAP.disconnect() is now a safe no-op when connect() was never reached, so cleanup can never throw.
  • Tests (tests/ldapConnectionRelease.test.cjs, added to test:unit:node): behavioural coverage of the guard (disconnects after success and returns the result; disconnects exactly once; disconnects and re-throws when the work throws — the failed-login path that exhausted OpenLDAP), negative cases (a failing disconnect never turns a success into a failure nor hides the real error; a missing/null ldap is tolerated), plus source-level guards that the leak-prone call sites actually route through the guard and that connect() lives inside the guarded region.
  • Thanks to the reporting operators and xet7 (fix).

and updates the bundled FerretDB (see the fork's own CHANGELOG Upcoming): the SQLite backend connection pool no longer caps MaxOpenConns at 16, which had starved WeKan's cursor-heavy load and made boards take minutes to load and logins fail with "Must be logged in" (#6467, #6469).

<details> <summary><a href="https://github.com/wekan/wekan/issues/6476">Snap: FerretDB never becoming ready made WeKan hang with no web port and no explanation</a>.</summary>

Snap: FerretDB never becoming ready made WeKan hang with no web port and no explanation ( #6476 ). With database=ferretdb, snap-src/bin/wekan-control waits for FerretDB to accept connections before starting WeKan — WeKan does not open its HTTP port until the database answers. That wait loop had no timeout and no diagnostics: if FerretDB never started listening (a crashed or CPU/architecture-incompatible per-arch binary, a locked or corrupt SQLite database, or the FerretDB service left disabled by a failed migration hand-off), WeKan blocked there forever. The service showed as active, but the port never opened and the only symptom was a silent FerretDB not ready yet, retrying in 5 seconds repeating — exactly what #6476 reported (Apache proxy could not connect to port 3333). The reporter's wekan.log stopped right after the startup env dump, confirming control never reached node main.js

</details>
  • Fixed by mirroring the MongoDB branch: after WEKAN_DB_WAIT_TIMEOUT seconds (default 120, overridable) the FerretDB wait loop now prints an actionable hint once and keeps retrying — pointing to snap logs <snap>.ferretdb for the real error, naming the SQLite directory to check for locks/corruption, the arch-mismatch (exec format error) case, the snap start --enable <snap>.ferretdb recovery, and the snap set <snap> database=mongodb fallback to keep working meanwhile. This turns "port never opens, no clue why" into a clear message. Regression test: tests/ferretdbWaitTimeout.test.cjs, including a negative guard that the FerretDB wait is not a silent unbounded loop again.
  • This is a robustness/diagnostics fix; the underlying reason FerretDB was not accepting connections is environment-specific and lives in the FerretDB service log.
  • Thanks to uusijani (report) and xet7 (fix).

Thanks to above GitHub users for their contributions and translators for their translations.

v9.98 2026-07-17 WeKan ® release

This release fixes the following SECURITY ISSUES found by GitHub CodeQL code scanning:

<details> <summary><a href="https://wekan.fi/hall-of-fame/escapebleed/">EscapeBleed</a>.</summary>

** EscapeBleed : incomplete string escaping when building a regular expression** (GitHub CodeQL code scanning alert #423, rule js/incomplete-sanitization, CWE-116 Improper Encoding or Escaping of Output; tests/maximizedCardPosition.test.cjs). Code that turned a CSS declaration into a RegExp escaped only parentheses (str.replace(/[()]/g, '\\$&')) instead of the full regex metacharacter set — an input containing other metacharacters (including a backslash) would not be escaped correctly, so the generated pattern could match the wrong thing

</details>
  • Fixed by escaping the complete metacharacter set (str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), matching the correct pattern already used elsewhere in the tests. This is test-only code with a fixed, trusted input list, so there was no injection exposure, but the incomplete escape was genuinely wrong.
  • Thanks to GitHub CodeQL (code scanning alert #423) and xet7 (fix).
<details> <summary><a href="https://wekan.fi/hall-of-fame/randombleed/">RandomBleed</a>.</summary>

** RandomBleed : biased random ids from a cryptographically secure source** (GitHub CodeQL code scanning alert #422, rule js/biased-cryptographic-random, CWE-1204 / weak randomness; server/lib/schemaUpgradeSteps.js). The startup schema upgrade generates Meteor-style document ids with crypto.randomBytes(len) mapped through byte % ID_CHARS.length. Because 256 is not a multiple of the 55-character alphabet, that modulo skews generated ids toward the first 36 characters of the alphabet (each ~1.4% more likely than the rest) — reducing entropy of the ids used for the swimlanes/lists/checklist-items the upgrade creates

</details>
  • Fixed with rejection sampling: bytes at or above the largest multiple of the alphabet size (220) are discarded and resampled, so every character is exactly equally likely; ids stay Meteor-style 17 characters (and exact-length for custom lengths). A negative regression test pins that out-of-range bytes are never wrapped.
  • Thanks to GitHub CodeQL (code scanning alert #422) and xet7 (fix).

Thanks to above GitHub users for their contributions and translators for their translations.

v9.97 2026-07-17 WeKan ® release

This release adds the following new features:

<details> <summary><a href="https://github.com/wekan/wekan/issues/6473">Snap: SELF-HEALING attachments — the fixes below apply themselves automatically on upgrade, no…</a> Thanks to mueschel and xet7.</summary>

Snap: SELF-HEALING attachments — the fixes below apply themselves automatically on upgrade, no commands needed ( #6473 , snap-src/bin/attachment-repair (new), snap-src/bin/wekan-control, snap-src/bin/migration-control, snap-src/bin/wekan-force-migrate, releases/migrate-mongodb-to-ferretdb.mjs, snap-src/bin/migrate-mongo3-to-ferretdb.mjs, snapcraft.yaml, snapcraft-core26.yaml). The snap auto-refreshes on ~15k servers, so a fix that needs snap run wekan.migrate typed by hand does not reach most of them — and a full re-migration would be WRONG anyway, because it rebuilds FerretDB from the frozen MongoDB source and would lose boards/cards users created on FerretDB since migrating. Instead, on every start on FerretDB, wekan-control now launches attachment-repair in the background: it starts a temporary source mongod (7.x or the bundled 3.2 reader — the MongoDB data was never modified, so everything is recoverable) and runs the migration importer in a new incremental FILES_ONLY mode that checks what is already migrated and migrates only what is missing: text collections are never touched, every attachment/ avatar whose target record is on fs with the file actually on disk is verified and skipped, records deleted by users since the migration are never resurrected, and only the missing binaries/records are extracted — so on healthy servers the repair is a fast no-op and on #6473-affected servers the attachments simply appear, live, while WeKan runs. It runs once (marker $SNAP_COMMON/.attachments-files-v2-done; a fresh successful migration pre-writes it, snap run wekan.migrate clears it, failures retry on the next start without ever blocking WeKan from starting), and a manual snap run wekan.repair-attachments command is registered too. The importer itself now stamps the migration marker with filesVersion (currently 2) and, when it finds a marker with an older filesVersion — or FILES_ONLY=true in the environment — automatically switches to this incremental repair, so Docker/source installs get the same self-healing by simply re-running the same importer command they migrated with. Verified end-to-end against two live FerretDB instances with the real importer: a broken-migration target (missing CollectionFS record, gridFsFileId-only record, marker without filesVersion) was repaired — record re-created with its card linkage, binary extracted, record repointed — while a board renamed on FerretDB after the migration stayed untouched, and the second run exited immediately as already-migrated. Behavioral tests (positive + negative) drive the real bash script with stubbed snap tooling: tests/attachmentRepair.test.cjs

</details> <details> <summary><a href="https://github.com/wekan/wekan/issues/1971">All platforms: startup schema upgrade — WeKan now CHECKS on start that data from EVERY old WeKan…</a> Thanks to mueschel and xet7.</summary>

All platforms: startup schema upgrade — WeKan now CHECKS on start that data from EVERY old WeKan version has been migrated to the newest database structure, and migrates only what is missing (#6473 follow-up, #1959, #1971 , server/lib/schemaUpgradeSteps.js (new), server/startupSchemaUpgrade.js (new), server/migrations/ensureValidSwimlaneIds.js, server/imports.js). WeKan v0.9–v8.00 ran startup migrations; v8.01 disabled them (large databases meant long downtime) in favour of read-time compatibility — which covers the swimlane era but not everything, so text data from old versions could sit invisible in the database. The new startup upgrade reinstates the safety net without the downtime: version-gated (the _wekan_migration marker stores the WeKan version and datetime of the previous successful re-check, so while the version is unchanged a boot costs ONE findOne — a full re-check is mandatory only after a new WeKan release, or WEKAN_FORCE_SCHEMA_UPGRADE=true; opt out with WEKAN_SKIP_SCHEMA_UPGRADE=true), non-blocking (runs in the background, WeKan serves immediately), with a live migration dashboard at /schema-upgrade-status on every platform that shows the Admin Panel product name when one is set (not "WeKan") plus per-step progress, and fast on big databases (bounded existence probes, distinct() set-joins instead of card scans, and server-side updateMany batches instead of per-document round trips — thousands of cards never mean thousands of queries). Steps, each verified against git show v8.00:server/migrations.js and each idempotent: archived-flag-backfill (docs missing archived never match the archived: false view queries — whole boards/lists/cards were invisible in the Swimlanes and Lists views), swimlane-structure (every board gets a visible swimlane, every list/card a swimlaneId, cards with a dangling listId are rescued to a visible list — and, fixing #1959 and #1971, unarchived cards whose swimlane was DELETED, ARCHIVED or belongs to another board are reassigned to the board's first visible swimlane, so everything is visible in both the Swimlanes view and the Lists view; the card-insert hook now also validates client-supplied swimlaneIds so new cards can never land under a deleted/archived swimlane again), checklist-items-embedded (pre-v0.79 embedded checklist.items[] extracted to the ChecklistItems collection — the text was in the database but never shown), customfields-boardIds (pre-v2.49 scalar boardIdboardIds array — old custom field definitions and their card values were orphaned), board-allows-defaults (the ~33 defaultValue-true allows* flags backfilled — a missing flag rendered as false and HID existing descriptions/checklists/comments/attachments), board-members-isactive (members without isActive were denied board access), board-permission-lowercase ('PUBLIC' boards had silently become member-only), and fs-path-heal (filesystem attachments/avatars whose recorded path predates the current WRITABLE_PATH layout — v6.10-18 uploads/<coll>, v6.19-v8.4x WRITABLE_PATH/<coll>, CFS→ostrio temp files — are located and repointed/copied into the current layout). A step that fails or leaves unresolved work never blocks WeKan from starting and keeps the version un-stamped so the next boot re-checks. 36+ unit tests with negative cases (tests/schemaUpgradeSteps.test.cjs), plus verified end-to-end against a live FerretDB (SQLite) with old-shape seed data: all 8 steps migrate correctly and the second boot is gated to a no-op

</details> <details> <summary>Migration speed: batched writes and preloaded lookups instead of per-document round trips. Thanks to mueschel and xet7.</summary>

Migration speed: batched writes and preloaded lookups instead of per-document round trips (5-hour migrations reported; releases/migrate-mongodb-to-ferretdb.mjs). The text phase now copies each batch with ONE unordered insertMany round trip (falling back to per-document replaceOne upserts only for batches that hit duplicates on resumed/re-run migrations), and the attachment/avatar phases preload the target's metadata records once per bucket instead of one findOne per file (bounded: collections over 100k records fall back to per-file lookups). The startup schema upgrade uses the same philosophy (distinct()/updateMany).

</details>

and fixes the following bugs:

<details> <summary><a href="https://github.com/wekan/wekan/issues/5695">OAuth2/OIDC: OAUTH2_LOGIN_STYLE=redirect was ignored — a popup always opened</a>. Thanks to ArturRuta and xet7.</summary>

OAuth2/OIDC: OAUTH2_LOGIN_STYLE=redirect was ignored — a popup always opened ( #5695 , packages/wekan-oidc/oidc_client.js, server/authentication.js, server/models/settings.js, client/components/main/layouts.js). The setting was stored correctly in the OIDC service configuration, but the login button always passed loginStyle: 'popup', and Meteor's OAuth._loginStyle gives the caller's option precedence — so the admin's redirect setting silently lost on every login (and the Meteor-internal loginStyle option even leaked into the provider's authorization URL). The client now honors a configured loginStyle: 'redirect' over the button's generic popup default (explicit caller choices still win; Safari-private-mode popup fallback kept) and no longer leaks the option to the provider. Also repaired the existing OIDC_REDIRECTION_ENABLED=true "go straight to the provider" feature, which was doubly broken since the Meteor 3 port: isOidcRedirectionEnabled inspected a Promise (always false), and the client handler assigned an undeclared variable (strict-mode ReferenceError). Behavioral tests drive the real client code in a VM against Meteor's loginStyle precedence: tests/oauth2LoginStyle.test.cjs (12 tests, positive + negative)

</details> <details> <summary><a href="https://github.com/wekan/wekan/issues/1289">Deleting a user left "ghost users" on boards and cards</a>. Thanks to chotaire and xet7.</summary>

Deleting a user left "ghost users" on boards and cards ( #1289 , models/users.js, new models/lib/userDeletionCleanup.js): every deletion path (admin method, self-service delete, DELETE /api/users/:userId) removed only the user document, leaving dangling references — empty-avatar board members that could not be removed, stale card members/assignees/watchers, orphaned avatar files — reproducible for 8 years. A server-side Users.after.remove hook now prunes boards.members/watchers, cards.members/assignees/watchers, lists.watchers and the user's avatar files on every deletion path; activities and comments are deliberately kept for history (their rendering is already null-guarded). Tests: tests/userDeletionCleanup.test.cjs (6)

</details> <details> <summary><a href="https://github.com/wekan/wekan/issues/2877">Swimlanes jumped up and down when starting/ending a card drag</a>. Thanks to xet7.</summary>

Swimlanes jumped up and down when starting/ending a card drag ( #2877 , client/components/boards/boardBody.css): drag start hid every list's "+ Add Card" composer link with display: none, collapsing its row — lists shrank, auto-height swimlanes shrank, and every swimlane below jumped up ~23px (and back down on drop), shifting the drop target under the cursor mid-drag (root cause proven by frame-diffing the issue's own GIF). The composer now hides with visibility: hidden, keeping its layout box, so nothing moves; collapsed multi-selection cards stay collapsed intentionally (they preview the post-drop list). Tests: tests/swimlaneDragJump.test.cjs (8)

</details> <details> <summary><a href="https://github.com/wekan/wekan/issues/443">Dragging a card toward an off-screen list never auto-scrolled the board</a>. Thanks to anhenghuang, AlexanderS and xet7.</summary>

Dragging a card toward an off-screen list never auto-scrolled the board ( #443 , client/components/lists/list.js, new imports/lib/boardAutoScroll.js): the horizontal auto-scroll targeted .board-canvas, which only overflows vertically since the swimlane layout — its scrollLeftMax was always 0, so the guard never fired and users had to drop on an intermediate list and scroll by hand. Edge-proximity auto-scroll now drives the .js-lists lane actually under the pointer (clamped, overshoot-safe), with vertical scrolling kept on the canvas. Tests: tests/boardAutoScroll.test.cjs (15, incl. a proof the old no-overflow target could never scroll)

</details> <details> <summary><a href="https://github.com/wekan/wekan/issues/2674">Board Rules: the Card Title Filter did nothing on several triggers — and rules created via the REST…</a> Thanks to InfoSec812, sfahrenholz and xet7.</summary>

Board Rules: the Card Title Filter did nothing on several triggers — and rules created via the REST API never fired at all (#2345, #2674 , client/components/rules/triggers/boardTriggers.js, .jade, server/rulesHelper.js, server/models/rules.js, new models/lib/ruleCardTitleFilter.js, docs/API/Rules.md, docs/API/REST-API.md, api.py): the generic moved/archive trigger builders never saved the filter (and a trigger doc MISSING the field can never satisfy the matcher's $in, so those rules fired for nothing); a set filter never showed in the rule details; archive activities carry no card title so their filters compared against undefined; and REST-created rules skipped the wildcard defaulting entirely — the exact "remove user when moved away" rule from #2674 silently never ran. Filters are now stored (empty → *), shown in the rule description, matched with the title resolved from the card when the activity lacks it, legacy field-less triggers keep matching, the API normalizes missing matching fields to wildcards and validates types, and the rule actions no longer crash on unresolvable usernames or member-less cards. The Rules REST API is now documented (docs/API/Rules.md) and listed in api.py's help with the #2674 two-rule example. Tests: tests/rulesCardTitleFilter.test.cjs (12) and tests/rulesApiTriggerNormalize.test.cjs (19)

</details> <details> <summary><a href="https://github.com/wekan/wekan/issues/574">Sandstorm: username uniqueness probe was case-sensitive and raced concurrent logins</a>. Thanks to mitar and xet7.</summary>

Sandstorm: username uniqueness probe was case-sensitive and raced concurrent logins ( #574 , sandstorm.js, new models/lib/sandstormUsername.js): deriving max, max1, … from the preferred handle matched exact case only (an existing Max did not stop a new max) and the check-then-set window let a concurrent insert claim the name first, aborting the hook with E11000. The probe is now an anchored, escaped, case-insensitive regex and the claim retries the next number on a duplicate-key loss. Tests: tests/sandstormUsername.test.cjs (11)

</details> <details> <summary><a href="https://github.com/wekan/wekan/issues/619">Inviting a second user whose email shares a local part failed with a bare "403"</a>. Thanks to lemoer and xet7.</summary>

Inviting a second user whose email shares a local part failed with a bare "403" ( #619 , server/models/users.js, new models/lib/inviteeUsername.js): inviting [email protected] creates user "cats"; inviting [email protected] then crashed into Meteor's raw 403 Username already exists. The invitee's username now probes cats, cats1, cats2, … to the first free variant, and exhaustion raises the translated error-username-taken instead of a number. Tests: tests/inviteeUsername.test.cjs (11, incl. the literal reported scenario)

</details> <details> <summary><a href="https://github.com/wekan/wekan/issues/1502">Date pickers ignored the configured default time and stored 12:00</a>. Thanks to Vlasterx, saschafoerster, suncobran and xet7.</summary>

Date pickers ignored the configured default time and stored 12:00 ( #1502 , client/lib/datepicker.js, new imports/lib/datePickerTime.js): the due-date picker configures a 17:00 default (and now() for received/start/end), but an inverted guard applied it only when the card ALREADY had a date — exactly when it is unnecessary — so empty time fields fell back to a hard-coded 12:00 on save. The default now pre-fills empty pickers and backs the submit fallback; existing dates keep their own time. The issue's original AM/PM parse mismatch was already resolved by the native date/time inputs (79b94824e). Tests: tests/datePickerDefaultTime.test.cjs (10)

</details> <details> <summary><a href="https://github.com/wekan/wekan/issues/2769">Dragging a card while someone added a card to the target list dropped it into the WRONG swimlane</a>. Thanks to hever and xet7.</summary>

Dragging a card while someone added a card to the target list dropped it into the WRONG swimlane ( #2769 , new client/lib/cardDragGeometry.js, client/components/lists/listBody.js): jQuery UI sortable snapshots container geometry at drag start; a mid-drag DOM insertion (another user's new card, or the drag's own composer auto-close) shifted every swimlane below while the cached rectangles stayed put — the drop landed in the neighbouring swimlane with no visible placeholder. A MutationObserver now refreshes the active drag's geometry on real mid-drag layout changes (sortable's own churn filtered out). Tests: tests/cardDragGeometry.test.cjs (13)

</details> <details> <summary><a href="https://github.com/wekan/wekan/issues/1992">Board import lost card dates</a>. Thanks to xet7.</summary>

Board import lost card dates ( #1992 , models/wekanCreator.js, new models/lib/importedCardDates.js): the importer derived createdAt only from a createCard activity (absent in Sandstorm/pruned exports — dates silently reset to import time) and never imported receivedAt/endAt at all. All five date fields now restore with sane fallbacks (activity → the card's own exported date → import time), and the card creator falls back to the exported userId. The missing-cards half of the report was already fixed by 68e0032c6. Tests: tests/importedCardDates.test.cjs (13)

</details> <details> <summary><a href="https://github.com/wekan/wekan/issues/2292">Archiving a swimlane made its cards disappear — and restore brought back an empty swimlane</a>. Thanks to Cactusbone and xet7.</summary>

Archiving a swimlane made its cards disappear — and restore brought back an empty swimlane ( #2292 , models/swimlanes.js, new models/lib/swimlaneArchive.js): only the swimlane document was flagged; its unarchived cards became invisible everywhere (board views render unarchived swimlanes, Archive lists archived docs). Archiving a swimlane now archives its cards (mirroring lists), and restore brings back exactly the cards archived WITH it — individually archived cards stay archived. Tests: tests/archiveSwimlaneCards.test.cjs (11)

</details> <details> <summary><a href="https://github.com/wekan/wekan/issues/2494">"Move/Copy selection to board" wrote sort: NaN to every card</a>. Thanks to Vermeille and xet7.</summary>

"Move/Copy selection to board" wrote sort: NaN to every card ( #2494 , imports/reactiveCache.js): the client-side noCache card lookup returned a PROMISE since the Meteor 3 port, so the max-sort read was undefined and every moved card got NaN — cards appeared and disappeared and could not be reordered. The uncached client path is synchronous minimongo again. Tests: tests/reactiveCacheNoCacheCard.test.cjs (8, incl. a proof the pre-fix routing yields NaN)

</details> <details> <summary><a href="https://github.com/wekan/wekan/issues/1853">Subtask "View it" did nothing when the subtask lives on another board</a>. Thanks to Vanclief and xet7.</summary>

Subtask "View it" did nothing when the subtask lives on another board ( #1853 , client/components/cards/subtaskViewHelpers.js, subtasks.js): the original crash (board._id of undefined) had become a silent no-op guard — when the deposit board is not in minimongo the button just did nothing. Navigation now falls back to the subtask's own boardId (the route loads the board), and truly broken subtasks warn instead of dying. Tests: tests/subtaskViewNavigation.test.cjs (11)

</details> <details> <summary><a href="https://github.com/wekan/wekan/issues/1554">Labels/members could not be dragged onto cards added after the board rendered</a>. Thanks to Miffe and xet7.</summary>

Labels/members could not be dragged onto cards added after the board rendered ( #1554 , client/components/lists/list.js): the droppable-initializing autorun lost its reactive dependency in 7673c77c5 (2023), so it ran once per list render and later-added minicards silently rejected sidebar drags until the board was re-entered ("works after search-and-back"). Dependency restored via the ReactiveCache. Tests: tests/labelDragDroppable.test.cjs (3)

</details> <details> <summary><a href="https://github.com/wekan/wekan/issues/2306">"Add filtered cards to selection" swept cards from OTHER boards into bulk actions</a>. Thanks to IcedQuinn and xet7.</summary>

"Add filtered cards to selection" swept cards from OTHER boards into bulk actions ( #2306 , client/lib/filter.js, client/lib/multiSelection.js, new models/lib/boardScopedSelection.js): the filter selector carried no boardId, and minimongo legitimately holds foreign-board cards (linked boards, dialogs, notifications) — a bulk archive could silently mutate other boards. The selection and its bulk-action selector are now board-scoped, and foreign ids are rejected at insertion. Tests: tests/boardScopedSelection.test.cjs (17, incl. the exact reported repro)

</details> <details> <summary><a href="https://github.com/wekan/wekan/issues/1437">REST API: login tokens could never be revoked</a>. Thanks to ppouliot and xet7.</summary>

REST API: login tokens could never be revoked ( #1437 , server/apiAuthRoutes.js, new models/lib/apiLogout.js): every POST /users/login minted another ~90-day resume token with no way to invalidate any of them. New POST /users/logout revokes the presented token (or all of the user's tokens with {"all": true}), always scoped to the authenticated user. Tests: tests/apiLogout.test.cjs (12)

</details> <details> <summary><a href="https://github.com/wekan/wekan/issues/2418">Editing one checklist item and clicking another left BOTH edit forms open — and submitting…</a> Thanks to Beebo89 and xet7.</summary>

Editing one checklist item and clicking another left BOTH edit forms open — and submitting overwrote the new item's title with the previous item's text ( #2418 , client/lib/inlinedform.js, new client/lib/inlinedFormManager.js): since a 2021 change, the "close the previously opened inline form" call was a silent no-op (the escape action is disabled for click execution), and the submit handlers grab the template's FIRST textarea — with two forms open the wrong form's text was saved. Subtasks reproduced the full bug; checklists' workaround corrupted the open-form tracker so Escape closed the whole card pane. A small state manager restores the single-open-form invariant (opening a form closes the previous one, without closing popups — preserving the 2021 intent). Tests: tests/inlinedFormSingleOpen.test.cjs (9, incl. the exact reported repro chain)

</details> <details> <summary><a href="https://github.com/wekan/wekan/issues/2989">Advanced Filter never matched date custom fields</a>. Thanks to k1ng440 and xet7.</summary>

Advanced Filter never matched date custom fields ( #2989 , client/lib/filter.js, new imports/lib/advancedFilter.js): the tokenizer treated every / as a regex delimiter even inside quotes, so 'Date de fin' == '06/04/2020' broke tokenizing and the filter silently did nothing; and date custom fields store Date OBJECTS while the selectors compared strings/parseInt — ==/</> could never match and != matched everything. Dates typed in the user's date format (day-first respected) now build half-open Date-range selectors (== means "that day"), with the legacy behavior untouched for non-date fields. Tests: tests/advancedFilterDate.test.cjs (14, incl. a proof the old selector shape never matched a stored Date)

</details> <details> <summary><a href="https://github.com/wekan/wekan/issues/3826">Cards could not be reordered by drag in lists full of subtask cards — with silent data loss on…</a> Thanks to jayki and xet7.</summary>

Cards could not be reordered by drag in lists full of subtask cards — with silent data loss on multi-selection drops ( #3826 , server/models/cards.js, client/components/lists/list.js, new models/lib/cardSortRepair.js): addSubtaskCard inserted EVERY subtask card with the constant sort: -1, so such lists contained only tied sorts; dropping between two equal sorts computes a zero increment, the move modifier came out empty and the card snapped back — and a multi-selection drop wrote the SAME sort to every selected card, permanently destroying their order. Subtask cards now append with a unique sort, and the drop handler detects degenerate (tied/inverted) gaps and repairs the siblings' sorts to a strict order before recomputing the drop index. Tests: tests/subtaskCardReorder.test.cjs (14)

</details> <details> <summary><a href="https://github.com/wekan/wekan/issues/3453">Cross-board subtask full path disappeared after refresh</a>. Thanks to MPeti1 and xet7.</summary>

Cross-board subtask full path disappeared after refresh ( #3453 , server/publications/boards.js, new server/lib/subtaskAncestors.js): the board publication shipped only the DIRECT parent cards, while the full-path label walks the whole ancestor chain client-side — after F5 the grandparents were missing from minimongo and the path truncated/vanished. The publication now walks and publishes the full ancestor chain (batched per level, cycle-safe, tolerant of deleted ancestors). Tests: tests/subtaskAncestors.test.cjs (11)

</details> <details> <summary><a href="https://github.com/wekan/wekan/issues/3748">Linked cards: phantom empty custom-field rows and a template TypeError on every render</a>. Thanks to peterbecich and xet7.</summary>

Linked cards: phantom empty custom-field rows and a template TypeError on every render (from #3748 , models/cards.js, new models/lib/customFieldsWD.js): a linked card keeps the ORIGINAL board's custom-field snapshot; unresolvable definitions rendered as empty {} placeholders — a phantom row per entry and Cannot read properties of undefined (reading 'type') from the card details template (also reachable on normal cards with deleted definitions). Unmatched entries are now skipped. The rest of #3748 is by design: linked cards are pointers that mirror the original; label/custom-field ids are board-scoped, and name-based inheritance is the COPY feature. Tests: tests/customFieldsWD.test.cjs (9)

</details> <details> <summary><a href="https://github.com/wekan/wekan/issues/3199">Archive sidebar: Restore/Delete links floated ambiguously between two archived cards</a>. Thanks to fxkr and xet7.</summary>

Archive sidebar: Restore/Delete links floated ambiguously between two archived cards ( #3199 , client/components/sidebar/sidebarArchives.jade, sidebar.css): each card and its links were loose siblings with near-equal spacing above and below, so the links seemed to belong to the card underneath. Each archived card is now grouped with its own links in one container with a clear separator gap below (RTL-safe logical properties, theme-neutral). Tests: tests/archiveLinkGrouping.test.cjs (9)

</details> <details> <summary><a href="https://github.com/wekan/wekan/issues/3843">Attachments uploaded inside card comments: listing in the card's Attachments section is now…</a> Thanks to jghaanstra and xet7.</summary>

Attachments uploaded inside card comments: listing in the card's Attachments section is now guaranteed and regression-locked ( #3843 , new models/lib/attachmentMeta.js, client/lib/utils.js): the rich comment editor already uploads into the same Attachments collection with the same card meta as the Attachments popup, so they DO list — but nothing pinned that invariant and the meta was built in two places. One shared, null-safe builder now feeds both paths, with tests pinning that gallery queries key on meta.cardId with no source filter (and that board backgrounds stay excluded). Tests: tests/commentAttachmentsList.test.cjs (12)

</details> <details> <summary><a href="https://github.com/wekan/wekan/issues/4822">Maximized card rendered at the wrong place after scrolling in Swimlanes view</a>. Thanks to pravdomil and xet7.</summary>

Maximized card rendered at the wrong place after scrolling in Swimlanes view ( #4822 , client/components/cards/cardDetails.css): the legacy maximized-pane CSS had no position of its own, so the pane stayed an in-flow item inside the scrolled board canvas (off-screen after scrolling down); the desktop-mode floating-window rules also out-specified every maximize geometry rule, and inline drag offsets survived maximizing. The maximized pane is now viewport-fixed with explicit insets that beat both the floating-window rules and stale drag offsets (drag position is restored on minimize), RTL-safe via logical properties. A cascade-resolver regression test pins the behavior against the real stylesheet and fails on the pre-fix CSS: tests/maximizedCardPosition.test.cjs (11 tests)

</details> <details> <summary><a href="https://github.com/wekan/wekan/issues/4036">LDAP group filter locked out admins and rejected multiple groups</a>. Thanks to zeisss-mercedes and xet7.</summary>

LDAP group filter locked out admins and rejected multiple groups ( #4036 , packages/wekan-ldap/server/ldap.js): with LDAP_GROUP_FILTER_ENABLE=true, only members of the single LDAP_GROUP_FILTER_GROUP_NAME group could log in — an admin who was only in LDAP_SYNC_ADMIN_GROUPS could not log in at all, and a comma-separated group list produced the literal filter (cn=A,B) that matches nothing. The filter now ORs across every comma-separated group name and, when admin sync is enabled, also admits the admin-sync groups

</details> <details> <summary><a href="https://github.com/wekan/wekan/issues/4043">Board invitation emails could carry a dead invitation code — and signup then failed with "The…</a> Thanks to jkoenig134 and xet7.</summary>

Board invitation emails could carry a dead invitation code — and signup then failed with "The invitation code doesn't exist" ( #4043 , server/models/settings.js, server/models/users.js, new models/lib/invitationCodeEmail.js): re-inviting an unregistered user re-sent the SAME stale code (invalidated by an earlier OAuth2 signup or deleted account) instead of regenerating it; a failed SMTP send deleted a previously delivered, still-valid code; codes were mailed without checking they exist and are valid; the invitee address was only lowercased client-side; and the signup hook deleted the code BEFORE the account insert was committed, so a failed insert burned the code for every retry. Re-invites now regenerate stale codes (still-valid ones are kept so earlier emails keep working), sends fail loudly on unusable codes, rollback only removes codes the failed send itself created, and consumption happens only after a successful signup. Tests: tests/invitationCodeEmail.test.cjs (14)

</details> <details> <summary><a href="https://github.com/wekan/wekan/issues/4023">Japanese/Chinese UI: the add-card button and footer links wrapped mid-word</a>. Thanks to yuki-snow1823, Sylvain2703 and xet7.</summary>

Japanese/Chinese UI: the add-card button and footer links wrapped mid-word ( #4023 , client/components/forms/forms.css): CJK text has no spaces, so the narrow add-card composer footer broke 追加 / リンク / 検索 / テンプレート between any two characters. The composer/edit footers now use word-break: keep-all with flex-wrap: wrap (wrapping between links, never inside a word) and white-space: nowrap on the button and each link group; Latin wrapping is unchanged and the negative tests pin that no global word-break was introduced. Tests: tests/cjkLabelWrap.test.cjs (9)

</details> <details> <summary><a href="https://github.com/wekan/wekan/issues/4593">Users added to a team AFTER the team was assigned to a board never became board members — and the…</a> Thanks to szymonsztuka and xet7.</summary>

Users added to a team AFTER the team was assigned to a board never became board members — and the Admin Panel bulk team add/remove silently did nothing ( #4593 , server/models/users.js, client/components/settings/peopleBody.js, new models/lib/teamBoardMemberSync.js): assigning a team to a board snapshotted its then-current members, so later joiners could see the board via publications but every authority gate (hasMember, card/list mutations, attachment downloads, export) denied them; and the Admin Panel "Add/Remove team to selected users" used a direct client-side Users.update that server permissions silently deny. editUser/createUser now add new team members to all boards their teams are assigned to (never touching existing member entries, skipping template boards), and the bulk actions go through the admin editUser method. Tests: tests/teamBoardMemberSync.test.cjs (13)

</details> <details> <summary><a href="https://github.com/wekan/wekan/issues/4654">LDAP background sync only worked once per user</a>. Thanks to fabianrbz and xet7.</summary>

LDAP background sync only worked once per user ( #4654 , packages/wekan-ldap/server/ldap.js, sync.js, new userIdFilter.js): getUserById crashed or built the invalid filter (|(=user)) when LDAP_UNIQUE_IDENTIFIER_FIELD was unset/empty (it never consulted LDAP_USER_SEARCH_FIELD, where the stored id actually comes from), and the username sync passed $set as query OPTIONS to findOneAsync — logging "Syncing user username" while writing nothing. New shared buildUserIdFilter() ORs across both configured fields, idAttribute is persisted on new LDAP users, and the username sync actually updates. Tests: tests/ldapUserIdFilter.test.cjs (11)

</details> <details> <summary><a href="https://github.com/wekan/wekan/issues/4825">All Boards page: per-list card counts and member avatars never showed</a>. Thanks to mueschel, Miuler and xet7.</summary>

All Boards page: per-list card counts and member avatars never showed (#5174, #4825 , new models/lib/boardTileData.js, server/publications/boards.js, client/components/boards/boardsList.js, .jade): the helpers were stubbed to [] to stop the #4214 reactive "icons dance", and the gating flags were never published. New non-reactive getAllBoardsTileData method (one boards query, one lists query, one grouped card count) fetched once per page visit; per-board "Show card count per list"/"Show Board members avatars" settings enforced strictly both ways. Tests: tests/boardTileData.test.cjs (17)

</details> <details> <summary><a href="https://github.com/wekan/wekan/issues/5659">Lists rendered with different widths by default</a>. Thanks to butteredCat-2021 and xet7.</summary>

Lists rendered with different widths by default ( #5659 , new models/lib/listWidth.js, client/components/lists/list.js, listHeader.js, models/users.js): the default width was duplicated in four resolution paths that disagreed (270 vs 272), so lists on the same (public) board could differ with no customization. Single source of truth (272), all paths normalize out-of-range values the same way; customized widths still win. Tests: tests/listWidthDefaults.test.cjs (12)

</details> <details> <summary><a href="https://github.com/wekan/wekan/issues/4730">Declining a board invitation kept the board active in the member's overview</a>. Thanks to Griiimm and xet7.</summary>

Declining a board invitation kept the board active in the member's overview ( #4730 , server/models/boards.js, new models/lib/boardInvites.js): the decline flow called quitBoard (deactivate) then acceptInvite, which unconditionally REACTIVATED membership — it also let any removed member re-add themselves. quitBoard now clears the pending invitation (and works for stale-invite-only users); acceptInvite only activates when an invitation actually exists. Tests: tests/boardInvites.test.cjs (11)

</details> <details> <summary><a href="https://github.com/wekan/wekan/issues/4419">After migrating a user from password to LDAP, the old local password still logged in</a>. Thanks to preciousamorc and xet7.</summary>

After migrating a user from password to LDAP, the old local password still logged in ( #4419 , new server/lib/ldapPasswordLoginGuard.js, server/authentication.js): a validateLoginAttempt hook now rejects password-service logins for authenticationMethod: 'ldap' users while LDAP is enabled — respecting the LDAP_LOGIN_FALLBACK=true feature, never touching other services or session resumes, and opt-out-able with LDAP_MIGRATION_ALLOW_PASSWORD_LOGIN=true so no deployment is hard-locked. Tests: tests/ldapPasswordLoginGuard.test.cjs (12)

</details> <details> <summary><a href="https://github.com/wekan/wekan/issues/4158">LDAP_ENCRYPTION=true (the documented value) silently connected WITHOUT encryption</a>. Thanks to farwayer and xet7.</summary>

LDAP_ENCRYPTION=true (the documented value) silently connected WITHOUT encryption ( #4158 , new packages/wekan-ldap/server/encryptionSetting.js, ldap.js, docs/Login/LDAP.md): only the undocumented ssl/tls values did anything, and any other value (including true, which JSON-parses to a boolean) meant silent plaintext. Now true→LDAPS, starttls→STARTTLS, legacy ssl/tls keep their historical meanings with a deprecation notice, and unknown values log a clear warning listing the accepted ones. Tests: tests/ldapEncryptionSetting.test.cjs (22)

</details> <details> <summary><a href="https://github.com/wekan/wekan/issues/4560">OIDC login onto an existing account wiped the profile — avatars and templates disappeared</a>. Thanks to LeoLu-eng and xet7.</summary>

OIDC login onto an existing account wiped the profile — avatars and templates disappeared ( #4560 , server/models/users.js): the OAUTH2_MERGE_EXISTING_USERS merge path replaced the whole profile with the OIDC-derived one, losing avatarUrl, templatesBoardId (+ template swimlanes), language and preferences. The merge now preserves the stored profile and only fills gaps/updates the asserted fullname; the fail-closed linking rules (GHSA-mp7g-hj5q-gxhq) are untouched. Tests: tests/oidcProfileMerge.test.cjs (7, fails on pre-fix code)

</details> <details> <summary><a href="https://github.com/wekan/wekan/issues/4897">OAuth2/OIDC: concurrent logins could contaminate each other's user data — users saw stale or…</a> Thanks to gerardo-junior and xet7.</summary>

OAuth2/OIDC: concurrent logins could contaminate each other's user data — users saw stale or missing emails/username/teams, and the database could disagree with the UI ( #4897 , packages/wekan-oidc/oidc_server.js, packages/wekan-oidc/loginHandler.js). The OIDC server flow kept profile, serviceData and userinfo as MODULE-SCOPE variables shared by every login of every user: fields the current login did not overwrite leaked from the previous user's login (refreshToken, whitelisted id-token claims, branch-dependent email), and because the handler awaits the token/userinfo requests, two interleaved logins wrote into the SAME objects — a login could complete carrying another user's id/email/username, updating the wrong user document. The PROPAGATE_OIDC_DATA group/attribute path additionally ran on implicit GLOBALS (teamArray, isAdmin, user_email, …) with awaits between assignment and use, so concurrent logins could write one user's email/teams/admin flag onto another user's document — real database corruption, matching the "web interface shows different data vs mongodb" report. All login state is now per-login locals, the login handler compares actual values (the old username/fullname comparisons compared a string to an object, always true), and a regression test proves isolation under concurrent logins and fails against the pre-fix code: tests/oidcLoginStateIsolation.test.cjs (11 tests, positive + negative)

</details> <details> <summary><a href="https://github.com/wekan/wekan/issues/6473">Snap/Docker: after the MongoDB → FerretDB migration ALL CollectionFS-era attachments were missing</a>. Thanks to mueschel and xet7.</summary>

Snap/Docker: after the MongoDB → FerretDB migration ALL CollectionFS-era attachments were missing ( #6473 , releases/migrate-mongodb-to-ferretdb.mjs, snap-src/bin/migrate-mongo3-to-ferretdb.mjs, snap-src/bin/migrate-gridfs-to-fs.mjs). Real CollectionFS (old WeKan's FS.Store.GridFS('attachments')) stores each file's GridFS id at copies.<bucket>.key (copies.attachments.key / copies.avatars.key) — but the importers only looked at original.gridFsFileId, gridFsFileId and copies.gridfs.key, none of which exist in that layout. So the modern importer "skipped" every file silently and the MongoDB 3.x importer extracted the binaries but never created an attachment record (and the records it did create lost their meta.cardId/boardId, which CollectionFS keeps at the record's TOP level — an attachment without meta.cardId shows on no card). Either way the migration reported success with zero attachments visible. A new shared resolveCfsGridFsId() resolves the id from all four layouts (original.gridFsFileId, gridFsFileId, copies.<bucket>.key, copies.gridfs.key, then any copies.*.key), the modern importer now drives extraction from cfs_gridfs.<bucket>.files itself (so a missing/empty cfs.<bucket>.filerecord collection no longer skips everything — binaries without a filerecord are still extracted to disk), the mongo3 importer copies the top-level boardId/cardId/listId/swimlaneId/ userId into meta, and filerecords whose binary cannot be located are reported as errors on the migration dashboard instead of being silently dropped. If you already migrated and attachments are missing, run the migration again: snap run wekan.migrate (the source MongoDB data was never modified). Behavioral positive/negative tests: tests/migrationAttachmentExtraction.test.cjs

</details> <details> <summary><a href="https://github.com/wekan/wekan/issues/6473">Snap/Docker: Meteor-Files attachments stored in GridFS without a storage: 'gridfs' flag were left…</a> Thanks to mueschel and xet7.</summary>

Snap/Docker: Meteor-Files attachments stored in GridFS without a storage: 'gridfs' flag were left pointing at a GridFS that no longer exists after migration ( #6473 , releases/migrate-mongodb-to-ferretdb.mjs, snap-src/bin/migrate-gridfs-to-fs.mjs). WeKan's own getFileStrategy serves a version from GridFS when its storage flag says 'gridfs' or it carries a versions.*.meta.gridFsFileId reference — those reference-only records worked fine on MongoDB, but the migration's file phase only matched versions.original.storage: 'gridfs', so their binaries were never extracted and the record kept pointing into the void (404 after the switch). The record scan now matches both forms (and every version, not just original), and a second, bucket-driven sweep walks <bucket>.files by its metadata.fileId back-reference (the way WeKan writes GridFS uploads), recovering binaries even when the record's flags say nothing about GridFS. Any GridFS-flagged version whose binary genuinely cannot be located is reported on the dashboard

</details> <details> <summary><a href="https://github.com/wekan/wekan/issues/6473">Admin Panel &gt; Attachments showed "/data" as the Filesystem Storage path on every platform</a>. Thanks to mueschel and xet7.</summary>

Admin Panel > Attachments showed "/data" as the Filesystem Storage path on every platform ( #6473 , client/components/settings/attachments.js, client/components/settings/settingBody.js, server/models/attachmentStorageSettings.js, models/lib/attachmentStoragePath.js). The Blaze helpers computed the path from process.env.WRITABLE_PATH in the browser, where process.env never has it, so the page always fell back to "/data" — a path that does not exist on a Snap install (the real path is /var/snap/wekan/common/files/attachments), sending admins hunting for a directory that was never there. The client now asks the server via a new admin-only getAttachmentStoragePaths method, whose Snap-aware computation (shared, dependency-free models/lib/attachmentStoragePath.js — WRITABLE_PATH already ends in /files on Snap, /files is appended elsewhere) is also used for the settings document's default filesystem path, which pointed at /data/attachments instead of /data/files/attachments on Docker. Unit tests with negative cases: tests/attachmentStoragePath.test.cjs

</details> <details> <summary><a href="https://github.com/wekan/wekan/issues/6473">Snap: snap run wekan.database ferretdb looked like it re-ran the migration — it only switches…</a> Thanks to mueschel and xet7.</summary>

Snap: snap run wekan.database ferretdb looked like it re-ran the migration — it only switches databases ( #6473 , snap-src/bin/wekan-database). Running it while already on FerretDB printed "WeKan now uses FerretDB (SQLite)." and users reasonably read that as "migration done" while their attachments stayed missing. It now says when nothing was switched, states that the command does NOT migrate data, and names the command that does: snap run wekan.migrate

</details> <details> <summary><a href="https://github.com/wekan/FerretDB">FerretDB (SQLite) rejected documents with literal dotted field names, silently dropping them during…</a> Thanks to mueschel and xet7.</summary>

FerretDB (SQLite) rejected documents with literal dotted field names, silently dropping them during migration (#6473, wekan/FerretDB internal/types/document_validation.go). MongoDB has accepted documents with literal . in field names since 3.6, and data migrated from a real MongoDB can legitimately contain them — but FerretDB v1's document validation rejected every such document ("invalid key: … (key must not contain '.' sign)"), and since per-item migration errors are deliberately non-fatal (#6466), those documents simply went missing. Fixed in the bundled wekan/FerretDB fork: dotted keys are stored and round-tripped literally with MongoDB's own semantics (query/update paths still treat . as a path separator), while the other key rules ($ prefix, duplicates, UTF-8) still reject. Verified end-to-end against a live FerretDB (SQLite): insert, nested $set, round-trip, and the negative cases

</details>

Thanks to above GitHub users for their contributions and translators for their translations.

v9.96 2026-07-16 WeKan ® release

This release adds the following updates:

<details> <summary>Releases now include the Sandstorm .spk package. Thanks to xet7.</summary>

Releases now include the Sandstorm .spk package (.github/workflows/release-all.yml). The release-all workflow got a new build-sandstorm job that runs after the GitHub Release is created: it builds the Sandstorm package from the release tag with the same steps as the standalone sandstorm.yml workflow and attaches wekan-<version>-sandstorm.spk to the Release. The job is non-blocking (continue-on-error), so the experimental Sandstorm build never fails or delays the rest of the release. The standalone sandstorm.yml workflow still exists for building and testing the .spk on its own without doing a full release.

</details> <details> <summary><a href="https://github.com/wekan/wekan/issues/6458">New general cpu-exec + bundled qemu-user: every WeKan platform now runs binaries that need missing…</a> Thanks to a1bert01 and xet7.</summary>

New general cpu-exec + bundled qemu-user: every WeKan platform now runs binaries that need missing CPU features through emulation automatically ( #6458 , snap-src/bin/cpu-exec, snap-src/bin/mongodb-control, snap-src/bin/migration-control, .github/workflows/release-all.yml, .github/workflows/sandstorm.yml, sandstorm-src/build-deps.sh, releases/ferretdb/start-wekan.sh, releases/ferretdb/wekan-entrypoint.sh, docs/Databases/mongodb-avx-qemu.md, docs/Databases/mongodb-raspi4-qemu.md). The #6458 report turned out to run inside a hypervisor that MASKS AVX — and the snap's old per-tool AVX wrappers (amd64-only, PATH-based) were bypassed by every absolute-path mongod invocation. The new cpu-exec helper is one general mechanism for all scripts, sandboxes, platforms and CPUs: cpu-exec --features x86_64=avx,aarch64=atomics <binary> [args] checks /proc/cpuinfo and, when a required feature is missing, transparently re-runs the binary through a same-architecture qemu-user (bundled first, then system); with no declared features it is a plain zero-overhead exec, so every binary can be routed through it (WEKAN_REQUIRED_CPU_FEATURES declares requirements externally). The Snap's mongodb-control and migration-control now run every mongod 7 through it — so MongoDB works (slower) on CPUs without AVX and the migration can READ modern MongoDB data there, with the FerretDB switch/migration fallbacks unchanged; aarch64=atomics covers MongoDB's ARMv8.2-A requirement (Raspberry Pi 4 and older lack it). release-all.yml now ships cpu-exec plus this-arch's static qemu-user in every Linux bundle .zip (amd64/arm64/ppc64le/s390x/riscv64 — stripped from the Windows/macOS bundles, where qemu-user does not exist), which flows into the Docker image and the Snap automatically, and the Sandstorm .spk gets both via build-deps.sh; the bundle launcher and Docker entrypoint route node/ferretdb through it

</details> <details> <summary>Added regression tests, with negative cases, for all of the fixes below. Thanks to xet7.</summary>

Added regression tests, with negative cases, for all of the fixes below (tests/htmljsArrayContent.test.cjs, tests/cardDescriptionDraft.test.cjs, tests/commentDraft.test.cjs, tests/attachmentDeleteGuard.test.cjs, tests/ruleMoveAction.test.cjs, tests/snapMigrationRecovery.test.cjs, tests/ferretdbPolling.test.cjs, tests/uiDensity.test.cjs, tests/cpuExec.test.cjs — a BEHAVIORAL test that executes the real cpu-exec against fake /proc/cpuinfo files and a fake qemu-user, covering direct exec, qemu fallback, missing-qemu error, per-arch scoping and env overrides —, tests/cpuExecWiring.test.cjs — pins the cpu-exec DELIVERY pipeline: every Linux bundle in release-all.yml embeds cpu-exec plus its own arch's qemu-user (arm64/extra arches replace the inherited amd64 one, tolerantly), the Windows and macOS bundles strip both, qemu-user-static is installed in every bundle-building job, the Sandstorm .spk ships both via build-deps.sh, the Docker entrypoint and bundle launcher route ferretdb/node through cpu-exec WITH direct-exec fallbacks for bundles that lack it, and the snap ships it via the snap-src helpers part —, tests/subtasksDefaultBoard.test.cjs (see the #6456 entry below), and tests/ferretdbHasData.test.cjs — a BEHAVIORAL test executing the real snap-src/bin/ferretdb-has-data guard (the check that gates every switch to FerretDB) against crafted directories: non-empty .sqlite passes with no -wal sidecar required, while a 0-byte .sqlite from a failed migration, sidecar-only leftovers, a directory named *.sqlite, and empty/missing directories are all rejected — all wired into test:unit:node in package.json; plus Go table tests in the wekan/FerretDB fork's internal/backends/sqlite/query_test.go and the fork's integration-test fixes (OTel exporter skipped with a single log line when no collector is listening, and valid span contexts without a collector so TestOtelComment passes — details in the fork's own CHANGELOG Upcoming). The htmljs test exercises the vendored compiler's Tag constructor directly (array content vs. attributes, #6459) and the draft tests run the real extracted normalize/normalizeTrigger functions; the UI tests guard the #6465 density fixes in the repo's CSS-guard style (base font 14px, card details docking right of the board instead of over its own card, compact admin table headers, un-clipped zoom pill, one-click board settings cog). The FerretDB Go tests pin the SQLite filter-pushdown semantics: which strings are pushdown-safe, exact WHERE/args for _id and top-level equality filters, and that dotted paths, operators, non-strings and unsafe strings stay with the in-Go filter.

</details>

and fixes the following bugs:

<details> <summary><a href="https://github.com/wekan/wekan/issues/6456">Subtasks: creating a subtask crashed with "Exception while invoking method 'addSubtaskCard'"</a>. Thanks to sjohnen and xet7.</summary>

Subtasks: creating a subtask crashed with "Exception while invoking method 'addSubtaskCard'" ( #6456 , models/boards.js, tests/subtasksDefaultBoard.test.cjs). The lazily-creating getters for the default subtasks helper board and its landing list still used the SYNC Boards.insert / Swimlanes.insert / Lists.insert / Boards.update APIs, which Meteor 3 removed from the server — so the addSubtaskCard method's async path crashed with "insert is not available on the server. Please use insertAsync() instead" the first time a board needed its ^Board^ helper board created (the list creator additionally read getDefaultSwimline()._id, which on the Meteor 3 server is a Promise, so it could never have worked). The sync getters are now PURE (no creation — also matching the #3868/#2256 rule that only the server may create these), and the server-side lazy creation lives in getDefaultSubtasksBoardAsync/getDefaultSubtasksListAsync using the async APIs. The never-called date-settings twins, which had the same sync calls and no server-only guard at all, are pure getters now too. With a regression test guarding that no sync collection writes come back to models/boards.js

</details> <details> <summary><a href="https://github.com/wekan/wekan/issues/6459">GUI: the "More" menu of cards was completely empty, so cards could not be deleted from it</a>. Thanks to thku and xet7.</summary>

GUI: the "More" menu of cards was completely empty, so cards could not be deleted from it ( #6459 , npm-packages/meteor-jade-loader/lib/vendor/htmljs.js, client/components/lists/listHeader.jade). A WeKan-local vm-sandbox patch in the vendored jade compiler's htmljs made isConstructedObject(Array) return false (upstream returns true), so a tag whose inline text compiles to an ARRAY — the label {{_ 'source-board'}}: and label {{_ 'parent-card'}}: lines in cardMorePopup — got its content array mis-assigned as the tag's attributes. At runtime Blaze then found a template view object inside the attributes and threw "The basic TransformingVisitor does not support foreign objects in attributes" on every render, killing the whole popup — including the card Delete link. The tag constructor now treats an array first-argument as content, like upstream htmljs. Also fixed the list "More" popup's copy-link input, which was always empty because it referenced a rootUrl helper that does not exist (now absoluteUrl). Board and swimlane menus intentionally offer archive (delete lives in Sidebar → Archive), so only the card menu was actually broken

</details> <details> <summary><a href="https://github.com/wekan/wekan/issues/1287">Cards: the "You have an unsaved description" warning could never be cleared by saving</a>. Thanks to C0rn3j and xet7.</summary>

Cards: the "You have an unsaved description" warning could never be cleared by saving (#6455, reincarnation of #1287 , client/components/cards/cardDetails.js, client/components/cards/cardDescription.js). Two bugs: closing the description editor only avoided adding a draft when the text matched the saved description — it never removed a pre-existing draft record, so once the warning appeared, "View it" → Save could not clear it, only Discard could. And the comparison matched a per-line-whitespace-stripped draft against the raw stored description (or null when empty), so descriptions with Markdown " " hard-breaks re-created a phantom draft on every save. Saving now removes the draft record explicitly, and both sides of the comparison are normalized the same way

</details> <details> <summary><a href="https://github.com/wekan/wekan/issues/5547">Comments: a comment being written was lost when the card was closed or a click landed outside the…</a> Thanks to Finnlife, webenefits and xet7.</summary>

Comments: a comment being written was lost when the card was closed or a click landed outside the card ( #5547 , client/components/activities/comments.js). The comment-draft machinery existed (the form even prefills from it) but had been disarmed since 2019: the escape handler that saved the draft was gated on a "form is open" flag that nothing ever set — the setter was removed back then because the handler also cleared the visible text on every outside click. Now the draft is saved continuously (debounced) while typing and flushed when the form is torn down, submit removes the draft, and the escape handler no longer clears the visible text — so an unfinished comment survives closing the card, and reopening the card restores it into the form

</details> <details> <summary><a href="https://github.com/wekan/wekan/issues/5282">Attachments: deleting an attachment could log a client-side "Removed nonexistent document"…</a> Thanks to lupuszr and xet7.</summary>

Attachments: deleting an attachment could log a client-side "Removed nonexistent document" exception even though the delete succeeded ( #5282 , client/components/cards/attachments.js). Same class as the fixed #3252 for comments and checklists: under publication churn the attachment document can already be evicted from Minimongo when the confirm handler runs, and removing a missing _id throws. The delete now only runs when the document is still in the local cache — the comment/checklist guards' missing sibling

</details> <details> <summary><a href="https://github.com/wekan/wekan/issues/6472">Rules: the "move card to top/bottom" actions did nothing</a>. Thanks to jmb26240 and xet7.</summary>

Rules: the "move card to top/bottom" actions did nothing ( #6472 , server/rulesHelper.js, client/components/rules/actions/boardActions.js, client/components/rules/rulesImportExport.js, server/rulesButton.js, models/lists.js). A pile-up of five bugs, all silent because the activity hook swallows rule-action errors: (1) an unresolved destination list (typo'd, renamed, case-mismatched, or on another board) crashed on list.cardsUnfiltered — now it falls back to the card's current list; (2) the classic rule wizard's generic "move to top/bottom" stored the field as listTitle, which the rule engine never reads (listName) — so every such rule created from the wizard has never worked; (3) an empty destination list made Math.min()/Math.max() of nothing write a corrupt sort: ±Infinity; (4) the rules JSON/CSV import created rules with raw client inserts that the board-admin-only allow rules reject into minimongo limbo — it now uses the same rules.createRule server method as the wizard, and defaults missing trigger matching fields (e.g. userId) to the * wildcard so hand-written JSON matches; (5) rules.createRule let an empty boardId: '' from a not-yet-loaded board selector override the real board. Also fixed the server-side orphaned-cards fallback in List.cards()/cardsUnfiltered(), which silently never applied because an async lookup was read synchronously

</details> <details> <summary><a href="https://github.com/wekan/wekan/issues/6465">GUI: desktop density regressions from the v7.98–v8.18 mobile UI work</a>. Thanks to Mintyt, csonkaoszimt, micha141076 and xet7.</summary>

GUI: desktop density regressions from the v7.98–v8.18 mobile UI work ( #6465 , client/components/main/layouts.css, client/components/cards/cardDetails.css, client/components/main/header.css, client/components/settings/peopleBody.jade, client/components/settings/settingBody.css, client/components/boards/boardHeader.jade). Restores the WeKan 6.09 desktop look users asked for: the base font is back to 14px (the clamp(...2.5vw...) introduced in v7.98 and raised in v8.02 resolved to 18px on any window wider than 720px — +28.5% on every font, button and input, the "everything is way too big" complaint) and headings back to 22/18/16px; the card details window no longer opens as a huge floating sheet ON TOP of its own card — it docks to the right edge like the classic side panel (still movable by its drag handle), with 6.09's 20px content padding instead of ~48px white borders, a 3px corner radius, and without the v8.18 rule that forced ALL card text to the title's size; the All Boards page header band is back to 6.09's compact padding; the Admin Panel Organizations/Teams tables no longer explode column widths ("Select all / Unselect all" header links are now compact icons and header cells may wrap); the zoom pill no longer renders cut off (fixed-pixel pill inside the 28px quick-access row); and board settings opens with ONE click from a new cog button in the board header (the sidebar path still works). Follow-ups caught by the Playwright suite: archiving or deleting a card now also CLOSES its details window (the card id stayed in the openCards session list, so the right-docked window kept rendering exactly over the archives sidebar and intercepted its Restore/Delete clicks), and an OPEN sidebar now stacks above the card window (z-index 2002 vs 2001) so the sidebar is always usable while a card is open. With Playwright coverage: a new spec proves board settings opens in one click from the header cog (and that the popup closes again), and the archives spec's board-menu click is scoped to the sidebar instance since the cog made the bare class selector ambiguous. Note: a missing watch "eye" icon is the Admin Panel → Features → Notifications "disable watch" setting, not a regression

</details> <details> <summary><a href="https://github.com/wekan/wekan/issues/6466">Snap: WeKan served "502 Bad Gateway" forever when mongod could not start — including on CPUs…</a> Thanks to kiarn and xet7.</summary>

Snap: WeKan served "502 Bad Gateway" forever when mongod could not start — including on CPUs without AVX ("Illegal instruction") (#6458, #6466 , snap-src/bin/mongodb-control, snap-src/bin/migration-control). MongoDB 5.0+ x86_64 binaries require AVX; on CPUs without it mongod dies instantly with SIGILL (exit 132). mongodb-control never checked the mongod fork's exit status: it pinged the dead port for ~10 minutes, snapd restarted the service, and the cycle repeated forever — same limbo as when the data files are still MongoDB 3.x ("This version of MongoDB is too recent"). Now there is an AVX pre-flight and the fork/final-start exit codes are checked: if a COMPLETED FerretDB migration exists the snap switches to it; otherwise the MongoDB → FerretDB migration is (re)run — FerretDB is pure Go + SQLite and the 3.x reader uses the bundled MongoDB 3.2 tools, so neither needs AVX — with a 3-attempt counter so a persistently failing migration cannot ping-pong, and clear log guidance (snap run wekan.migrate). migration-control also skips the pointless mongod 7 probe when AVX is missing

</details> <details> <summary><a href="https://github.com/wekan/wekan/issues/6466">Snap: a migration that finished with a few per-item errors ("Avatars Errors") deleted the…</a> Thanks to Nissulya, S0QR2, lezioul, usrflo and xet7.</summary>

Snap: a migration that finished with a few per-item errors ("Avatars Errors") deleted the fully-copied FerretDB database and left the snap serving 502 Bad Gateway ( #6466 , snap-src/bin/migrate-mongo3-to-ferretdb.mjs, releases/migrate-mongodb-to-ferretdb.mjs, snap-src/bin/migration-control). Both importers treated ≥10 logged errors of ANY kind as failure — but per-item errors (one document that fails JSON parsing, one avatar that fails to extract) don't invalidate everything that DID copy. The failure path then discarded the whole migrated SQLite, set migrate=off, and "fell back" to MongoDB — impossible for a 6.09 upgrade, whose 3.x data files the bundled mongod 7 cannot open, producing the reported endless db-eval.mjs ping loop and 502. Per-item errors are now logged but non-fatal (only real failures — disk full, unreachable target/source — still fail), and a failed 3.x-source migration keeps the partial FerretDB SQLite plus its checkpoint and RESUMES on the next start instead of deleting hours of copied data

</details> <details> <summary><a href="https://github.com/wekan/wekan/issues/6468">Snap/Bundle: FerretDB v1 pinned 250–400% CPU and boards took minutes to load after migration</a>. Thanks to anlx-sw, markusst1982 and xet7.</summary>

Snap/Bundle: FerretDB v1 pinned 250–400% CPU and boards took minutes to load after migration (#6467, #6468 , snap-src/bin/wekan-control, releases/ferretdb/start-wekan.sh, and the wekan/FerretDB fork). Two sides. WeKan side: with FerretDB there is no oplog, so Meteor observes every query by POLLING — and its defaults re-run every observed query 50 ms after ANY write and at least every 10 s, which on an active board multiplies into hundreds of full queries per second; with FerretDB the snap and the bundle launcher now default to METEOR_POLLING_THROTTLE_MS=2000 / METEOR_POLLING_INTERVAL_MS=30000 (overridable; own changes still appear instantly, other users' changes may take ~2 s longer). FerretDB side (fork v1.28): real filter pushdown so {boardId: X} uses the SQLite expression indexes instead of decoding the whole 53k-card collection per query, a connection pool cap of 2×CPUs (was 100 — dozens of concurrent full scans thrashing the pure-Go SQLite mutexes were the reported 821k futex calls/30 s), and inserts no longer take the registry's global write lock

</details> <details> <summary><a href="https://github.com/wekan/wekan/pull/6469">LDAP: group search filters were double-escaped and broke group filtering</a>. Thanks to ChristianMa97.</summary>

LDAP: group search filters were double-escaped and broke group filtering (#6460, PR #6469 , packages/wekan-ldap/server/ldap.js). The group filter was post-processed with a global backslash-doubling replace, a leftover workaround from the ldapjs era that predates the proper escapedToHex hex escaping. It turned already-correct RFC 4515 escapes like \5c and \28 (an AD DN with an escaped comma, a group name with parentheses) into \\5c/\\28, which ldapts' strict filter parser rejects with "Invalid escaped hex character" — so group filtering, admin-status sync and role sync failed for exactly those directories. The redundant replace is removed; injection protection is unchanged (escapedToHex still hex-escapes the username)

</details> <details> <summary><a href="https://github.com/wekan/wekan/pull/6470">LDAP: enabling org/team sync made every LDAP login fail with 'forbidden'</a>. Thanks to ChristianMa97 and xet7.</summary>

LDAP: enabling org/team sync made every LDAP login fail with 'forbidden' (#6461, PR #6470 , packages/wekan-ldap/server/loginHandler.js, packages/wekan-ldap/server/sync.js). On the server, a nested Meteor.callAsync inherits the current method invocation's connection, so when the login handler called the setUserOrgsTeamsFromLdap method, its admin guard saw the client's login connection with no logged-in user yet and rejected the sync — and the unhandled rejection failed the whole login. (The nightly cron sync runs outside a method invocation, so it was unaffected — which is why this hid.) The login-time sync call now clears the inherited invocation context so it is a true server-to-server call, and org/team sync is additionally wrapped so an optional-enrichment failure is logged instead of blocking login. The same PR also throws the account-creation error from addLdapUser at the right point (it was previously used as a user object first), fixes external-avatar localization to use Avatars.writeAsync (the callback-style write no longer exists in ostrio:files 3.x, so localizing avatars silently did nothing), and adds */? wildcard support with unit tests to the LDAP_SYNC_ORGANIZATIONS_GROUPS / LDAP_SYNC_TEAMS_GROUPS allowlists

</details>

Thanks to above GitHub users for their contributions and translators for their translations.

v9.95 2026-07-15 WeKan ® release

This release fixes the following bugs:

<details> <summary><a href="https://github.com/wekan/wekan/issues/6457">FerretDB: WeKan did not work after migrating to FerretDB — every logged-in browser was logged out…</a> Thanks to markusst1982 and xet7.</summary>

FerretDB: WeKan did not work after migrating to FerretDB — every logged-in browser was logged out again and the database CPU was pinned at 100% ( #6457 , server/accounts-resume-login.js, server/imports.js). Meteor's accounts-base "resume" login handler projects one array element with the positional operator ({fields: {'services.resume.loginTokens.$': 1}}), and FerretDB rejects that find with "Executor error during find command :: caused by :: positional operator '.$' couldn't find a matching element in the array" (code 51246). Resume is how an already-logged-in browser re-authenticates on every page load and every DDP reconnect, so the throw logged the user out, the client reconnected, resume threw again, and the retry loop pinned the FerretDB CPU — the board looked broken right after a migration that had just taken hours. WeKan now replaces that login handler with one that projects the whole (small) loginTokens array and picks the matching token in JavaScript — which is what upstream already does in its own $or fallback query, so nothing else about login behaviour changes

</details> <details> <summary>Snap: a snap refresh during the MongoDB → FerretDB migration threw away hours of migration progress. Thanks to markusst1982 and xet7.</summary>

Snap: a snap refresh during the MongoDB → FerretDB migration threw away hours of migration progress (snap-src/bin/migration-control, releases/migrate-mongodb-to-ferretdb.mjs, snap-src/bin/migrate-mongo3-to-ferretdb.mjs). A big migration can run for 5 hours, so being interrupted by a snap refresh, snap stop or a reboot is normal — but it was treated as a failure: snapd's SIGTERM killed the importer, migration-control read its signal exit code (143) as "the migration failed", and fail_and_run_mongodb deleted the partial FerretDB SQLite and set migrate=off. Every refresh meant starting the whole migration from zero. Now an interruption is distinguished from a failure (a SIGTERM/SIGINT trap, plus an importer exit code >= 128) and keeps the partial database and the checkpoint, leaves auto-migration on, and hands back to MongoDB so WeKan keeps working until the next start resumes.

</details> <details> <summary>Snap: an interrupted migration re-extracted every attachment, needing double the disk space. Thanks to markusst1982 and xet7.</summary>

Snap: an interrupted migration re-extracted every attachment, needing double the disk space (releases/migrate-mongodb-to-ferretdb.mjs, snap-src/bin/migrate-mongo3-to-ferretdb.mjs). Only fully-copied collections were checkpointed; the file phase started over, and because the destination path was picked with a "add a _1, _2, … counter while the file exists" loop, every already-extracted attachment was written a second time under a new name, orphaning the first copy — so a resumed migration silently needed twice the disk the up-front space check had budgeted for. Extracted files are now checkpointed individually (recorded only once the bytes are on disk and the record points at them, and re-verified by size on resume, so a half-written file is never mistaken for a finished one) and skipped when resuming, and the destination path is deterministic — an existing file at that path can only be this record's own partial extraction, so it is overwritten. The MongoDB 3 importer had no checkpoint at all and now resumes collections and files the same way.

</details> <details> <summary>Snap: discarding a partial FerretDB migration left the resume checkpoint behind, so the retry could… Thanks to markusst1982 and xet7.</summary>

Snap: discarding a partial FerretDB migration left the resume checkpoint behind, so the retry could switch to a database missing most of its data (snap-src/bin/migration-control). The importer's checkpoint lists the collections it has already copied and lives in $SNAP_COMMON, not in the SQLite directory that discard_partial_ferretdb wipes — so it survived. The next migration then trusted it, skipped every "already migrated" collection, copied only the rest into the now-empty database, and reported success — leaving the snap serving a FerretDB missing most of its data. The checkpoint is only meaningful together with the SQLite it describes, so the two are now always discarded together.

</details> <details> <summary>Snap: releases are now published to the Snap Store stable channel automatically. Thanks to xet7.</summary>

Snap: releases are now published to the Snap Store stable channel automatically (.github/workflows/release-all.yml). Both snap jobs (native and Launchpad) pushed only to candidate, beta and edge, and stable had to be released by hand afterwards. Both now publish to stable,candidate,beta,edge.

</details>

Thanks to above GitHub users for their contributions and translators for their translations.

v9.94 2026-07-15 WeKan ® release

This release adds the following new features:

<details> <summary>Snap: the database setting is now authoritative, with a snap run wekan.database mongodb|ferretdb… Thanks to xet7.</summary>

Snap: the database setting is now authoritative, with a snap run wekan.database mongodb|ferretdb command and no-downtime handling of a failed migration (snap-src/bin/wekan-database, snap-src/bin/wekan-control, snap-src/bin/mongodb-control, snap-src/bin/ferretdb-control, snap-src/bin/migration-control, snap-src/bin/migration-pending). Previously the control scripts force-switched to FerretDB whenever a *.sqlite file existed, so snap set wekan database=mongodb would not stick and there was no way to keep WeKan on MongoDB while fixing a migration — a failed migration meant downtime. Now the database setting decides which database WeKan runs on, and snap run wekan.database mongodb switches WeKan to MongoDB (and pauses auto-migration, migrate=off) while snap run wekan.database ferretdb switches to the migrated FerretDB. Auto-migration can be paused with snap set wekan migrate=off and, crucially, a migration that FAILS now pauses itself and hands back to MongoDB automatically (migration-control fail_and_run_mongodb) so WeKan keeps working on MongoDB instead of retrying-and-failing every start; re-run it with snap run wekan.migrate. A successful migration also restarts wekan.wekan so it reconnects to FerretDB. The only remaining auto-override is the safety guard that refuses to start an empty FerretDB while MongoDB still holds data.

</details>

and fixes the following bugs:

<details> <summary>Snap: the MongoDB → FerretDB migration failed on GridFS collections and left a half-migrated… Thanks to xet7.</summary>

Snap: the MongoDB → FerretDB migration failed on GridFS collections and left a half-migrated database behind (releases/migrate-mongodb-to-ferretdb.mjs, snap-src/bin/migrate-mongo3-to-ferretdb.mjs, snap-src/bin/migration-control). FerretDB v1 rejects collection names containing a dot ("invalid key: 'attachments.chunks' (key must not contain '.' sign)"), and the importers tried to copy the GridFS internals collections (attachments.chunks, attachments.files, avatars.*, cfs_gridfs.*) and the CollectionFS cfs.<bucket>.filerecord collections as text, so the migration aborted. Now the text phase skips every dotted collection — none of WeKan's real data collections contain a dot, and the dotted ones are all GridFS internals (extracted in the file phase), CollectionFS filerecords (turned into bare attachments/avatars records in the file phase, now created with an upsert) or system.* collections. And crucially, when a migration fails partway it now deletes the partial FerretDB SQLite it wrote (migration-control's discard_partial_ferretdb): otherwise that non-empty-but-incomplete files/db/wekan.sqlite looked "migrated" to the data check, so the snap disabled MongoDB and tried to serve an incomplete FerretDB. Now a failed migration cleanly keeps MongoDB and retries.

</details>

Thanks to above GitHub users for their contributions and translators for their translations.

v9.93 2026-07-15 WeKan ® release

This release adds the following new features:

<details> <summary>Snap: new snap run wekan.migrate command to force a fresh MongoDB → FerretDB migration. Thanks to xet7.</summary>

Snap: new snap run wekan.migrate command to force a fresh MongoDB → FerretDB migration (snap-src/bin/wekan-force-migrate, registered in snapcraft.yaml). Ignores the database setting and the migration marker, removes any partial FerretDB SQLite + resume checkpoint, then re-runs the migration so it re-reads the existing MongoDB (3 or 7) data and migrates text + attachments + avatars to FerretDB again. The source MongoDB data is never modified or deleted. Watch it with snap logs -f wekan.mongodb or the migration dashboard.

</details> <details> <summary>Snap: new maintenance mode (snap run wekan.maintenance on|off) so you can run MongoDB OR FerretDB… Thanks to xet7.</summary>

Snap: new maintenance mode (snap run wekan.maintenance on|off) so you can run MongoDB OR FerretDB by hand for data access (snap-src/bin/wekan-maintenance, snap-src/bin/wekan-maintenance-page.mjs, and the DB control scripts). While the $SNAP_COMMON/.wekan-maintenance marker is present, the control scripts do not auto-migrate or auto-disable, so snap start wekan.mongodb (old MongoDB data) or snap start wekan.ferretdb (migrated FerretDB SQLite data) stays up instead of shutting itself down — letting you reach the data over the MongoDB wire protocol on port 27019 (one at a time; they share the port). WeKan itself serves an "under maintenance" page (HTTP 503) on the web port for all URLs while maintenance is on, so end users see a clear message. The page shows the Admin Panel product name if one is set (not "WeKan"): it is cached to $SNAP_COMMON/.productname.txt while a database is running (by wekan-control on startup and by the migration importers), so it is still available in maintenance mode when both databases are stopped.

</details>

and fixes the following bugs:

<details> <summary>Snap: db-eval could not load the MongoDB driver, so EVERY database readiness check silently failed. Thanks to xet7.</summary>

Snap: db-eval could not load the MongoDB driver, so EVERY database readiness check silently failed (snap-src/bin/db-eval.mjs; also migrate-schema-v843.mjs, migrate-gridfs-to-fs.mjs). db-eval is the small Node helper the snap uses (instead of mongosh) to check whether MongoDB/FerretDB is up, elect the replica-set primary, and run the migration's mongod-7 readiness probe. It did import { MongoClient } from 'mongodb' and the wrapper set NODE_PATH=$SNAP/programs/server/node_modules to point at the bundled driver — but Node's ESM loader ignores NODE_PATH (that env var is honored only by the CommonJS loader). So the import resolved nothing, db-eval exited before ever opening a connection, and every ping/primary/rs-* check "failed." This was the single root cause behind a whole family of symptoms: WeKan looping "MongoDB not ready yet, retrying…" or "FerretDB not ready yet…" forever even though the database was running and listening; replica-set initialisation failing; and — most damaging — the MongoDB → FerretDB migration falling back to MongoDB 3.2 because the mongod-7 readiness probe never connected (mongod 7 opened the data fine, but db-eval couldn't reach it, so migration-control wrongly concluded "mongod 7 can't open this" and tried the 3.2 reader, which cannot read WiredTiger-7 files — producing no FerretDB SQLite). Proven by the migration source mongod log: mongod 7 reached Waiting for connections with zero Connection accepted before being killed by the readiness timeout. Fixed by resolving the driver with createRequire (CommonJS, which does honor NODE_PATH and the bundle layout), anchored inside the modern bundle.

</details> <details> <summary>Snap: the presence of a FerretDB SQLite database — not the migration marker — now decides whether… Thanks to xet7.</summary>

Snap: the presence of a FerretDB SQLite database — not the migration marker — now decides whether to use FerretDB, so a FAILED migration no longer leaves the snap with no database (snap-src/bin/ferretdb-control, snap-src/bin/mongodb-control, snap-src/bin/wekan-control). An earlier attempt keyed the "use FerretDB / disable MongoDB" decision off the $SNAP_COMMON/.migration-to-ferretdb-done marker. But migration-control also writes that marker when the migration falls back (data unreadable, tools missing) or when there is nothing to migrate — with no FerretDB data produced. So on a server whose MongoDB → FerretDB migration failed, mongodb-control saw the marker, logged "migration already finished; disabling mongodb service" and disabled MongoDB, while FerretDB's SQLite was empty — leaving WeKan with no database at all, looping "MongoDB not ready yet, retrying…" forever and refusing to keep wekan.mongodb running. Now all three scripts decide from actual data on disk: MongoDB is disabled / FerretDB is forced only when a *.sqlite database exists in files/db; an empty files/db means the migration did not succeed, so MongoDB starts normally and WeKan keeps working while the migration can be retried. The check is a single shared helper (snap-src/bin/ferretdb-has-data) that requires a *.sqlite file bigger than 0 bytes, not merely present, so a 0-byte stub never counts as "migrated". And migration-control's finish_success now verifies that non-empty SQLite (with its WAL) exists before switching to FerretDB — if the importer returns success but wrote no data, the snap keeps MongoDB and retries next start instead of switching to an empty database.

</details> <details> <summary>Snap: the migration's mongod-7 readiness probe fast-fails when the temporary mongod has exited. Thanks to xet7.</summary>

Snap: the migration's mongod-7 readiness probe fast-fails when the temporary mongod has exited (snap-src/bin/migration-control). ready_via_node waits up to 45 s for the temporary source mongod to accept connections (a large MongoDB 6/7 database can need that long to replay its journal, #6454), but it now also checks the process is still alive: if the temp mongod has exited — e.g. mongod 7 cannot open old MongoDB 3.x data — it stops waiting immediately and drops to the MongoDB 3.2 reader instead of pinging a dead process for the full timeout. Matters across many unattended 6.09 → 9.x upgrades.

</details>

Thanks to above GitHub users for their contributions and translators for their translations.

v9.92 2026-07-15 WeKan ® release

This release fixes the following bugs:

<details> <summary><a href="https://github.com/wekan/wekan/issues/6454">Snap: WeKan never started on a running MongoDB, looping "MongoDB not ready yet, retrying in 5…</a> Thanks to xet7.</summary>

Snap: WeKan never started on a running MongoDB, looping "MongoDB not ready yet, retrying in 5 seconds..." forever, on any server with less than ~34 GB RAM ( #6454 , snap-src/bin/mongodb-control, snap-src/bin/migration-control). The WiredTiger cache size was hardcoded to 32 GB. On a smaller box (e.g. 8 GB) mongod was OOM-killed seconds after it started — including the temporary mongod that mongodb-control starts to initialise the replica set, which died before it could be reached, so the replica set was never initiated. The final mongod then ran with --replSet rs0 but no config, no PRIMARY was ever elected, and since Meteor requires the replica-set primary (change streams), wekan-control waited on it forever. Fixes: (1) the cache size is now RAM-aware — ~50 % of (RAM − 1 GB), the same formula mongod uses for its own default, capped at 32 GB, min 1 GB, overridable with MONGODB_WIREDTIGER_CACHE_GB; (2) the temporary-mongod readiness wait is raised from 30 s to 90 s so a large database that needs longer to replay its journal still gets its replica set initialised; and (3) the temporary source mongod that the MongoDB → FerretDB migration starts to read the data now uses a conservative RAM-aware cache (¼ of (RAM − 1 GB), cap 8 GB) so it leaves room for the target FerretDB and the importer running alongside it

</details> <details> <summary>Snap: the MongoDB 6/7 → FerretDB importer now resolves the correct driver and matches the MongoDB… Thanks to xet7.</summary>

Snap: the MongoDB 6/7 → FerretDB importer now resolves the correct driver and matches the MongoDB 3.2 importer's safety and dashboard (releases/migrate-mongodb-to-ferretdb.mjs). It now (a) resolves the mongodb driver by probing the WeKan bundle and using whichever actually works — preferring a v6+ driver that exposes GridFSBucket and speaks OP_MSG, because an older v2 driver's OP_QUERY is rejected by FerretDB ("Unsupported OP_QUERY command: update"); and (b) gained the same disk-space guard and progress UI as migrate-mongo3-to-ferretdb.mjs: it measures the total attachment/avatar size up front and stops before extracting if it will not fit, and if space runs out mid-run it stops, deletes the partially-migrated files, and reports how much more disk is needed (a full disk can corrupt the still-running source MongoDB), shows a live per-file progress bar, and translates the dashboard into the browser's language.

</details> <details> <summary>Snap: verified the migration's disk-space guard actually works inside snap confinement. Thanks to xet7.</summary>

Snap: verified the migration's disk-space guard actually works inside snap confinement (snap-src/bin/migrate-mongo3-to-ferretdb.mjs, releases/migrate-mongodb-to-ferretdb.mjs — comment/documentation only, no behaviour change). statfs (and statfs64 / fstatfs / fstatfs64 / statvfs / fstatvfs) is in snapd's default seccomp allow-list (snapcore/snapdinterfaces/seccomp/template.go), and a snap has no per-snap disk quota by default, so the migration's free-space check (statfsSync on $SNAP_COMMON/files) returns the real free space and its "stop before the disk fills" safety is fully active on Snap. quotactl is not allowed, but the migration never calls it; the one case it would matter — a snapd storage-quota group (project quotas invisible to statfs) — is still caught by the ENOSPC/EDQUOT write-failure fallback. Only Sandstorm grains genuinely lack statfs (FUSE does not implement it, quotactl blocked), where the guard already relies on that write-failure fallback.

</details> <details> <summary>Snap: after a MongoDB → FerretDB migration, FerretDB never started and WeKan was stuck on "MongoDB… Thanks to xet7.</summary>

Snap: after a MongoDB → FerretDB migration, FerretDB never started and WeKan was stuck on "MongoDB not ready yet, retrying..." (snap-src/bin/migration-control). The wekan.ferretdb service disables itself while database is mongodb; the migration then switches the snap with an internal snapctl set database=ferretdb, but an internal snapctl set does not re-run the configure hook that flips the two database services — so FerretDB was left disabled and MongoDB enabled but stopped, leaving WeKan with no database (both bind the same port, so only one runs at a time). finish_success now performs the flip itself, exactly as the configure hook's ferretdb branch does: enable + start (and restart) wekan.ferretdb, then stop + disable wekan.mongodb — using the snap instance name so parallel snaps target their own services. Immediate recovery on an already-migrated install — flip the setting so the configure hook re-runs and enables + starts FerretDB and stops MongoDB (do not try snap start wekan.ferretdb directly; while database is still mongodb, ferretdb-control reads the setting and disables itself again — see the next entry): sudo snap set wekan database=ferretdb.

</details> <details> <summary>Snap: the migration-success marker is now authoritative, so FerretDB starts even if the database… Thanks to xet7.</summary>

Snap: the migration-success marker is now authoritative, so FerretDB starts even if the database setting was never flipped (snap-src/bin/ferretdb-control, snap-src/bin/mongodb-control, snap-src/bin/wekan-control). Every DB service keyed its behaviour off the database setting alone: ferretdb-control logged "database is 'mongodb', not 'ferretdb'. Disabling ferretdb service." and self-disabled whenever the setting still said mongodb — so on an already-migrated install snap start wekan.ferretdb started and then immediately stopped itself, unfixable by hand without first setting database=ferretdb. Now the marker $SNAP_COMMON/.migration-to-ferretdb-done overrides the setting: when present, ferretdb-control repairs database=ferretdb and keeps running instead of self-disabling; mongodb-control disables itself (before the migration-pending check, so a finished migration is never re-attempted); and wekan-control forces ferretdb and brings the service up. WeKan thus recovers on its own after a migration whose setting flip was lost.

</details> <details> <summary>Snap: WeKan now starts its database itself on startup instead of waiting forever for a stopped one. Thanks to xet7.</summary>

Snap: WeKan now starts its database itself on startup instead of waiting forever for a stopped one (snap-src/bin/wekan-control). Previously the wekan.wekan service only waited for whichever database database pointed at (FerretDB not ready yet… / MongoDB not ready yet…) and never started a DB service, so if both wekan.ferretdb and wekan.mongodb were stopped/disabled WeKan hung indefinitely. Now, before waiting, it enables + starts whichever DB service is configured (snapctl start --enable = snap enable + snap start, targeting the snap instance name so parallel snaps hit their own services).

</details> <details> <summary>Snap: WeKan never starts an EMPTY FerretDB while data still lives in MongoDB. Thanks to xet7.</summary>

Snap: WeKan never starts an EMPTY FerretDB while data still lives in MongoDB (snap-src/bin/wekan-control). On startup WeKan now checks what data actually exists on disk — the FerretDB SQLite dir ($SNAP_COMMON/files/db) and the MongoDB WiredTiger data ($SNAP_COMMON) — instead of trusting the database setting alone. If database=ferretdb was selected (or forced) but the migration never ran, so the SQLite database is empty while MongoDB still holds the data, WeKan reverts to database=mongodb and prints how to run the migration, rather than starting an empty FerretDB that would look like all data was lost.

</details>

Thanks to above GitHub users for their contributions and translators for their translations.

v9.91 2026-07-15 WeKan ® release

This release adds the following new features:

<details> <summary>Snap / Sandstorm migration dashboard: live per-file progress and a disk-space safety guard. Thanks to xet7.</summary>

Snap / Sandstorm migration dashboard: live per-file progress and a disk-space safety guard (snap-src/bin/migrate-mongo3-to-ferretdb.mjs). While a MongoDB 3 database's attachments/avatars are migrated to the filesystem, the progress page now shows a live per-file progress bar for the file currently being extracted — its name, size, "file N of TOTAL", percent, and whether it is an attachment or an avatar (using WeKan's existing translations in the viewer's browser language, with an English fallback; there is no logged-in user during migration, so the browser's Accept-Language is used). Big files no longer land in RAM: each GridFS file is streamed chunk-by-chunk straight to disk (the old approach buffered the whole file through mongoexport's 512 MB stdout limit and failed on large attachments). Because a full disk can corrupt the still-running source MongoDB, the migration guards disk space where it can measure it (Snap with a working statfs): it shows remaining disk space, checks the total size of all files up front and stops before extracting if the volume cannot hold them, and stops mid-run if free space drops below a safety margin (default 1 GB, MIGRATION_MIN_FREE_BYTES). A Sandstorm grain cannot see its own free space or quota (its FUSE layer does not implement statfs and quotactl is blocked — statfs on /var would report the host disk, not the grain quota), and a locked-down Snap container may not report it either; in those "unknown free space" cases the migration hides the space figures and instead treats an actual ENOSPC/EDQUOT write failure as "out of space". On any such stop it deletes the partially-migrated files to free the space back, reports how many files were migrated before stopping, and shows how much more disk space is required to migrate them all. Platform is detected from the environment ($SNAP; SANDSTORM / SANDSTORM_RAW_MONGO_PATH / METEOR_SETTINGS).

</details>

and adds the following updates:

<details> <summary>i18n: 10 more selectable languages, corrected native names, and a Transifex-pull safety report. Thanks to xet7.</summary>

i18n: 10 more selectable languages, corrected native names, and a Transifex-pull safety report. Added languages.js picker entries for translation files that were pulled from Transifex but had no entry (so they were never selectable): Català (Valencià), English (Indonesia / Singapore / Turkey), Español (Colombia), Français (France), Português (Portugal), Русский (Украина), Türkmençe (Türkmenistan) and 吴语(简体). Fixed language names that showed the English name instead of the native one: Welsh → Cymraeg (and cy-GBCymraeg (Y Deyrnas Unedig)), Acehnese → Bahsa Acèh, and Afrikaans (South Africa)Afrikaans (Suid-Afrika). Also, releases/translations/pull-translations.sh now runs a new report-english-regressions.mjs after tx pull that lists any language file where a previously-translated string reverted to the English source (untranslated on Transifex), so the regression is visible instead of committed silently.

</details>

and fixes the following tests:

<details> <summary>Test / dev infrastructure: starting a dev server now also frees the MongoDB port, not just the app… Thanks to xet7.</summary>

Test / dev infrastructure: starting a dev server now also frees the MongoDB port, not just the app and rspack ports (build.sh, kill_meteor_on_port). Picking a "Run Meteor for dev" option stops any server already on the app port, but it only freed the app port (3000) and the rspack dev-server port (8080) — not Meteor's bundled MongoDB on app-port+1 (3001). When the previous meteor parent is SIGKILLed its mongo child is often orphaned and keeps holding 3001, so the new meteor run died with Unexpected mongo exit code 48 ... port was closed, or was already taken. The stop step now also frees app-port+1 (which additionally clears a leftover standalone test mongod on :3001) and waits for all three ports before starting.

</details>

and fixes the following bugs:

<details> <summary>Snap: MongoDB 3 → FerretDB migration failed with "EJSON.parse unavailable", and the progress…</summary>

Snap: MongoDB 3 → FerretDB migration failed with "EJSON.parse unavailable", and the progress dashboard should stay on the page you were on (snap-src/bin/migrate-mongo3-to-ferretdb.mjs). The migrator reads the 3.2 source with the legacy CLI and inserts into FerretDB with the modern Node driver, which needs WeKan's current bson (with EJSON) and mongodb v6 (OP_MSG). It anchored those requires at paths relative to the script — but in the snap the script runs from $SNAP/bin/ while the bundle is at $SNAP/programs/server/… (one level up), so every anchor resolved $SNAP/bin/programs/server/… (nonexistent) and fell through to the ancient meteor-spk base bson 1.x that has no EJSON → FATAL: EJSON.parse unavailable and the migration aborted. Now it builds candidate bundle roots from $SNAP (env) and the script's parent directories and searches the known modern-bundle sub-paths under each (npm-mongo's nested v6 driver first, so the ancient v2 that FerretDB rejects with "Unsupported OP_QUERY" is never picked). The migration progress dashboard already answers on every URL (so reloading any board page during migration shows progress); on completion it now reloads the same page you were on instead of forcing All Boards. (The dashboard itself also gained live per-file progress and a disk-space safety guard — see the new features above.) Thanks to xet7.

</details> <details> <summary>Admin Panel / Features / Security: "Always show all code as plain text" did not take effect (links… Thanks to xet7.</summary>

Admin Panel / Features / Security: "Always show all code as plain text" did not take effect (links stayed clickable, code stayed rendered). The setting is applied by the inner markdown helper, which reads a ReactiveVar (Markdown.alwaysShowCodeAsText) that only a separate startup autorun kept in sync. But the mentions viewer wrapper re-renders whenever the settings doc changes (it reads it for "render links as plain text"), and that re-render usually ran BEFORE the startup autorun updated the ReactiveVar — so the markdown helper read the stale value and rendered normally, and because mentions does not depend on that ReactiveVar it never re-rendered again (the race persisted even after reload). Fixed by setting the flag inside the mentions helper, from the same reactive getCurrentSetting() it already reads, right before it renders the inner markdown — so the toggle now takes effect immediately in every rich-text field (card titles, descriptions, comments, checklists).

</details> <details> <summary>i18n: several languages fell back to English (or clobbered another language) because a browser…</summary>

i18n: several languages fell back to English (or clobbered another language) because a browser language string did not map to the right translation file. Fixes:

</details>
  • Japanese ja_JP overwrote ja. The Transifex .tx/config lang_map had ja_JP: ja, writing Transifex's ja_JP into imports/i18n/data/ja.i18n.json — the same file the real Japanese (ja) uses — so tx pull -a -f reverted Japanese to the English source. Mapped to its own file (ja_JP: ja-JP); ja, ja-JP and ja-Hira are now three separate files, matching languages.js.
  • Language matching is now case- and underscore/hyphen-insensitive (requested): isLanguageSupported and a new TAPi18n.resolveTag() map any input to the canonical tag (zh-hantzh-Hant, JA-JPja-JP, browser af-ZA → legacy key af_ZA); loadLanguage/setLanguage/ensureLanguageLoaded resolve through it so the loaded file, the i18next code and the stored current tag all agree.
  • Chinese: zh-Hans-CN / zh-Hant-TW (and bare zh) fell back to English. Browser detection now strips trailing subtags progressively (zh-Hans-CNzh-Hans, zh-Hant-TWzh-Hant) instead of jumping to the first segment, and bare zh is aliased to zh-Hans (Simplified). Each Chinese variant (zh-CN, zh-TW, zh-HK, zh-Hans, zh-Hant, zh-SG) maps to its own file.
  • Mandarin (cmn) was unselectable — its tag was the typo cnm (and code cn), which mismatched supportedLngs; fixed to cmn.

Regression tests added in imports/i18n/i18n.test.js. Thanks to xet7.

Thanks to above GitHub users for their contributions and translators for their translations.

v9.90 2026-07-15 WeKan ® release

This release fixes the following CRITICAL SECURITY ISSUE of MimeBleed:

<details> <summary><a href="https://github.com/wekan/wekan/security/advisories/GHSA-jhph-whx8-wq6p">MimeBleed: file-upload MIME-type validation bypass → stored XSS on deployments without the file…</a></summary>

MimeBleed: file-upload MIME-type validation bypass → stored XSS on deployments without the file binary ( GHSA-jhph-whx8-wq6p , CWE-434 Unrestricted Upload of File with Dangerous Type). WeKan's upload validation (models/fileValidation.js) detects a file's real MIME type by running the Unix file command. On minimal Docker/Alpine images where file is not installed, detectMimeFromFile() silently returned undefined and the code fell back to the client-supplied fileObj.type. An authenticated board member (with WITH_API=true) could therefore upload an HTML file containing JavaScript while setting fileType: "image/png": the spoofed type is not on the dangerous-MIME deny-list, so the dangerous-content scan was skipped and the file was stored, yielding stored XSS served under the WeKan origin (session theft / actions as the victim, including admin)

</details>
  • Fixed so the client-supplied type can never gate the safety scan: when content-based detection via file is unavailable, WeKan now falls back to a dependency-free JS content sniff (looksLikeDangerousMarkup()) that inspects the real bytes for HTML/SVG/XML/<script> signatures and forces the dangerous-content scan regardless of the claimed MIME — so a spoofed image/png that is actually HTML+JS is caught and rejected. The sniff only matches definitive markup signatures, so genuine binary uploads (real PNG/JPEG/PDF, including large ones) are unaffected. WeKan also now logs a one-time warning when the file command is missing (previously the failure was silent). A regression test (server/lib/tests/fileValidationBypass.security.tests.js) covers both the spoofed dangerous uploads and safe binaries. CVSS:3.1 8.3 High (AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N).
  • Affected container deployments without the file binary and WITH_API=true; fixed at the upcoming WeKan release. Reported by HNUfwj. Also install the file package for full content-based MIME detection (WeKan's official images already do). Thanks to HNUfwj and xet7.

and adds the following new features:

  • Admin Panel / Features / Security: import/export privacy controls. Six new optional toggles govern how boards and user data cross the WeKan boundary:

    • Disable all import / Disable all export — master switches that turn off every import / export feature (WeKan JSON, Trello, CSV/Excel, Jira, Kanboard, NextCloud Deck, OpenProject, GitHub/GitLab/Gitea/Forgejo, board clone, and the single-attachment export). The server rejects any such request, and the import / export menu options are hidden in the UI.
    • Disable import avatars / Disable export avatars — never carry avatars (profile pictures) into / out of WeKan. Import covers WeKan JSON import, Trello import and external identity-provider avatar sync on login (LDAP, OIDC/OAuth2), gated at the single localizeAvatarFromBuffer choke point; export covers WeKan JSON and CSV export.
    • Anonymize import users / Anonymize export users — replace every user's username, full name and initials with counter placeholders (user1, user2, ...), drop their avatar, and rewrite @username mentions plus the requested-by / assigned-by fields inside card and comment content, so the imported board / exported file carries no real user identity. The placeholder word "user" follows the language of the person importing/exporting (e.g. "käyttäjä1" in Finnish). Both export paths are covered — the in-memory build() and the streaming buildStream() (which does a lightweight id-only pre-scan so mentions streamed before the users array still resolve to matching labels).

    All six default to off (current behaviour). Enforcement lives server-side in the Exporter, the WekanCreator import path and the avatar localizer, so it cannot be bypassed from the client. Thanks to xet7.

<details> <summary>Admin Panel / Features / Security: new optional "Always show all code as plain text" toggle. Thanks to xet7.</summary>

Admin Panel / Features / Security: new optional "Always show all code as plain text" toggle. When enabled, rich text is never rendered as markdown or HTML — the entire source is shown as escaped plain text in every rich text field (board and card titles, descriptions, comments, checklists, etc.), so hidden content is always revealed: HTML comments (<!-- -->), the target URL inside a markdown link, JavaScript and any other code. All code is always visible, not clickable, and not running. This extends the existing invisiblebleed protection (which already showed raw source for description-less markdown links) to all content. The wekan-markdown package cannot import app code, so the setting is bridged to the markdown renderer through a reactive flag on the exported Markdown object, kept in sync by the rich text viewer. Stored as the global alwaysShowCodeAsText setting; default off, so markdown renders normally.

</details> <details> <summary><a href="https://github.com/wekan/wekan/issues/6453">Admin Panel / Features: new optional "Render links as plain text" security toggle</a>. Thanks to bcook-konza and xet7.</summary>

Admin Panel / Features: new optional "Render links as plain text" security toggle. When enabled, all links — both markdown links like [label](url) and raw HTML <a href> tags — are always shown as plain, non-clickable text in every rich text field (board and card titles, descriptions, comments, checklists, etc.), so a link can never be clicked and cannot present misleading anchor text. This is a hardening option on top of the existing XSS sanitization (which already strips javascript:/data: schemes, event-handler attributes and dangerous tags): it addresses #6453 , where a board title could render as a clickable link. The toggle lives under Admin Panel / Features / Security, is stored as the global renderLinksAsPlainText setting, and defaults to off (links stay clickable). Implemented by forbidding the <a> tag (while keeping its visible text) in the shared DOMPurify sanitizer used by the rich text viewer, gated on the setting so toggling it re-renders reactively

</details>

and adds the following updates:

<details> <summary><a href="https://github.com/wekan/wekan/issues/5143">Outgoing webhooks / notifications: include the card description</a>. Thanks to xet7.</summary>

Outgoing webhooks / notifications: include the card description ( #5143 ). A card's description was saved to the activity log (a a-changedDescription activity, #5482) but was never put into the outgoing-webhook / notification payload, so integrations received an event with no description text. The payload now carries description (the card's current description, added only when non-empty) for every card event, and — for a description change — the before/after text as oldValue / value (previously only timeValue/timeOldValue were forwarded). New fields are additive, so existing webhook consumers are unaffected

</details>

and fixes the following tests:

<details> <summary>Test infrastructure: fix meteor test (Mocha, server-side) crashing at boot with 0 tests run.</summary>

Test infrastructure: fix meteor test (Mocha, server-side) crashing at boot with 0 tests run (scripts/patch-yargs-dirname.cjs). The whole yargs package is dragged into the Meteor test server bundle as a transitive devDependency, and Meteor 3 wraps every module in a CommonJS function function(require, exports, module, __filename, __dirname){…}. yargs (and its ESM dependencies) ship native-ESM constructs that are illegal inside that wrapper and crash the bundle before a single test runs:

</details>
  • import.meta.url / import.meta.resolve(…) → "Cannot use 'import.meta' outside a module"
  • const __dirname = … → "Identifier '__dirname' has already been declared"
  • const require = createRequire(import.meta.url) (and the guarded bundler variant const require = createRequire ? createRequire(import.meta.url) : undefined) → "Identifier 'require' has already been declared"

A postinstall patch (patch-yargs-dirname.cjs) rewrites those constructs to the wrapper's own __filename / __dirname / require (yargs is never executed here — it only needs to parse as CommonJS). Getting the patch right took several iterations, each of which failed in a way that looked like an unrelated source-level syntax error:

  1. The patch expanded import.meta.url before stripping const require = createRequire(…). The expansion injected a require('url')… call whose ) then terminated the paren-naive createRequire\([^)]*\) match early, leaving a dangling .pathToFileURL(__filename).href)) — reify then died with SyntaxError: Unexpected token (19:33) on the generated yargs esm.mjs, not on any repo file. Diagnosing it required instrumenting reify's Babel parser in the Meteor dev_bundle copy to dump the exact source string it was choking on, because every scan of the repo's own *.js came back clean (the broken file was in node_modules, was .mjs, and was produced by the patch itself). Fixed by removing the createRequire line before expanding import.meta.url, plus a repair rule that collapses any already-corrupted …href)) leftover back to a marker.
  2. With parsing fixed, a second ESM shim surfaced at boot — this time in a yargs dependency, node_modules/yargs-parser/build/lib/index.js (not under node_modules/yargs, so import.meta.url there was still unexpanded, a tell that the old yargs-only scan had never touched it). Its line 30 used the guarded ternary const require = createRequire ? createRequire(import.meta.url) : undefined;, whose createRequire ? (space, not () slipped past the direct createRequire\( matcher → the leftover const require collided with the wrapper parameter and the server bundle crashed at boot with "Identifier 'require' has already been declared". Two changes fixed it: (a) generalize the removal (and its trigger) from const require = createRequire\(…\) to a line-wise const require = …createRequire…; so it matches both the direct call and the guarded-ternary form, running it before the import.meta.url expansion so the injected require('url')… parens can never confuse it; and (b) broaden the scan from just node_modules/yargs to yargs and its ESM dependency packages (yargs-parser is the actual offender; cliui, escalade, string-width, y18n, get-caller-file, require-directory are scanned too and are harmless to include).

The patch is idempotent, best-effort (never fails an install), and re-runnable by hand (node scripts/patch-yargs-dirname.cjs) to repair a node_modules tree left broken by an older version. This is a pre-existing test-only build breakage (the yargs bundling predates these fixes); production runtime bundles were never affected. Separately, three server test files were present on disk but missing from the curated loader server/lib/tests/index.js (which the meteor test entry imports explicitly rather than by *.tests.js convention), so they had silently never run — the MimeBleed file-validation bypass regression, the import/export privacy settings, and the impersonation report query are now registered. With all of the above, the server suite boots and runs clean: 450 passing, 0 failing (411 before the three files were wired in). Thanks to xet7.

<details> <summary>Test infrastructure: build.sh and build.bat now show live progress in every test path.</summary>

Test infrastructure: build.sh and build.bat now show live progress in every test path. Several places used to sit silent for minutes, which looked like a hang. Audited both scripts and closed every gap:

</details>
  • Server-start wait (both "Run ALL tests" modes). While the :3000 server came up, the readiness wait printed only dots. .sh now shows live progress (see the .build/bundle item below, where it streams the boot log scrolling); .bat prints a check counter now and then and points at the live server log to type in another window (on cmd, echoing arbitrary log lines is unsafe as they can contain > < | &).
  • Fixed the readiness poll hanging so nothing showed for minutes (the progress line froze at [0s]). The wait polled curl http://127.0.0.1:3000/sign-in with no timeout; Meteor binds the :3000 proxy early and accepts the TCP connection while the app is still building but sends no HTTP response until it finishes, so curl blocked on that first connection for the whole build and the loop never advanced. Added --connect-timeout 2 --max-time 4 so each poll returns quickly, and the wait is now bounded by wall-clock time (.sh 1200s / .bat ~240 polls) rather than a fixed count (also applied to the Playwright-ALL precheck).
  • Sequential per-job (menu option 2). Each job used to block/redirect to a log with nothing on screen while it built and ran. Because only one suite runs at a time in this mode, .sh now streams each suite's reporter output straight to the console via tee (also saving the log), so you see every test tick by one-by-one as Mocha / Playwright / the E2E harness prints it, then a final PASS/FAIL + count line; .bat runs each job in its own minimized window (reusing the proven parallel start-commands + .done-<key> flags) and polls a live pass counter, one at a time.
  • Playwright "ALL browsers" single menu item (.sh option 10 / run_playwright_parallel). It redirected each browser to a log and only dumped the output after all finished. It now streams each browser's Playwright list reporter live via tee (progress visible per test) while still saving the log; PIPESTATUS[0] keeps the per-browser pass/fail accurate through the pipe.
  • The single-suite menu items (Mocha, import regression, Node E2E, single Playwright browser) already stream straight to the terminal, and the parallel-mode combined progress table is unchanged. Thanks to xet7.
<details> <summary>Test infrastructure: "Run ALL tests" reuses the precompiled .build/bundle for the :3000 server… Thanks to xet7.</summary>

Test infrastructure: "Run ALL tests" reuses the precompiled .build/bundle for the :3000 server instead of recompiling with meteor run (both build.sh and build.bat). Node E2E and Playwright drive a live WeKan over HTTP; that server is now started from the production bundle you already built with meteor build .build --directorynode main.js boots in seconds with no recompile, using Meteor's bundled node and mongod (mongod on :3001, or an already-running MongoDB there is reused). Its one-time programs/server npm install and its boot log now stream live (scrolling) so you can see exactly what is happening. Two caveats that are inherent to Meteor and are called out in the script: (1) the bundle is run as-is, so after changing source you must rebuild it or the tests run old code; (2) the server-side Mocha suite still uses meteor test (its own .meteor/local-test build) — the in-process unit/security tests cannot run from a production bundle, so copying the bundle would not help them. In sequential mode the suites run strictly one at a time, each streaming its own output, and the parallel-only combined table is skipped (only the live WeKan server + MongoDB run alongside — they are the system under test, not parallel test jobs). Before starting, the run checks for the .build/bundle directory specifically (not just .build) and builds it once with meteor build .build --directory if it is missing or incomplete, so a first run with no bundle still works. The bundle server talks to the meteor database on :3001 — the DB name Meteor's built-in mongo used under meteor run and the one the Playwright / Node E2E tests seed into (tests/playwright/helpers/db.js, tests/e2e/list-regressions.js); an earlier /wekan name made the app read an empty database while the tests seeded a different one, so every seeded test failed until the names were aligned. Both the Bash and the Windows batch runners now use this bundle-based :3000 server (the .bat resolves Meteor's bundled node / mongod from the dev_bundle, starts mongod in a minimized window, and stops it on exit only when it started it).

</details>

and fixes the following bugs:

<details> <summary>Sandstorm: the WeKan Admin Panel is now always available in a migrated grain, not just a freshly… Thanks to xet7.</summary>

Sandstorm: the WeKan Admin Panel is now always available in a migrated grain, not just a freshly created one. On Sandstorm the grain owner (Sandstorm configure permission) is mapped to a WeKan admin, which gates the Admin Panel (and Admin Panel / Attachments / Sandstorm, used to delete leftover files after a migration). A brand-new grain worked because Users.after.insert derives the role for the new user, but a grain migrated from an older WeKan already contains the user, so that insert hook never runs. Sandstorm auto-login uses connection.setUserId() (bypassing accounts-base, so Accounts.onLogin never fires), and the only remaining hook — an observeChanges on services.sandstorm — fires solely when that field actually changes on login. When the migrated user's stored permissions already equal the grain's current permissions, the login $set is a no-op with no oplog entry, so the role was never derived and the owner had no Admin Panel. WeKan now reconciles this at grain startup (a migrated grain reboots once migration completes): every Sandstorm user whose stored permissions include configure is granted WeKan admin. It only promotes, never revokes, so a stale/empty stored permission set can never lock the owner out.

</details> <details> <summary>Admin Panel / Version: show the MongoDB storage engine even with per-database credentials (no more… Thanks to xet7.</summary>

Admin Panel / Version: show the MongoDB storage engine even with per-database credentials (no more "unknown"). The storage engine was read only from the serverStatus command, which requires the cluster-level clusterMonitor role. A per-database WeKan user (readWrite on the wekan database only, as in the docs/Platforms/FOSS/Docker/Meteor3/ setup) is not authorized to run it, so the command was rejected and the field stayed unknown. WeKan now falls back to a $collStats aggregation (which needs only the collStats action that read/readWrite already grant) and reads the real engine — wiredTiger (or inMemory) — from storageStats. The MongoDB compatible version and Database commit were already correct: they come from buildInfo, which needs no special privileges.

</details>

Thanks to above GitHub users for their contributions and translators for their translations.

v9.89 2026-07-13 WeKan ® release

This release fixes the following CRITICAL SECURITY ISSUE of SortBleed:

<details> <summary><a href="https://wekan.fi/hall-of-fame/boardbleed/">SortBleed: a low-privilege (comment-only / read-only) board member could escalate to board admin…</a></summary>

SortBleed: a low-privilege (comment-only / read-only) board member could escalate to board admin and take over a private board via the board sort collection-allow rule (GHSA-xm8x-c8wg-jhmf, CWE-863 Incorrect Authorization, CWE-269 Improper Privilege Management). Same broken-access-control class as BoardBleed (CVE-2026-55234) — a Meteor collection allow-rule field conflation — but on the Board document itself. To support drag-to-reorder on the All Boards / Public Boards pages, a second Boards.allow({ update }) rule returned true for any board member whenever the update touched the sort field. Meteor evaluates allow rules with OR semantics and does not scope an approving rule to the field that satisfied it: once any allow callback returns true and no deny callback returns true, the entire modifier is applied. Because canUpdateBoardSort only checked that sort was among the modified fields (not that it was the only one), a comment-only / read-only member could smuggle arbitrary board mutations into the same $set as sort in a single unprivileged DDP Boards.update call: {$set: {sort: 99, members: [...only themselves as admin...], permission: 'public', title: '...'}}. The member could therefore make themselves board admin, flip a private board to public (world-readable in Wekan), rename it, and evict the legitimate owner. The last-admin deny rule did not help because it only inspected $pull, so a wholesale $set of the members array bypassed it entirely

</details>
  • Fixed by restricting canUpdateBoardSort (server/lib/utils.js) so the sort-reorder rule approves an update only when sort is the sole modified field (fieldNames is exactly ['sort']) — it can no longer approve a modifier that also mutates members, permission, title or anything else. As defense in depth, the last-admin deny rule (server/permissions/boards.js) now also rejects a $set rewrite of the members array that would drop the last active admin, not just a $pull. A regression test covers the multi-field smuggling case (server/lib/tests/boards.security.tests.js). The legitimate All Boards drag-reorder is unaffected: it persists the order per-user in profile.boardSortIndex (Users.setBoardSortIndex), not in the board document. CVSS:3.1 8.8 High (AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H).
  • Affected Wekan v9.85 and earlier through the current release; fixed at the upcoming WeKan release. Reported by 5ud0 / Tarmo Technologies. Thanks to 5ud0 / Tarmo Technologies and xet7.

and adds the following updates:

Thanks to above GitHub users for their contributions and translators for their translations.

v9.88 2026-07-13 WeKan ® release

This release adds the following new features:

and fixes the following bugs:

<details> <summary>Sandstorm build: fixed "not writing through dangling symlink" that failed the sandstorm.yml… Thanks to xet7.</summary>

Sandstorm build: fixed "not writing through dangling symlink" that failed the sandstorm.yml workflow at sandstorm-src/build-deps.sh step [3/7]. The meteor-spk 0.6.0 base ships some runtime libs in meteor-spk.deps/lib as DANGLING symlinks (e.g. libstdc++.so.6 → a library that no longer exists), so refreshing them with the host's newer libs failed: cp -fL refuses to write through a dangling destination symlink. Added --remove-destination so cp deletes the existing destination (dangling symlink included) before copying the host's real library.

</details> <details> <summary>Sandstorm build: write the signing keyring to the path meteor-spk pack actually reads. Thanks to xet7.</summary>

Sandstorm build: write the signing keyring to the path meteor-spk pack actually reads. The sandstorm.yml "Restore the Sandstorm signing keyring" step wrote the decoded SANDSTORM_KEYRING secret to ~/.sandstorm/sandstorm-keyring, but meteor-spk / spk read the app private key from ~/.sandstorm-keyring (a file directly in $HOME), so packing aborted with open(~/.sandstorm-keyring): No such file or directory even when the secret was set. Now it writes ~/.sandstorm-keyring, and — since the .spk cannot be signed without it — fails early with an actionable message when the secret is missing, instead of the cryptic later crash.

</details> <details> <summary>Sandstorm .spk: trim it back toward Cloudflare's 100 MB upload limit.</summary>

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 .mjs importer). Also set PUPPETEER_SKIP_DOWNLOAD=1 for 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 .spk size and warns if it exceeds 100 MB. (The Database Tools remain in the WeKan .zip bundle / Docker / Snap, which do use them.) Thanks to xet7.
</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/143607ef22ae91803cb611053fd4ce00754e7096">Sandstorm .spk: bundle the matching glibc dynamic loader so grains start</a>. Thanks to xet7.</summary>

Sandstorm .spk: bundle the matching glibc dynamic loader so grains start : the .spk bundled the host's new glibc libc.so.6 (Ubuntu 24.04, glibc 2.39) but kept the old glibc 2.31 ld-linux from the meteor-spk 0.6.0 base, so node failed at startup with libc.so.6: undefined symbol: _dl_audit_symbind_alt, version GLIBC_PRIVATE (HTTP-BRIDGE exit 127) and the grain crash-looped. sandstorm-src/build-deps.sh now also copies the host's ld-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 bundled node cannot run under them — turning a silent grain crash-loop into a loud build failure

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/66d8706c4d2afab4d127f8c63e05d43407efaf30">Sandstorm build workflow: enable unprivileged user namespaces and build the dispatched branch</a>. Thanks to xet7.</summary>

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 the spk supervisor rely on, so sandstorm.yml now relaxes it on the runner. A new ref workflow_dispatch input also lets the workflow build a fix branch before it is merged, instead of a hardcoded main

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/60116bc72f68c7bc0aa56a7d5700e33b98e870af">Meteor unit tests: fix server-boot crash from __dirname in an ESM test file</a>. Thanks to xet7.</summary>

Meteor unit tests: fix server-boot crash from __dirname in an ESM test file : server/lib/tests/dependencies.openapi.tests.js is an ES module that referenced the bare __dirname global; 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 with Identifier '__dirname' has already been declared and the "Meteor unit tests" CI job died before any test ran. Drop the __dirname seed (process.env.PWD already reaches the repo root under meteor test)

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/1c28b40fd41acd9b9e21671548411f25e55464b8">Sandstorm .spk: point FerretDB state dir to writable /var so the grain starts</a>. Thanks to xet7.</summary>

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-dir defaults to . — which in a Sandstorm grain is /, read-only. So FerretDB failed with Failed 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.js now creates /var/ferretdb and passes --state-dir=/var/ferretdb (plus FERRETDB_STATE_DIR) in startFerret(), covering both the migration and steady-state FerretDB launches

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/f4f22bbe24e10659cc80cd1efeb6b4890b58ea49">Sandstorm .spk: don't crash the grain when capnp.node can't load on Node 24</a>. Thanks to xet7.</summary>

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), so Npm.require('capnp') in sandstorm.js failed with ERR_DLOPEN_FAILED and crash-looped the whole grain on boot. Cap'n Proto is now loaded lazily in a try/catch and degrades gracefully: the grain boots and core WeKan works, because login and user identity come from the sandstorm-http-bridge X-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

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/8549a62c85f43ad48e8f049eead64691c3f1dbf0">Snap release: build amd64+arm64 natively on GitHub, exotic arches non-blocking on Launchpad</a>. Thanks to xet7.</summary>

Snap release: build amd64+arm64 natively on GitHub, exotic arches non-blocking on Launchpad : the single snap job ran snapcraft remote-build for 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-native builds amd64 (ubuntu-24.04) and arm64 (ubuntu-24.04-arm) natively on GitHub runners via snapcore/action-build; snap-launchpad builds 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

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/60e40199c965460b95528ef9ea07281172c2d9b6">Sandstorm .spk: fix server-boot crashes from stale globals (Users, HTTP) in sandstorm.js</a>. Thanks to xet7.</summary>

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.js and 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/Activities collections as implicit globals, but those are now ES module default exports, so Users.after.insert threw Users is not defined at boot; and it monkey-patched HTTP.methods from the removed meteor/http package, so HTTP was undefined. The collections (and Accounts) are now imported explicitly, and the obsolete HTTP.methods patch is removed

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/975316f5cd6e81b6ea8ae6ede588d26055f9f649">Sandstorm .spk: use boolean index options so FerretDB accepts the users index</a>. Thanks to xet7.</summary>

Sandstorm .spk: use boolean index options so FerretDB accepts the users index : wekan-accounts-sandstorm created the unique index on services.sandstorm.id with {unique: 1, sparse: 1}. Real MongoDB accepts the truthy 1, but FerretDB (used by the Sandstorm .spk) is strict and rejects it with The 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)

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/013b6b5266be34326e36762b1a50e212237b109d">Sandstorm .spk: strip Accept-Encoding so the grain doesn't serve corrupted (gzip) content</a>. Thanks to xet7.</summary>

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-bridge advertises Accept-Encoding: gzip to the app regardless of what the browser actually sent, so Meteor served gzip/brotli-encoded responses the browser could not decode. A WebApp.rawHandlers middleware now strips Accept-Encoding (it runs before Meteor's static/boilerplate serving) so responses go out uncompressed; bandwidth is a non-issue behind the local bridge

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/7f0706e45f26aa0937a15a2a8f7336df63830511">Sandstorm .spk: bundle a modern sandstorm-http-bridge to fix "Corrupted Content"</a>. Thanks to xet7.</summary>

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-bridge that mangles responses (it always advertises Accept-Encoding: gzip to the app and mishandles redirect/encoding). build-deps.sh now overwrites the bundled /sandstorm-http-bridge with the modern one from a Sandstorm install (/opt/sandstorm/latest/bin/sandstorm-http-bridge; override with SANDSTORM_HTTP_BRIDGE), and sandstorm.yml installs Sandstorm before assembling the deps so the bridge is available on the CI runner

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/ccdd1a0846ffed64546265c38263c7e1b8530480">Sandstorm .spk: fix the "/" redirect (malformed Location + Content-Length mismatch)</a>. Thanks to xet7.</summary>

Sandstorm .spk: fix the "/" redirect (malformed Location + Content-Length mismatch) : the grain's / served a broken 301: the Location was .../:6080board because FlowRouter.path() does not resolve on the server in Meteor 3.x (it returned the bare route name board), and the response advertised Content-Length: 90 while 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

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/b5945cf596be546b9440854ee9c39520c3c715c0">Sandstorm .spk: open the grain on the All Boards page, not a hard-coded board</a>. Thanks to xet7.</summary>

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 the WebApp.handlers.get("/") redirect is removed entirely — / now falls through to WeKan's normal serving, whose client home route 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

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/8f11e8ac66d7348f62e8cf9278f4196b44c719b0">Sandstorm .spk: don't auto-create a board; map grain permissions to global role</a>. Thanks to xet7.</summary>

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. updateUserPermissions previously 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

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/b0712e6656ce33bfe7c360547185fea04660c9f7">Sandstorm .spk: set SANDSTORM=1 so the header-based auto-login runs</a>. Thanks to xet7.</summary>

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-sandstorm client only starts the automatic X-Sandstorm-* header login when __meteor_runtime_config__.SANDSTORM is set, and the package only sets that when process.env.SANDSTORM is present — which nothing did. The launcher now sets process.env.SANDSTORM = '1' before loading the WeKan bundle, so the client auto-logs-in the Sandstorm user from the headers

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/d5f707fd37b63ff038f61d7aa1316d5800859b35">Sandstorm .spk: rewrite ROOT_URL per request to the grain URL (fixes login + CORS)</a>. Thanks to xet7.</summary>

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 fixed ROOT_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 to 127.0.0.1:4000 — cross-origin and unreachable — and the header-based login handshake (a DDP method call) never completed. A WebApp.addRuntimeConfigHook now rewrites ROOT_URL to the grain's real base URL (X-Sandstorm-Base-Path) per request, so DDP, dynamic imports and the Sandstorm auto-login work

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/6f3174b23d7072a383703a5fe03711090fe9f844">Sandstorm .spk: bounce from sign-in to the boards list once auto-login lands</a>. Thanks to xet7.</summary>

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 checks Meteor.userId() once and, because the Sandstorm header login is asynchronous and uses connection.setUserId() (bypassing accounts-base, so Accounts.onLogin never fires), finds it still null on first render and redirects to atSignIn — where the user is stranded even after login completes. A Sandstorm-only reactive autorun now sends the user from atSignIn back to home as soon as Meteor.userId() is set, so the grain lands on the All Boards page

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/68e59ab66f68d1c60bc10c954c50999809e5381b">Sandstorm .spk: keep the grain URL in sync with the in-app route</a>. Thanks to xet7.</summary>

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.enter callback, which does not fire reliably on client navigation in this flow-router-extra / Meteor 3 setup; it is now synced from a reactive Tracker.autorun on FlowRouter.watchPathChange() (the same mechanism the title sync uses), so every route change updates the grain URL to /grain/<id><path>

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/dc8ff6e6ca3093298d5e07f2c8726f597d672449">Sandstorm .spk: upload attachments over DDP (bridge strips Meteor-Files' HTTP headers)</a>. Thanks to xet7.</summary>

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 an x-start header and tracks the session with x-mtok/x-chunkid/x-fileid/x-eof, but Sandstorm's request-header whitelist does not include them, so the sandstorm-http-bridge strips them — the server never sees x-start, treats every request as a chunk continuation, cannot find the session and returns 408. Attachment uploads now use transport: 'ddp' on Sandstorm (DDP method calls, no custom HTTP headers); HTTP transport is kept everywhere else

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/1ca7bd3bb6016f560aaa3f748bbb23cec514acf0">Sandstorm .spk: authorize attachment/avatar downloads via X-Sandstorm-User-Id</a>. Thanks to xet7.</summary>

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 / authToken query / meteor_login_token cookie), but Sandstorm has none of these: authentication is via connection.setUserId() and the sandstorm-http-bridge X-Sandstorm-* request headers, so extractLoginToken() returned null and isAuthorizedForBoard() denied every request. Sandstorm already gates grain access at the platform level, so any request that reaches WeKan is an authenticated grain user — both isAuthorizedForBoard() and isAuthorizedForAvatar() now allow when Meteor.settings.public.sandstorm is set and the request carries the bridge-injected X-Sandstorm-User-Id header. Gating on the setting means a spoofed header cannot bypass auth on non-Sandstorm deployments

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/3ab2b9dccbf3b32dd510d4634af88a1b9eb8513c">Sandstorm .spk: open All Boards (not sign-in) and keep the grain URL in sync</a>. Thanks to xet7.</summary>

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(), so Meteor.userId() is null for the first moments after the grain opens — and useraccounts' ensureSignedIn trigger plus renderBoardList() both bounced that brief null window to the atSignIn route, a sign-in page that does not exist inside a grain. config/router.js now uses a Sandstorm-aware ensureSignedInUnlessSandstorm wrapper (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-level Tracker.autorun that could run before flow-router-extra's reactive path tracking was ready and then never re-run. Restored v6.15's event-driven FlowRouter.triggers.enter (fresh entering path, order-independent) and kept a watchPathChange() autorun wrapped in Meteor.startup as a backup

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/0804cd19e">Sandstorm .spk: fix the MongoDB 3 → FerretDB migration of an existing grain: importing an old WeKan…</a> Thanks to xet7.</summary>

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 computed cache_size=0G and WiredTiger refused to open (minimum is 1MB), logging "Value too small for key 'cache_size'" / "Fatal Assertion 28561" to /var/migration-mongod.log before aborting. Both mongod invocations (the niscu → 3.0 stage and the 3.0 → FerretDB stage) now pass an explicit --wiredTigerCacheSizeGB 1 so the cache size never depends on RAM detection (mongod 3.0.7 parses this option as an integer number of GB — a decimal like 0.25 fails with Bad digit ".", fixed to 1 — 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 database meteor, but the importer was told SRC_DB=wekan, so even once mongod started it would have exported zero collections; only the FerretDB target database is wekan

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/50abd8f95">Sandstorm/Snap migration: import mongodb and bson as default (CommonJS) exports under Node 24: with…</a> Thanks to xet7.</summary>

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)

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/62df99153">Sandstorm/Snap migration: connect mongoexport over IPv4 so it can read the old data: with the…</a> Thanks to xet7.</summary>

Sandstorm/Snap migration: connect mongoexport over IPv4 so it can read the old data: with the importer finally running, every mongoexport of a source collection failed — at first silently (its own --quiet flag suppressed the reason), and once that was dropped the real error showed: "error connecting to db server: no reachable servers". mongoexport is a Go tool whose --host defaults to localhost, which resolves to ::1 (IPv6) first, but the migration mongod listens only on --bind_ip 127.0.0.1 (IPv4); the mongo shell defaults its host to 127.0.0.1 and so connected fine (it listed all 42 collections), which is why only the shell worked. Pass --host 127.0.0.1 explicitly. 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…)"

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/0e0a8bbb3">Sandstorm/Snap migration: resolve bson/mongodb from the modern server bundle so EJSON exists: once…</a> Thanks to xet7.</summary>

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')"EJSON was undefined. The importer script sits at the deps root right next to the OLD meteor-spk 0.6.0 base node_modules (kept only for the niscu → 3.0 stage): its bson is 1.x with no EJSON at all, and its mongodb is ancient — it has MongoClient (so the connection worked) but no EJSON re-export. A bare import/require from the script resolved those adjacent old copies, so every way of reaching EJSON (bare bson, mongodb.EJSON) came back undefined. WeKan's current bson 7.3 and mongodb driver (with EJSON) live under programs/server/npm/node_modules; anchor createRequire inside that modern bundle first (falling back to the deps root) and load both mongodb and bson through 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 if EJSON.parse is still unreachable

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/cab231ee3">Sandstorm/Snap migration: insert into FerretDB with the modern mongodb driver (OP_MSG)</a>. Thanks to xet7.</summary>

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

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/3476cd17c">Sandstorm/Snap migration: extract Meteor-Files GridFS attachments + parse legacy v1 binary</a>. Thanks to xet7.</summary>

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 mongoexport writes binary as legacy Extended JSON v1 {"$binary":"<b64>","$type":"00"}, but modern bson EJSON.parse only 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 attachments collection), not CollectionFS's cfs_gridfs.*. Reassemble those buckets to disk, link each GridFS file to its record via metadata.fileId/versionName, then repoint the record's versions.<v> at the file and drop versions.<v>.meta.gridFsFileId — otherwise WeKan's getFileStrategy keeps 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

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/b8bdbcd2d">Sandstorm/Snap migration: auto-open All Boards after migrating, WeKan-themed dashboard</a>. Thanks to xet7.</summary>

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

</details> <details> <summary>Avatars and original members now travel with a board (export/import), from every identity source.</summary>

Avatars and original members now travel with a board (export/import), from every identity source. A board could be moved between servers (or imported into Sandstorm) but member avatars vanished — they lived in Sandstorm / LDAP / OAuth2, not in WeKan — and original members were collapsed onto the importing user by a mapping that could attach the wrong person and leak board permissions. Now:

</details>
  • External avatars are localized into WeKan's own files/avatars, triggered when a board is opened and at login, from any source — Sandstorm profile picture, LDAP jpegPhoto/thumbnailPhoto, OAuth2/OIDC picture claim, gravatar or a pasted URL — every network fetch guarded against SSRF (http/https only; no private, loopback, link-local or cloud-metadata address; timeout + size + image-type caps). (board-open trigger. Inside a Sandstorm grain outbound fetch is sandboxed, so a still-external Sandstorm picture is a best-effort no-op there — but any avatar that is already a local file exports/imports fully, grain included.)
  • Board export embeds each member's local avatar file as base64 alongside their username, fullname and initials — never passwords, emails or services.
  • Board import preserves the original members as inert placeholder users (authenticationMethod:'imported', loginDisabled, isActive:false, no secrets), reusing each original _id so card/comment/activity references resolve to the right person with NO mapping at import time, and restores their avatar. The importer stays the sole admin; imported members hold no permissions until reconciled.
  • Reconciliation maps placeholders to the valid accounts deliberately, later: an admin sweep merges each placeholder into a matching real account (provisioned by LDAP/OIDC at login) by reassigning every reference, and leaves the rest inactive (e.g. a person not in LDAP); a one-off admin merge is available too.
  • Avatars visibly distinguish account state, on card avatars and in the right-sidebar member list: a dashed ring + amber "?" badge for un-reconciled imported placeholders, greyscale + dim for inactive members, and the sidebar now lists everyone (active first, then inactive) instead of hiding inactive members. Thanks to xet7.

Thanks to above GitHub users for their contributions and translators for their translations.

v9.87 2026-07-11 WeKan ® release

This release fixes the following bugs:

<details> <summary><a href="https://github.com/wekan/wekan/commit/b227a931933a3a8abff67c1084f9561b8847f444">Normal users cannot move cards between swimlanes</a>. Thanks to xet7.</summary>

Normal users cannot move cards between swimlanes : in the swimlanes view the .js-swimlanes sortable — which also carries moving a card from one swimlane to another — was disabled for every non-admin (!isBoardAdmin()), even though its own comment said it should be disabled only for non-members. So ordinary board members could move a card within a swimlane but not between swimlanes, and could not reorder swimlanes. Now it is disabled only for users without write access (!canModifyCard(): comment-only, worker, read-only), so board members can move cards between swimlanes again

</details>

Thanks to above GitHub users for their contributions and translators for their translations.

v9.86 2026-07-11 WeKan ® release

This release fixes the following bugs:

<details> <summary>FerretDB v1 is now downloaded as an individual per-arch binary, not ferretdb.zip. Thanks to xet7.</summary>

FerretDB v1 is now downloaded as an individual per-arch binary, not ferretdb.zip: the wekan/FerretDB release now attaches one ferretdb-<arch> (.exe on Windows) asset per platform instead of a single multi-platform ferretdb.zip. Every WeKan build path now downloads only the one binary for the platform it targets from https://github.com/wekan/FerretDB/releases/latest/download/ferretdb-<arch>: release-all.yml (the amd64/arm64/win64/mac/ppc64le/s390x/riscv64 bundle jobs — the separate build-ferretdb job and its ferretdb-zip artifact are removed), the default docker-compose.yml, and the Sandstorm sandstorm-src/build-deps.sh. FerretDB itself also moved its Go toolchain to 1.25.11 to clear the Quay.io stdlib security advisories.

</details> <details> <summary>Drop the mongosh binary; use bundled Node.js 24 + the mongodb driver instead. Thanks to xet7.</summary>

Drop the mongosh binary; use bundled Node.js 24 + the mongodb driver instead: WeKan no longer bundles or downloads the MongoDB Shell anywhere (snap, Windows bundle). Every scripted database operation it was used for — readiness ping, replica-set initiate/status, and the v8.43 schema migration — now runs through the new snap-src/bin/db-eval (a tiny wrapper around the bundled Node.js 24 + the mongodb driver), so the snap control scripts (wekan-control, mongodb-control, migration-control), the start-wekan.sh/start-wekan.bat launchers, and the ported migrate-schema-v843.mjs are all mongosh-free. This removes a large, CVE-prone binary and works identically on every architecture (including s390x/ppc64le/riscv64, which have no prebuilt mongosh). The legacy MongoDB 3.2 mongo shell (migratemongo, amd64) stays for migration-time reads only.

</details> <details> <summary><a href="https://github.com/wekan/mongo-tools">MongoDB Database Tools now come from wekan/mongo-tools, not the MongoDB website</a>. Thanks to xet7.</summary>

MongoDB Database Tools now come from wekan/mongo-tools, not the MongoDB website: every WeKan build downloads the per-arch <tool>-<arch> binaries (bsondump, mongodump, mongoexport, mongofiles, mongoimport, mongorestore, mongostat, mongotop) built by the wekan/mongo-tools fork (pure Go, cross-compiled for every architecture) from its newest release, replacing the fastdl.mongodb.org / downloads.mongodb.com downloads. They are embedded in the Linux .zip bundles (amd64/arm64/s390x/ppc64le/riscv64) — and therefore in the Docker image, which is built from those bundles — the Windows and macOS bundles, the Snap (mongotools part), and the Sandstorm .spk. The MongoDB 7 server (mongod) is still fetched from MongoDB (amd64/arm64 only), since MongoDB ships no server for the other architectures; the legacy MongoDB 3.2 CLIs (migratemongo, amd64) remain only for the one-time MongoDB 3 migration

</details> <details> <summary>Snap: the default snapcraft.yaml is now the base: core24, grade: stable build. Thanks to xet7.</summary>

Snap: the default snapcraft.yaml is now the base: core24, grade: stable build: the previous snapcraft.yaml (core26, grade: devel) is renamed to snapcraft-core26.yaml, and the former snapcraft-core24.yaml becomes snapcraft.yaml. Because the default is base: core24 (a released base), it can be published to the Snap candidate channel, which core26 cannot. The automated release-all.yml workflow publishes the snap to the candidate + beta + edge channels; the stable channel is published manually later, once it is proven stable enough.

</details>

Thanks to above GitHub users for their contributions and translators for their translations.

v9.85 2026-07-11 WeKan ® release

This release adds the following features and fixes:

  • Docker: FerretDB v1 + SQLite is now the default docker-compose.yml:

    The default database for docker compose up -d is now FerretDB v1 with embedded SQLite (from https://github.com/wekan/FerretDB) — light and self-contained, no separate database server. The compose files were renamed accordingly: docker-compose-ferretdb-v1-sqlite.yml -> docker-compose.yml (the new default), and the previous MongoDB default docker-compose.yml -> docker-compose-mongodb-v7.yml. The other files are unchanged: docker-compose-ferretdb-v2-postgresql.yml (FerretDB 2

    • PostgreSQL) and docker-compose-multitenancy.yml (MongoDB multitenancy). To use MongoDB 7, run docker compose -f docker-compose-mongodb-v7.yml up -d or rename that file to docker-compose.yml. build.sh / build.bat Docker menus now list FerretDB v1 SQLite first (default) and point at the new filenames, the compose files' own header comments were updated, and the Docker docs now describe which compose file maps to which database. The Docker menus also gained a Build from source & start (up -d --build) action per compose file, which builds the wekan-app image from the local Dockerfile (tagged as the image the compose file references) and starts that freshly built container instead of a possibly-stale prebuilt image — useful when a change (e.g. the FerretDB Version-page detection) isn't in the pulled image yet. Finally, the obsolete version: attribute (which Docker Compose v2 warns about and ignores) was removed from all compose files (docker-compose.yml, docker-compose-mongodb-v7.yml, docker-compose-ferretdb-v2-postgresql.yml, docker-compose-multitenancy.yml, .devcontainer/docker-compose.yml, and the ToroDB docs example).
  • build.sh / build.bat: reorganized into category submenus + Docker start/logs/stop:

    The long flat menu is now grouped into a short top-level menu — Setup, Dev server, Tests, Docker, Tools, Quit — each opening a small submenu with 0) Back, so you read only a handful of items at a time and labels are shorter (the category provides the context). Docker is a two-stage submenu: pick a backend (MongoDB docker-compose.yml, FerretDB v1 SQLite, FerretDB v2 PostgreSQL, MongoDB Multitenancy), then an action — Start (up -d), Follow logs (logs -f) or Stop (down) — which removes the previous repetition of 12 near-identical entries. The .sh auto-detects docker compose vs legacy docker-compose. All existing actions are unchanged, just regrouped.

<details> <summary>build.sh / build.bat: Dev server options now stop the previous server (including the rspack :8080…</summary>

build.sh / build.bat: Dev server options now stop the previous server (including the rspack :8080 dev server) before starting, plus a new "Kill all dev servers" option, and docs updated for the new menu:

</details>

Every Dev server option (localhost:3000, + trace warnings, + bundle visualizer, CURRENT-IP:3000, CURRENT-IP:3000 + MONGO_URL 27019, and CUSTOM-IP:PORT) now stops any Meteor dev server already running before starting a fresh one, so re-running a dev option no longer fails because a port is taken — no need to hunt down and kill the old processes yourself. Crucially it frees both the app port and the rspack dev-server port 8080: meteor run starts an rspack dev server on 8080 that can outlive the meteor parent, and a leftover one made the restart crash with Error: listen EADDRINUSE ... :8080. Port detection now checks the listening socket directly (ss/lsof, with a bash /dev/tcp fallback that needs no external tools, and netstat on .bat) instead of an HTTP probe, so it also catches a server that is still building. A new Dev server -> Kill all dev servers option frees every dev/test port the scripts use at once — the dev app (3000) and its Mongo (3001), the Mocha test server (3100) and its Mongo (3101), a Sandstorm standalone dev server (4000) and its Mongo (4001), and the rspack dev server (8080) — killing meteor, the rspack watcher and Meteor's bundled --replSet meteor Mongos (never a production/system Mongo). Both scripts escalate to SIGKILL if a port does not free up. Also updated the build-from-source docs (README.md, Build-from-source.md, Build-and-Create-Pull-Request.md, Emoji.md, and the two Sandstorm developer docs) to the new two-level menu (Setup -> Install dependencies, Setup -> Build WeKan, Dev server -> localhost:3000) and fixed a stale dev-server port (localhost:4000 -> 3000).

  • FerretDB: quieter logs, and removed a dead MongoDB-driver-selection subsystem:

    On FerretDB (SQLite), the driver debug logs revealed a second, TLS-enabled Mongo monitor connection retrying every ~0.5s and being rejected by the plaintext FerretDB port, which FerretDB logged at WARN (Connection stopped … invalid message length / before secure TLS connection was established) — harmless (WeKan runs fine on the real plaintext connection) but very noisy. FerretDB is now started with --log-level=error in all bundled launch points (Docker entrypoint, snap ferretdb-control, the release start-wekan.sh, and the docker-compose-ferretdb-v1-sqlite.yml example), which drops the per-connection WARN spam. Separately removed a dead, unused "MongoDB Driver System" (server/mongodb-driver-startup.js + models/lib/{meteorMongoIntegration,mongodbConnectionManager,mongodbDriverManager}.js) — an abandoned attempt to auto-detect MongoDB 3.0–8.0 and pick versioned driver packages that were never even installed; WeKan uses the mongodb-7 driver via Meteor.

  • Fix snap build failing on the caddy part (Cloudsmith unreachable on Launchpad):

    The snap installed Caddy from the Cloudsmith apt repo (curl … dl.cloudsmith.io … | gpg --dearmor), which fails on the Launchpad remote builders — their network is restricted to a fetch proxy that can't reach Cloudsmith, so gpg got no key (no valid OpenPGP data found) and the caddy override-build failed with code 2 on both arches, all attempts. Both snapcraft.yaml and snapcraft-core24.yaml now download the official prebuilt Caddy static binary from GitHub releases (per-arch, latest stable with a pinned fallback) instead. WeKan uses only built-in Caddy directives, so vanilla Caddy is sufficient — no apt repo, no gpg, no xcaddy/custom-module build.

  • Fix Admin Panel / Version showing "MongoDB" when running on FerretDB:

    The database detection only recognised a buildInfo.ferretdb sub-document, but the wekan/FerretDB v1 fork reports its identity as a top-level ferretdbVersion string (e.g. v1.24.2-60-gb5523566) plus ferretdbFeatures, with its git commit in gitVersion. So the Version page showed Database type: MongoDB and hid the FerretDB rows. Detection now handles both shapes (server/statistics.js), so it shows Database type: FerretDB, the FerretDB version and FerretDB commit rows, and the SQLite storage engine. (FerretDB v1's version: 7.0.42 is the MongoDB version it emulates.)

  • Design doc: WeKan on Sandstorm (Meteor 3.5 / Node 24) with MongoDB 3 → FerretDB migration:

    Added docs/Platforms/FOSS/Sandstorm/Meteor3/Migration.md describing how to build a modern Sandstorm .spk (Node 24, replacing meteor-spk 0.6.0's Node 14) that runs on FerretDB v1 (embedded SQLite) instead of MongoDB 3.0, migrating an existing grain's MongoDB 3.0 data on first launch — reusing the snap's proven migrate-mongo3-to-ferretdb logic (mongoexport read → FerretDB insert; CollectionFS/Meteor-Files GridFS attachments+avatars → filesystem). Includes the grain sandbox (seccomp) compatibility analysis, the rewritten start.js, and a new isSandstorm-only Admin Panel / Attachments / Sandstorm section (migration status, raw-MongoDB disk usage, and a guarded delete-raw-MongoDB-files action). Implementation of the in-app pieces follows.

  • Sandstorm: Admin Panel / Attachments / Sandstorm (migration status + free raw-MongoDB disk space):

    Implemented the in-app pieces from the design above. When WeKan runs inside a Sandstorm grain (isSandstorm), a new Sandstorm section appears in Admin Panel / Attachments showing whether the one-time MongoDB 3 → FerretDB v1 migration succeeded, and the disk space the raw MongoDB 3 database files, the FerretDB SQLite, and the attachments/avatars currently use inside the grain. An admin can delete the now-redundant raw MongoDB files to free disk space — guarded so it only runs after a confirmed-successful migration, behind a confirmation. New admin-gated server methods sandstormMigrationStatus / sandstormDeleteRawMongo (server/methods/sandstormMigration.js); the migration importer now writes a migration-status.json the panel reads.

  • Sandstorm: grain launcher + spk build tooling (Node 24 / FerretDB, no releases.wekan.team):

    Added the grain launcher sandstorm-src/start.js: on first launch it runs the migration chain for whatever an existing grain holds — niscu (MongoDB 2.x) → MongoDB 3.0 (the preserved legacy path for very old grains) then MongoDB 3.0 → FerretDB v1 — then runs WeKan (Node 24) on FerretDB. Migration support from old versions is permanent (niscud + mongod 3.0 are kept). Added the deps-assembly script sandstorm-src/build-deps.sh which builds a modern meteor-spk.deps on top of upstream meteor-spk 0.6.0 (dl.sandstorm.io) — swapping in Node 24, adding FerretDB + the Mongo 3.x CLIs + the launcher/importer, keeping niscud — with extra binaries fetched from GitHub releases (the retired releases.wekan.team / old projects.7z are no longer used). sandstorm.yml now calls it, and WRITABLE_PATH in sandstorm-pkgdef.capnp is /var/files. Build/CI only — not yet packed/tested end-to-end in a grain.

Thanks to xet7.

Thanks to above GitHub users for their contributions and translators for their translations.

v9.84 2026-07-11 WeKan ® release

This release adds the following features and fixes:

  • Fix scheduled backup cron init crashing at server startup on Meteor 3:

    The scheduled-backup cron used Meteor's synchronous Mongo API (BackupSettings.findOne/upsert), which Meteor 3 no longer allows on the server — startup logged findOne is not available on the server. Please use findOneAsync() instead. and the cron silently never registered, so scheduled backups did not run. registerCron() is now async and uses findOneAsync, the getBackupSchedule/saveBackupSchedule methods use findOneAsync/ upsertAsync, and every registerCron() caller awaits it. Added tests/backupCron.test.cjs (positive + negative cron-registration tests and a source guard that fails if the synchronous server-forbidden API is reintroduced), wired into the test:unit:node suite.

  • Snap: show the correct writable path for parallel installs in backup instructions:

    The backup/migration help text printed by wekan.help hardcoded /var/snap/wekan/common/files, which is wrong for a parallel snap install (e.g. wekan_customer, whose data lives under /var/snap/wekan_customer/common). It now prints $SNAP_COMMON/files, which snapd sets per instance. Display-only; all functional snap paths already derive from $SNAP_COMMON/$SNAP_DATA, so parallel installs were already fully supported.

  • Fix flaky server-side Mocha i18n test (TAPi18n .loadLanguage):

    The .loadLanguage suite stubbed addResourceBundle on the shared TAPi18n.i18n singleton and asserted the call count. Because loadLanguage() reads this.i18n late and each test re-init()s the singleton, cross-test state could leave the stub watching a different i18next instance than the one the bundle was registered on, so the count read 0 and the run intermittently reported expected addResourceBundle to be called once. The tests now assert on observable i18next state (hasResourceBundle/getResourceBundle under the normalised toI18nCode), which is instance-agnostic and deterministic and mirrors the reliable .setLanguage suite. Also stopped a leaked Tracker.autorun in the .getLanguage reactive test (the cross-test hazard).

  • Fix rebuild-all.yml s390x build; add bundled FerretDB v1 for ppc64le, s390x, riscv64 and to the Docker image:

    The extra-arch bundle build ran node:24-slim under QEMU, but the official node:24 image publishes no linux/s390x manifest (only amd64/arm64/ppc64le), so the s390x leg failed with "no matching manifest for linux/s390x". The emulated native-module rebuild now runs on an ubuntu:26.04 base (which publishes every arch) and installs Node.js from nodejs.org; riscv64 uses unofficial-builds.nodejs.org (nodejs.org ships no riscv64).

    MongoDB Community only ships amd64/arm64 server binaries, so on ppc64le, s390x and riscv64 — the other architectures with a Node.js 24 build — WeKan now bundles FerretDB v1 (the wekan/FerretDB fork with its embedded pure-Go SQLite backend, which speaks the MongoDB wire protocol) instead of requiring MongoDB. The FerretDB binary is cross-compiled once for all five architectures (CGO off, static, no QEMU) and embedded in every .zip next to main.js, so amd64/arm64 users can opt in too with WEKAN_DB=ferretdb. The Docker image now covers all five architectures and auto-starts FerretDB on the MongoDB-less ones. FerretDB telemetry is disabled and locked (--telemetry=disable, plus DO_NOT_TRACK). armv7l/32-bit ARM is still not built: there is no Node.js 24 build for it anywhere, so nothing could run WeKan there regardless of RAM.

<details> <summary><a href="https://github.com/wekan/wekan/commit/598aa8dfa1cc406243e0db681573093d9db7783f">Snap: choose MongoDB or FerretDB v1 with "snap set wekan database=ferretdb"; FerretDB default for…</a></summary>

** Snap: choose MongoDB or FerretDB v1 with "snap set wekan database=ferretdb"; FerretDB default for new installs; disable mongosh telemetry **:

</details>

The WeKan snap gains a database setting (mongodb or ferretdb). A new ferretdb service runs FerretDB v1 (SQLite) on the same port MongoDB would use, so MONGO_URL is unchanged; only one database runs at a time, and the configure hook stops one and starts the other when the setting changes. Switch with snap set wekan database=ferretdb (or back with database=mongodb).

New snap installs default to FerretDB on all platforms (via the install hook, which runs only on fresh installs — upgrades keep MongoDB, and a pre-existing MongoDB data directory is detected and kept so no data is lost). There is no snap install --db=ferretdb flag (snapd has no custom install options); use the two-step snap install wekan --channel=latest/beta then snap set wekan database=ferretdb.

mongosh collects anonymized usage analytics by default and the snap invokes it several times; it is now disabled so nothing phones home. mongod itself has no phone-home telemetry (Cloud Free Monitoring is opt-in and stays off).

<details> <summary><a href="https://github.com/wekan/wekan/commit/3824066f7fb88acd66bc1f4c70350e8516f5f00b">Standalone ferretdb.zip for all platforms, and rebuild-all.yml uses the prebuilt one from…</a></summary>

** Standalone ferretdb.zip for all platforms, and rebuild-all.yml uses the prebuilt one from wekan/FerretDB releases **:

</details>

FerretDB v1 (the wekan/FerretDB fork, pure-Go SQLite backend, CGO off, no QEMU) is now cross-compiled for every platform Go and modernc.org/sqlite support — far beyond the arches Node.js ships — and packed into a single ferretdb.zip:

  ferretdb/<arch>/ferretdb-<arch>        (Linux/macOS/BSD, executable)
  ferretdb/<arch>/ferretdb-<arch>.exe    (Windows only)
  ferretdb/README.md                     (links to https://github.com/wekan/FerretDB)

That zip is produced and released in the wekan/FerretDB repo (by its build.sh, which gained sequential and parallel "Build ferretdb.zip" menu options). The WeKan release workflow no longer builds FerretDB with Go: it downloads the newest ferretdb.zip from https://github.com/wekan/FerretDB/releases and every WeKan build (the bundle .zip for each arch, and via the bundle the Docker image and snap, including the Windows and macOS bundles) embeds its per-arch binary from that one source. ferretdb.zip is not re-attached to the WeKan releases.

  • Fix #6445: dynamic-import chunks 404 under a sub-path (duplicated build-chunks/build-chunks/):

    Under a sub-path deployment (ROOT_URL like https://host/wekan, usually behind a reverse proxy that strips the prefix), language selection and other lazy-loaded features failed with ENOENT ... build-chunks/build-chunks/<id>.js. rspack's client runtime builds each chunk URL as public-path + chunk-name, and the chunk name already carries the build-chunks/ prefix, but client/00-startup.js set the sub-path public path to <sub-path>/build-chunks/, so rspack appended a second build-chunks/. It now sets the public path to just <sub-path>/ and lets rspack add build-chunks/ itself.

  • Add snapcraft-core24.yaml so the newest WeKan can be published to the Snap Stable channel:

    The main snapcraft.yaml uses base: core26, which (until core26 is released) needs build-base: devel + grade: devel, so it can only go to Snap Beta/Edge. snapcraft-core24.yaml builds the SAME newest WeKan (Meteor 3.5, Node.js 24, FerretDB v1, MongoDB 7, Caddy 2) on base: core24 (a released base, grade: stable), so the Snap Stable channel can finally be updated from the old 6.09 snap. Only base/build-base/grade differ.

  • Self-contained release bundles: bundle Node.js + FerretDB + start-wekan.{sh,bat}:

    Each wekan-<version>-<arch>.zip is now fully offline. Its bundle/ directory contains the WeKan server, a Node.js binary for that platform, a FerretDB v1 (SQLite) binary, and a start-wekan.sh (start-wekan.bat on Windows) that by default runs WeKan on the bundled Node against the bundled FerretDB SQLite, storing data and attachments/avatars on the filesystem under WRITABLE_PATH (as in the Windows Offline guide) — no separate Node or database install needed. The Docker image and snap, which have their own Node and entrypoint, strip the redundant bundled Node + launchers to stay small.

  • Standalone sandstorm.yml workflow to build + attach the .spk (Sandstorm removed from release-all.yml):

    Sandstorm packaging (mirroring releases/release-sandstorm.sh + install-sandstorm.sh: installs Meteor, meteor-spk 0.6.0 and a dev Sandstorm, runs meteor-spk pack) is not tested well enough yet, so it no longer runs as part of a full release — the build-sandstorm job was removed from release-all.yml. Instead a separate, manually-triggered sandstorm.yml builds ONLY the .spk and uploads it as wekan-sandstorm-YYYY_MM_DD-HH_MM_SS.spk to the newest WeKan GitHub Release (plus a workflow artifact), so it can be downloaded and tested for errors without affecting releases. Experimental in CI — Sandstorm needs unprivileged user namespaces, and signing the .spk needs the app private key via the SANDSTORM_KEYRING secret; spk publish / scp upload stay manual.

  • Migration: resumable progress in WRITABLE_PATH + compact the old MongoDB after success:

    The MongoDB → FerretDB / GridFS → filesystem migrator (used by the Snap and Sandstorm migrations) now checkpoints progress to $WRITABLE_PATH/migration-progress.json after every collection and file phase, so an interrupted migration (snap refresh, Sandstorm grain restart, power loss) resumes on restart instead of starting over — skipping collections already copied. Once migration has completed, a later boot reclaims the now-duplicated disk space in the old MongoDB by running compact on each source collection (best-effort, once). The existing ROOT_URL progress dashboard and disk-space checks are kept, and the dashboard is restored from the checkpoint on resume.

  • Snap: migrate the Caddyfile from Caddy v1 to Caddy v2 format on upgrade:

    When upgrading from an old WeKan snap, $SNAP_COMMON/Caddyfile may still be in Caddy v1 syntax, which Caddy 2 cannot parse (caddy would fail to start). caddy-control now runs a converter before caddy run when caddy is enabled: a no-op if the file already parses as Caddy 2, otherwise it backs up the original, converts the common v1 directives (proxy / TARGETreverse_proxy TARGET, drop the v1 websocket/transparent presets Caddy 2 does by default, gzipencode gzip, strip the http:// scheme), validates with caddy adapt, and only keeps a valid result — falling back to the shipped Caddy 2 template otherwise, so caddy always starts with valid config.

  • Rename docker-compose-ferretdb.yml to -v2-postgresql.yml and add -v1-sqlite.yml:

    docker-compose-ferretdb.yml (FerretDB 2 + PostgreSQL) is renamed to docker-compose-ferretdb-v2-postgresql.yml, and a new docker-compose-ferretdb-v1-sqlite.yml runs WeKan against FerretDB v1 with the embedded SQLite backend — no PostgreSQL or MongoDB — fetching the v1 binary for the container's architecture from the newest wekan/FerretDB release. Both compose files now carry the FULL wekan service from docker-compose.yml (every documented environment variable and feature); the only differences are database-related (the ferretdb service replaces mongodb, MONGO_URL points at FerretDB, MONGO_OPLOG_URL is dropped and reactivity is polling, since FerretDB has no MongoDB change streams / replica-set oplog).

  • Snap: one-time MongoDB → FerretDB v1 migration on upgrade, with live progress at ROOT_URL:

    On first boot after upgrading an old MongoDB-based WeKan snap, mongodb-control hands off to a new migration-control before starting mongod. It opens the existing MongoDB data with the right bundled mongod — mongod 7 for MongoDB 7 data (all arches, including the arm64 snaps already on newest WeKan), or a bundled old mongod 3.2 (amd64 only) for WeKan 6.09 / MongoDB 3.2 data — starts a temporary FerretDB v1 (SQLite), and runs the migrator, which moves text data to FerretDB and both CollectionFS GridFS and Meteor-Files GridFS attachments+avatars to the filesystem, shows a live progress counter at ROOT_URL, checkpoints to $SNAP_COMMON (resumable), and compacts the old MongoDB when done. It then switches the snap to database=ferretdb. Idempotent, resumable, never deletes the source data, only switches on success. (The amd64 6.09/MongoDB-3.2 path needs testing on real 6.09 data; the arm64/MongoDB-7 path uses the already-bundled mongod 7.)

  • Snap: bundle migratemongo (MongoDB 3.2 binaries + old libraries + AVX wrappers) to read 6.09 data:

    Per docs/Backup/Backup.md and https://github.com/wekan/migratemongo, running the old MongoDB tools/server in the snap needs LC_ALL=C and their libraries on LD_LIBRARY_PATH ($SNAP/lib/<arch>-linux-gnu), and the 2016 MongoDB 3.2 binaries additionally need old libraries (libssl/libcrypto.so.1.0.0, libpng12, libexpat) that modern bases lack. A new migratemongo snapcraft part (amd64) stages https://github.com/wekan/migratemongo at $SNAP/migratemongo (its MongoDB 3.2 bin/, the old lib/x86_64-linux-gnu/, and the avx/ QEMU wrappers) — also filling in the $SNAP/migratemongo/avx path that mongodb-control/-backup/-restore already referenced but was never bundled. migration-control now reads the amd64 6.09 MongoDB 3.2 data with that mongod (old LD_LIBRARY_PATH) and the legacy mongo shell (mongosh cannot talk to 3.2).

  • Snap migration: read MongoDB 3.2 via migratemongo CLI (dump → restore into mongod 7), fix migrator NODE_PATH:

    The bundled Node MongoDB driver can't connect to a 3.2 server, and no single driver version spans 3.2 and MongoDB 7 / FerretDB — so rather than aliasing an EOL Node driver into package.json, the amd64 6.09/3.2 case uses the migratemongo CLI: migration-control dumps the wekan database with the old migratemongo mongodump (MongoDB 3.2, old libraries), then loads it into a fresh temporary mongod 7 with the snap's modern mongorestore. The existing driver-based migrator then reads that mongod 7 exactly like the arm64 MongoDB-7 case (both GridFS types → filesystem, text → FerretDB v1 SQLite); the MongoDB-7 path connects the driver directly, unchanged. Also fixes a real bug: the standalone migrator in $SNAP/bin could not resolve its mongodb/bson imports — NODE_PATH now points at the WeKan bundle's node_modules.

  • Snap migration: only MongoDB 3 migrates (mongo CLI read + Node driver insert), FerretDB SQLite at files/db:

    Refines the snap migration to the intended design: a MongoDB 7 database works with newest WeKan as-is and is not migrated; only the old 6.09 / MongoDB 3.2 data is. migration-control now checks whether mongod 7 can open the data — if so it keeps MongoDB; otherwise it migrates the 3.x data. It reads it with the legacy mongoexport CLI (the Node driver can't talk to 3.2) and inserts into FerretDB with the Node driver — text streamed directly, GridFS attachments+avatars reassembled per-file straight to files/attachments/files/avatars — with no mongodump/mongorestore and no intermediate MongoDB 7 (which the earlier commit used). FerretDB's SQLite now lives at <files>/db, next to attachments/avatars (the files/<name> layout), across the snap, offline launchers, Docker entrypoint and the v1-sqlite compose.

  • Admin Panel / Attachments: migrate text data between MongoDB and FerretDB v1 (SQLite), both directions:

    A new "Database migration" section in Admin Panel / Attachments with two buttons: migrate text-based data (everything except attachments/avatars, which stay on the filesystem) to FerretDB v1 (SQLite) or back to MongoDB. WeKan is connected to one database at a time, so the server opens a second driver connection to the OTHER database (both speak the MongoDB wire protocol) and copies the text collections into it, upserting by _id (idempotent). The target is WEKAN_FERRETDB_URL (default mongodb://127.0.0.1:27018/wekan) or WEKAN_MONGODB_URL (default :27019); both must be running. Progress is shown live; afterwards point MONGO_URL at the other database and restart (Snap: snap set wekan database=ferretdb / =mongodb). Admin-only.

  • Admin Panel / Attachments / Backup: scheduled backups streamed to storage, restore + list:

    A new Backup section in Admin Panel / Attachments. Select any of Attachments, Avatars, Data (all text-based collections that are not attachments/avatars) and a storage (filesystem, S3/MinIO, Azure, GCS). "Backup now" streams the .zip directly to the selected storage — no temp file, no extra disk — as backup/YYYY/MM/DD/HH_MM_SS/backup.zip containing YYYY_MM_DD-HH_MM_SS/{attachments,avatars,data/<collection>.json} (filesystem pipes to the file; S3 uses @aws-sdk/lib-storage streaming, Azure uploadStream, GCS createWriteStream, with the cloud credentials from the storage tabs). A scheduler (off/daily/weekly/monthly + time/day) runs backups via synced-cron. List backups shows a table (storage, datetime, path); pick one and Restore with "Add missing data only" or "Replace all data". Admin-only. (Cloud upload and restore are not exercised end-to-end yet; jszip assembles the whole zip, so very large attachment sets use notable memory.)

  • Backup: switch from jszip to archiver+unzipper for low-memory streaming:

    Follow-up to the Backup section above: it no longer holds whole files or the whole zip in memory. The backup .zip is written with archiver, streaming each attachment/avatar straight from disk and each text collection a document at a time from a Mongo cursor as NDJSON, piped directly to the destination (filesystem or S3/Azure/GCS streaming upload). Restore uses unzipper: each file entry is piped to disk and each NDJSON data entry is applied line-by-line in 200-doc batches. A board with thousands of cards or a 5 GB attachment now backs up and restores with flat memory.

  • Stream board exports (JSON, CSV/TSV, Excel) with bounded memory:

    The board export routes used to buffer the whole board in memory: the JSON export built one object with every card, comment, activity, checklist and base64 attachment; CSV called that same builder; Excel additionally loaded data it never renders and did O(n²) find() lookups. On large boards this peaked at gigabytes and could exceed V8's max string length. Now the JSON export writes the document straight to the response a card at a time from raw cursors (attachments base64-encoded in aligned chunks), CSV streams one row per card keeping only the small lookup tables in memory, and Excel uses the exceljs streaming WorkbookWriter, committing each row and resolving card titles via an id→title map. Peak memory stays flat regardless of board size, and the JSON output is unchanged so import round-trips.

  • Export board to HTML .zip: stream to disk and include every card:

    The HTML export cloned the live DOM and built the whole .zip as an in-memory blob — but infinite scroll keeps only ~10 cards per list in the DOM, so most cards were missing, and the archive was buffered whole in browser RAM. Every list's card limit is now lifted so the entire board renders before the snapshot, and the zip is written with JSZip's generateInternalStream piped straight to the chosen file via the File System Access API, chunk by chunk with backpressure (browsers without the API fall back to the previous blob download).

  • Lazy card loading for very large boards: CARDS_LOADING=all|lazy + Admin Panel / Features:

    A board's board publication normally ships every non-archived card (with full fields, comments, attachments and checklists) into each viewer's minimongo. The list rendering is already infinite-scrolled (~10 cards per list in the DOM), but the whole dataset still crosses the wire and sits in browser memory, so a board with thousands of cards is heavy for every viewer.

    A new CARDS_LOADING mode (all default, or lazy) makes each list load only the cards it is about to render. In lazy mode the board publication ships no cards; instead each list/swimlane subscribes to a windowed publication (boardCardsWindow) for just its visible window — growing as you scroll — plus a reactive total count (boardListCardCount) so it knows when more remain. The window selector is ANDed with a server-forced board scope and refuses $where. This is set by the CARDS_LOADING env var (exposed in docker-compose*.yml, the bundle start-wekan.sh/.bat, and snap set wekan cards-loading=lazy) and also at runtime in a new Admin Panel / Features section — the intended home for optional / performance / future tier-gated capabilities. (client + Features, platform env)

    Lazy mode is opt-in and experimental: card counters and WIP limits are accurate (they read a server count for the list's exact selector), but the Calendar/Table/Gantt views and multi-select currently reflect only the cards loaded so far, and open boards must be reloaded after switching modes. Default all is unchanged. (accurate counters + WIP)

  • Fix Transifex push: remove duplicate database-migration i18n keys:

    en.i18n.json had database-migration and database-migration-description defined twice, so the Transifex source push failed with "Duplicate string key". The later, unused copies ("Database Migration" / "Updating database structure…") were removed, keeping the values the Admin Panel / Attachments migration UI actually renders. JSON now parses with unique keys.

  • Finnish translations for the newest features:

    Translated the remaining English strings of the Upcoming features into Finnish (fi.i18n.json), matching existing terminology: Admin Panel / Version (database type, FerretDB/MongoDB version + commit, reactivity mode), Attachments / Database migration, Features (card loading all/lazy), and Backup (schedule, storage, restore, list). 44 keys; product names/acronyms kept as-is.

  • Unit + negative tests for the newest features:

    Extract the pure, security-/correctness-critical logic of the recent features into Meteor-free models/lib/* modules (shared by production and tests) and add plain-Node tests with positive and negative cases, wired into test:unit:node: the windowed-card publication's $where selector safety, CARDS_LOADING mode resolution + window-count id, the JSON export's streaming base64 chunker, and the backup files-root + schedule-text helpers. 41 assertions, all passing.

  • docs: Design/Multiverse/Alternative-Architectures.md:

    Document which CPU architectures WeKan can run on and why (the limit is Node.js, not FerretDB): the Node 24 / MongoDB / FerretDB v1 matrix, why armhf/armv7l/i386 are unsupported (Node dropped 32-bit) and loong64 is not buildable in CI (no QEMU emulation / base image), the JavaScript-engine alternatives (Deno/Bun cover fewer arches; QuickJS/JSC/SpiderMonkey/JVM run on 32-bit but cannot run Meteor), and server-rewrite options (Go recommended, QuickJS niche, Tcl/Tk a poor fit).

  • Fix backup build: archiver@8 (ESM) + @aws-sdk/lib-storage dependency:

    The Meteor/rspack build failed on server/methods/backup.js: archiver@8 is now pure ESM with no default export or archiver('zip', …) factory (it exports classes), and the S3 streaming upload used @aws-sdk/lib-storage, which was not a dependency. Use import { ZipArchive } from 'archiver' / new ZipArchive({ zlib: { level: 6 } }), and add @aws-sdk/lib-storage (pinned to ~3.1073.0 to match the installed @aws-sdk/client-s3).

Thanks to xet7.

Thanks to above GitHub users for their contributions and translators for their translations.

v9.83 2026-07-09 WeKan ® release

This release fixes the following bugs:

  • Fix #3624 swimlane REST regression: await the async board.swimlanes():

    The swimlanes CRUD e2e test (23-rest-api-more.e2e.js:209) regressed on all browsers. Root cause: on the server ReactiveCache.getSwimlanes is async, so board.swimlanes() returns a Promise in the REST handler. The #3624 change read board.swimlanes().map(s => s.sort); .map on a Promise throws, the insert never ran, the error was swallowed by the handler's catch (returned as 200 with no _id), and the test read .title off a null swimlane.

    The earlier "move the require to a top-level import" commit was not the real cause (the same module.exports import pattern works server-side, e.g. ruleDeletePermission in rulesButton.js). The actual fix is to await board.swimlanes() before mapping. This also means API-created swimlanes now get a real max(existing sort)+1 sort — previously the un-awaited .length yielded undefined, so they were stored with no sort at all, which is the #3624 symptom.

    Verified: the swimlane test passed in every kept local run through 2026-07-09_02-38-07 and failed starting 09-52-25 (right after the #3624 change), confirming the regression window; the tests/swimlaneSort.test.cjs unit test still passes.

Thanks to above GitHub users for their contributions and translators for their translations.

v9.82 2026-07-09 WeKan ® release

This release fixes the following bugs:

<details> <summary><a href="https://github.com/wekan/wekan/commit/9f7eac158e99f1ae38dedf586bc43225e267722c">Fix #5536: automated rule can now move/link a card to a different board</a>. Thanks to DarthKillian and xet7.</summary>

** Fix #5536: automated rule can now move/link a card to a different board **: The rules "move card to top/bottom of a list on another board" and "link card to another board" actions failed for boards the rule creator did not own. The action showed BLANK after creating the rule (the wizard's optimistic client inserts landed in minimongo limbo / were rejected by allow-deny for non-owner members) — these now create the rule through the server rules.createRule method, keeping the action's destination boardId. And execution crashed with an "Internal Server Error" because the destination swimlane fallback dereferenced ._id on a possibly-undefined swimlane titled exactly Default (renamed/translated/deleted on the destination board); resolution now uses the board's real default swimlane via a Meteor-free resolver models/lib/ruleActionResolve.js, unit tested in tests/ruleActionResolve.test.cjs

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/8a8470772627aa4b315bdd01712d31051be87a74">Fix #4978: board background updates when switching boards via the favorites bar</a>. Thanks to dasarne and xet7.</summary>

** Fix #4978: board background updates when switching boards via the favorites bar **: Switching directly between two boards via the favorites bar reused the same boardBody template instance, so the one-shot setBackgroundImage() in onRendered never re-ran and the previous board's background stuck. It is now applied inside a reactive autorun and clears any stale inline background when the new board has no image. The decision is a Meteor-free helper models/lib/boardBackground.js, unit tested in tests/boardBackground.test.cjs

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/e5c844fca7190d158302472862bab380707ff3aa">Fix #4881: Due this week / next week filter respects the start day of week</a>. Thanks to mimZD and xet7.</summary>

** Fix #4881: Due this week / next week filter respects the start day of week **: The "Due this week" filter selected next week's cards and ignored the configured start weekday, because it derived its window from startOf(now(), 'week') — which the native dateUtils never implemented, so it returned the date unchanged. A new Meteor-free helper models/lib/weekStart.js computes the correct week window for any start day of week; the this/next-week buttons now toggle per week too. Unit tested in tests/weekStart.test.cjs

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/75df523ef0324dd018955c82370df97fb3a3b8cc">Fix #4946: calendar week numbers respect the defined start day of week</a>. Thanks to helioguardabaxo and xet7.</summary>

** Fix #4946: calendar week numbers respect the defined start day of week **: In the Calendar view the week-number column was numbered from Sunday regardless of the start-day-of-week setting. The calendar now computes the number with weekNumberByFirstDay() (in models/lib/weekStart.js) from the same firstDay used to lay out the grid, unit tested in tests/weekStart.test.cjs

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/0119e62da5b6021d6a1fd7f67a9e6960efa50322">Fix #4653: LDAP username with a hyphen no longer becomes a dot</a>. Thanks to RowhamD and xet7.</summary>

** Fix #4653: LDAP username with a hyphen no longer becomes a dot **: With LDAP_UTF8_NAMES_SLUGIFY enabled, limax(text, { separator: '.' }) turned every non-alphanumeric run — hyphens included — into ., so an LDAP username like p.parta-partb became p.parta.partb and the user could not log in. The username is now slugified per hyphen-separated segment and rejoined with -, preserving hyphens while still transliterating UTF-8. Pure helper packages/wekan-ldap/server/usernameSlug.js, unit tested in tests/ldapUsernameSlug.test.cjs

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/a9a5ac7a07050cbea746ab0a0a33e2e1f0f8dc1f">Fix #4236: Enter adds a new line in the card title, consistent with the description</a>. Thanks to listenerri and xet7.</summary>

** Fix #4236: Enter adds a new line in the card title, consistent with the description **: The card title textarea submitted on plain Enter (only Shift+Enter made a new line), unlike the description field which inserts a new line on Enter and saves on Ctrl/Cmd+Enter. The title now uses the same shared, Meteor-free rule isSubmitKey() (models/lib/editorSubmitKey.js): submit only on Ctrl/Cmd+Enter, newline otherwise. Unit tested in tests/editorSubmitKey.test.cjs

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/84ab805ec1f123f738d12a3a1b6c45a128fa58f3">Fix #4055: ISO week-number regression test (already correct in current code)</a>. Thanks to marcungeschikts and xet7.</summary>

** Fix #4055: ISO week-number regression test (already correct in current code) **: #4055 reported the week number was one/two weeks too high for 2021-10-25..31 (ISO week 43). That was the old moment-based math; the current native, DST-safe getISOWeek() computes it correctly. Added tests/isoWeek.test.cjs pinning the reported dates so it cannot regress

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/e014d5c8169053805da407facdee98531c6c7d14">Fix #4394: Register / Forgot Password links stay hidden after a failed login</a>. Thanks to Alsterdetektive1 and xet7.</summary>

** Fix #4394: Register / Forgot Password links stay hidden after a failed login **: Security. With registration / forgot-password disabled in the Admin Panel, the links were hidden by a one-shot .hide() that a useraccounts form re-render (e.g. an LDAP failed login) dropped, so the links reappeared. The disable state is now a class on the stable <body> ancestor with matching CSS, so the links are re-hidden on every re-render

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/25cdc4db0d490b4bbd1cf3c66ac57325fd52cd04">Fix #4494: creating a board from a template no longer breaks subtasks</a>. Thanks to Xilef11 and xet7.</summary>

** Fix #4494: creating a board from a template no longer breaks subtasks **: A board created from a template inherited the template's subtasksDefaultBoardId / dateSettingsDefaultBoardId; when those pointed at the template board itself, subtasks created on the new board were dropped onto the TEMPLATE board and linked back across boards. Board.copy() now repoints such self-referential defaults to the copy and clears the paired list id so it self-heals on the new board. Pure helper models/lib/boardCopyDefaults.js, unit tested in tests/boardCopyDefaults.test.cjs

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/14b4f5b5ed34a31d6e077114c42ab80a6a965091">Fix #4249: filter by card title now works for renamed linked cards</a>. Thanks to Ben0it-T and xet7.</summary>

** Fix #4249: filter by card title now works for renamed linked cards **: Renaming a linked card wrote the new title only to the linked target, leaving the linking card's own title field stale; filter-by-title queries the own field, so linked cards dropped out of title filters after a rename. Card.setTitle() now also writes the linking card's own title. Pure helper models/lib/linkedCardTitle.js, unit tested in tests/linkedCardTitle.test.cjs

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/3117e419bbf6e0b0db53b2d1b8328dac1a5bfd8d">Fix #3606: activity feed no longer shows "edited/deleted comment undefined"</a>. Thanks to janchuelo and xet7.</summary>

** Fix #3606: activity feed no longer shows "edited/deleted comment undefined" **: The feed passed the comment id (absent on old activities, and gone for a deleted comment) into the activity string. Edit/delete activities now store the comment text and render it via Activities.commentDisplayText(), which falls back to the live comment text and then to an empty string — never "undefined". Pure helper models/lib/commentActivity.js, unit tested in tests/commentActivity.test.cjs

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/e3f17be14d9841b33be9dc93c2e55759778c5bdc">Fix #3624: new_swimlane REST API appends the swimlane last and accepts a sort</a>. Thanks to tamasberesoebb and xet7.</summary>

** Fix #3624: new_swimlane REST API appends the swimlane last and accepts a sort **: POST /api/boards/:boardId/swimlanes set the sort to the swimlane count, so a new swimlane appeared FIRST when existing sort values were non-contiguous. It now appends at max(existing sort)+1 and honors an optional explicit sort in the body. Pure helper models/lib/swimlaneSort.js, unit tested in tests/swimlaneSort.test.cjs

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/38471577534cfb558c41abe45f3c6cc064dbed50">Fix #3185: copying a card (or template) now copies its subtasks' checklists</a>. Thanks to ramses345 and xet7.</summary>

** Fix #3185: copying a card (or template) now copies its subtasks' checklists **: Copying a card inserted each subtask as a bare card document, so the subtasks' checklists (and items) were dropped and the copied subtasks came out empty. Card.copy() now copies each subtask's checklists onto the new subtask. Pure helper models/lib/subtaskCopy.js, unit tested in tests/subtaskCopy.test.cjs

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/1621f3784a824fa6f2e23bbd4107c16bf869ccf6">Fix #5439: list scrollbar is always visible on desktop and mobile browsers</a>. Thanks to xet7.</summary>

** Fix #5439: list scrollbar is always visible on desktop and mobile browsers **: List bodies used overflow-y: auto, so overlay scrollbars (macOS/iOS/Android/ Firefox) auto-hid. The list body now keeps a scrollbar visible across all engines — overflow-y: scroll, ::-webkit-scrollbar (Chrome/Safari/Edge/ mobile WebKit), scrollbar-width/scrollbar-color (Firefox/Gecko) and scrollbar-gutter: stable. Cross-browser rules in models/lib/scrollbarCss.js, applied in client/components/lists/list.css, unit tested (builder contract + applied CSS, positive + negative) in tests/scrollbarCss.test.cjs

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/3aaf8f9b6180661abb0a86becf15710fc6937ef8">Fix #6444: RTL — typing a card title no longer garbles other lists' minicard titles</a>. Thanks to xet7.</summary>

** Fix #6444: RTL — typing a card title no longer garbles other lists' minicard titles **: In an RTL language (e.g. Arabic) the board root is dir="rtl", and both the minicard title (a dir="auto" .viewer) and the add-card composer textarea were dir="auto" with no bidi isolation, so they shared the surrounding bidirectional context. Typing a strong RTL character into one list's composer re-resolved that shared context and visibly reflowed the displayed minicard titles of the OTHER lists (no data change, reverted on refresh). Each title and the composer now use unicode-bidi: isolate. Locked in (positive + negative) by tests/minicardBidiIsolation.test.cjs; browser RTL behaviour is covered by tests/playwright/specs/18-rtl-layout.e2e.js

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/984ec160daec54b0e6b16cd15d89884f1c4fadf1">Fix the Playwright CI test failures introduced by the #3624 and #4236 fixes above</a>. Thanks to xet7.</summary>

Fix the Playwright CI test failures introduced by the #3624 and #4236 fixes above: Two browser tests failed on Chromium, Firefox and WebKit after this release's changes. (1) 23-rest-api-more.e2e.js:209 (swimlanes CRUD): on the server ReactiveCache.getSwimlanes is async, so board.swimlanes() returns a Promise in the REST handler. The #3624 change read board.swimlanes().map(s => s.sort); .map on a Promise throws, so POST /api/boards/:boardId/swimlanes never ran the insert, the error was swallowed by the handler's try/catch (returned as 200 with no _id), and the test read .title off a null swimlane. The old board.swimlanes().length had tolerated the un-awaited Promise by silently yielding undefined (so API-created swimlanes were stored with no sort at all — the very #3624 symptom). Fixed by awaiting board.swimlanes() before mapping, which also gives API-created swimlanes a real max(sort)+1 value. (2) 02-cards-open-view.e2e.js:93 (editing the card title): #4236 deliberately made plain Enter insert a newline in the card title (Ctrl/Cmd+Enter saves), but the CardPage page object still saved with plain Enter, so the title never persisted and the read timed out — updated to save with Control+Enter ( 984ec16 ). The server-side Mocha, import-regression and Node E2E jobs were already green; this makes the Playwright jobs green too

</details>

Thanks to above GitHub users for their contributions and translators for their translations.

v9.81 2026-07-09 WeKan ® release

This release adds the following new features:

<details> <summary><a href="https://github.com/wekan/wekan/commit/b07c4d0a7bed0f03ba2d7d1ad1f030e7edf3dfff">Feature #5394: the Link-card popup's Cards dropdown is now sorted alphabetically</a>. Thanks to xet7.</summary>

** Feature #5394: the Link-card popup's Cards dropdown is now sorted alphabetically **: In the "Link to this card" popup, the Cards pull-down list is now sorted alphabetically by card title (case-insensitive, locale/numeric aware) instead of board sort order, so a card can be found on boards with many cards

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/7ce71dbb2e1eae5159503eefac7e6f349dfb59c8">Feature #5396: edit Lists (title, color) via the REST API + api.py commands</a>. Thanks to C0rn3j and xet7.</summary>

** Feature #5396: edit Lists (title, color) via the REST API + api.py commands **: Lists can now be edited through the REST API like cards can. The endpoint PUT /api/boards/:boardId/lists/:listId already accepted title, color, starred and wipLimit, but the color was stored unvalidated; it now validates the color with normalizeListColor (a named palette color or a custom #rrggbb hex) and rejects an unknown color with a clear 400 instead of silently storing None. The pure field/validation logic lives in a Meteor-free helper models/lib/listApiUpdate.js and is unit tested in tests/listApiUpdate.test.cjs. The api.py reference CLI gains two new commands mirroring editcard/editcardcolor: editlist BOARDID LISTID NEWLISTTITLE and editlistcolor BOARDID LISTID COLOR

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/30f36a7f319675bd3aac7a4ba0700427000726b9">Feature #5514: custom color-wheel (RGB/hex) picker with automatic readable text contrast</a>. Thanks to Ruyeex and xet7.</summary>

** Feature #5514: custom color-wheel (RGB/hex) picker with automatic readable text contrast **: The color pickers that previously offered only a fixed set of named colors now also include a native color wheel (<input type="color">), so any #rrggbb color can be chosen. The wheel was added alongside the existing swatches for card labels ("categories"), swimlanes ("tabs"), lists and cards. The schema color fields accept a custom hex in addition to the named palette, and existing named-color data keeps working; a stored hex is rendered with an inline background-color instead of the named CSS class. A new pure, Meteor-free helper models/lib/contrastColor.js computes a readable text color from sRGB relative luminance (white text on dark backgrounds, black on light), maps the named palette to hex, and validates/normalizes hex; it is applied as an inline text color wherever a chosen color is a background behind text (label chips, swimlane / list headers, minicard, card details header), so text stays readable on any color. Covered by tests/contrastColor.test.cjs (20 assertions pass)

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/53328f40c6e092988175ce820c14658c855390de">Feature #5621: Rules can set a date field to a custom time (value + minute/hour/day/week/month…</a> Thanks to xet7.</summary>

** Feature #5621: Rules can set a date field to a custom time (value + minute/hour/day/week/month later) **: The Rules "Set date relative to now" action previously only offset a date field (Start / Due / End / Received) by a whole number of DAYS. It now has a unit selector, so a rule can set a date field to now + <value> <unit> later, where the unit is minute(s) / hour(s) / day(s) / week(s) / month(s); negative values move the date earlier. Months use a real calendar-month add (e.g. keeping the same day-of-month) rather than a fixed 30-day approximation. Existing rules created before this change have no stored unit and keep working exactly as before (no unit ⇒ days), so the change is fully backward compatible. The offset math lives in a new pure, Meteor-free helper models/lib/relativeDateOffset.js used by server/rulesHelper.js and covered by tests/relativeDateOffset.test.cjs (15 assertions pass). Note that the related "overdue" Rules trigger and the "set date field to now" action from the same feature request already existed in an earlier release; this change adds only the missing custom-time unit selector

</details>

and fixes the following bugs:

<details> <summary><a href="https://github.com/wekan/wekan/commit/d41e17d6a088edf6abbb1f9517d2ecb58d618f03">Fix #5351: users are auto-added to organizations matching their email domain on sign-up</a>. Thanks to xet7.</summary>

** Fix #5351: users are auto-added to organizations matching their email domain on sign-up **: the Organization setting "Automatically add users with the domain name" (org.orgAutoAddUsersWithDomainName) could be configured, but nothing at sign-up ever read it, so a new user whose email domain matched an organization was never added to it; the Accounts.onCreateUser hook now, on every non-admin sign-up path (password registration, LDAP, invitation code, new OIDC user), adds the user to each organization whose configured domain exactly matches the domain part of their email (case-insensitive, exact — a subdomain does not match and an empty org domain matches nobody), using the same { orgId, orgDisplayName } membership shape used everywhere else and never duplicating an existing membership, with the matching decision extracted into a Meteor-free, unit-tested orgsToAutoAddForEmail helper

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/1ce2d8cc8871e5da6f0ce5dde04520885649c0ba">Fix #5369: the Activities show/hide control is now a clear eye / eye-slash icon toggle</a>. Thanks to xet7.</summary>

** Fix #5369: the Activities show/hide control is now a clear eye / eye-slash icon toggle **: the Activities panel's show/hide control was a generic, unlabeled material toggle switch whose ON/OFF meaning was counterintuitive; it is replaced on the card details Activities panel with an eye / eye-slash icon toggle (open eye = activities shown, crossed eye = activities hidden) that mirrors the login/register password-visibility toggle, so the icon reflects the current visibility, clicking flips it, and the tooltip states the action (Show activities / Hide activities). For consistency the board sidebar Activities toggle, which drives the same showActivities state, was switched from the check / empty-square icon to the same eye / eye-slash toggle. The underlying show/hide setting and its persistence are unchanged

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/aad00cdba2bb896068f4a896cf67df2a7bd79a73">Fix #5442: outgoing webhooks now include the label name on add/remove label</a>. Thanks to xet7.</summary>

** Fix #5442: outgoing webhooks now include the label name on add/remove label **: the addedLabel/removedLabel outgoing webhook (and notification) text showed a bare, generic "label" with no name, because labels are embedded in the board document but the Activities label() helper looked the label id up in the Cards collection and always returned undefined, so the __label__ token was never filled; the hook now resolves the label from the already-loaded board via getLabelById and a pure, unit-tested labelDisplayName helper (name, then color for a nameless label as shown in the UI, then the id), so the webhook always carries the label's display name

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/da0d338e336ecfd3c120753aaea064e9487bf3cd">Fix #5482: adding/editing a card description now triggers outgoing webhooks</a>. Thanks to xet7.</summary>

** Fix #5482: adding/editing a card description now triggers outgoing webhooks **: outgoing webhooks fire only when an operation logs an activity (the Activities.after.insert hook posts to the board's webhooks), but changing a card's description created no activity — unlike title/date changes — so no webhook was sent; the Cards before.update hook now logs an a-changedDescription activity on first-time set and later edits (but not on no-op / empty-to-empty saves), so the existing webhook hook fires, with a Meteor-free unit-tested descriptionChanged helper

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/8c499823cbc8c5785ec604015d55e28daa11336e">Fix: the Rules Workflow view is now fully translatable via i18n</a>. Thanks to xet7.</summary>

** Fix: the Rules Workflow view is now fully translatable via i18n *: the Rules Workflow view rendered its trigger/action palette chips ("Card is created", "Move card to top", "Set received date to now", etc.) as hardcoded English regardless of the UI language, while the rest of the page was translated; each palette entry now carries an i18n key translated at render time via TAPi18n.__ (reusing existing rule keys where they fit, plus new r-w- keys for workflow-only labels), and the slot clear tooltip now uses the existing r-remove key, so the whole view follows the selected language

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/68dd14140f620574a95db42dc1078a7b4bd97916">Fix: deleting a board rule no longer fails with "Access denied [403]"</a>. Thanks to xet7.</summary>

** Fix: deleting a board rule no longer fails with "Access denied [403]" **: deleting a rule ran three separate client-side Collection.remove() calls (Rules

  • Triggers + Actions), each gated by a per-collection allow() rule that resolved the board from that document's own boardId; when a trigger/action document had no resolvable boardId (legacy docs, or docs not published to the client) the board came back null, allowIsBoardAdmin returned false, and Meteor rejected the mutation with 403 "Access denied" — so the delete failed even for a legitimate board admin. Rule deletion now goes through a new server method rules.deleteRule that authorizes once (active board admin or site admin) and removes the rule, its trigger and its action server-side, bypassing the brittle client allow/deny; the permission decision is a Meteor-free, unit-tested helper and no permission is loosened
</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/0e9aed5a4b359188a9390f1132443614b3b0e133">Fix #5510: adding a board label via the REST API no longer errors/hangs</a>. Thanks to xet7.</summary>

** Fix #5510: adding a board label via the REST API no longer errors/hangs **: PUT /api/boards/:boardId/labels only sent a response when the body had a label key, so a body without one hung until the client timed out, and a bare-string label pushed a schema-invalid label and returned 200; the handler now always returns JSON (2xx on success, 4xx on bad input, real error status otherwise) via a pure, unit-tested input helper

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/d01526bbff892a946a58aae1b516bd0b82d23113">Fix #5604: CSV export no longer crashes on boards with dangling references</a>. Thanks to xet7.</summary>

** Fix #5604: CSV export no longer crashes on boards with dangling references **: exporting a large/old board to CSV failed with "Couldn't download - Network issue" because Exporter.buildCsv read .title/.username/.name directly on the result of looking up a card's deleted list/swimlane/owner/member/assignee/label/customField by id, throwing "Cannot read property 'title' of undefined"; the per-card row builder is now a null-safe helper that emits a blank cell for missing references while keeping identical output for well-formed cards

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/b0da9097c18dea12ccce8dec6de8a5cf2519551a">Fix #5656: the Calendar view now honors the active board filters</a>. Thanks to kerier and xet7.</summary>

** Fix #5656: the Calendar view now honors the active board filters **: the Calendar view queried cards by board and date only and ignored the active Filter sidebar (member / assignee / due-date / label / custom-field), so it showed every card in the interval unlike the Board / Lists / Swimlanes views; it now ANDs the Filter selector into its query and refetches when the filter changes

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/457713968cf3c365899271606398b7796a7b07f4">Fix #6442: All Boards "Custom (drag order)" — drop now persists the reorder</a>. Thanks to jullbo and xet7.</summary>

** Fix #6442: All Boards "Custom (drag order)" — drop now persists the reorder **: Follow-up to #6439, which restored the drag preview but left the drop a no-op: dragging a board on the All Boards page in Custom (drag order) mode showed the dashed-border preview, but releasing it snapped the board back without reordering. The drop handler built the current on-screen order from el.classList[0] of each .js-board, but the item is li.js-board(class="{{_id}} …") and Jade emits the literal js-board class FIRST, so classList[0] was the string "js-board" for every board — never the board _id. The ordered ids were therefore ['js-board','js-board',…], the (correct, unit-tested) computeReorderedSortIndex helper could not find the dragged/target ids among them, returned null, and nothing was written to profile.boardSortIndex; the preview still worked because it is driven by the dragover CSS class, independent of the id. The fix reads each board's _id from its Blaze data context (the same source dragstart uses via this._id) instead of the literal class, so the real display order reaches the reorder helper and the drop persists. Guarded by a new case in tests/boardSortReorder.test.cjs (the wrong-class extraction yields no mapping; real ids reorder)

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/a37e7d1cc7e1a4541c498c974aa97b2e36136065">Fix #6443: cards on a deleted swimlane are invisible in swimlane view</a>. Thanks to xet7.</summary>

** Fix #6443: cards on a deleted swimlane are invisible in swimlane view **: On some old boards a swimlane was deleted while its cards kept the now-dangling swimlaneId (an "orphaned" card), so those cards showed no content in swimlane mode even though they worked in list mode. Cards with no swimlane at all (null / '' / missing) already appear in every swimlane, but an orphaned card matched no existing swimlane and so was visible in NO swimlane (while list view, which applies no swimlane scope, still showed it) — exactly the reported symptom. The fix mirrors the existing orphaned-list fallback (Swimlanes.orphanedSwimlaneLists, which surfaces orphaned lists in the first swimlane) for cards: when a list's cards are fetched for the board's FIRST swimlane, the swimlane-membership clause becomes a single { swimlaneId: { $nin: <otherSwimlaneIds> } } (everything not owned by another existing swimlane: own id, null/'', missing, or orphaned). It stays a single field clause with no second $or, so the #6441 board-wide label filter still holds, and orphaned cards appear once — in the first swimlane — without a database migration. Threaded through the pure models/lib/swimlaneFilter.js helpers, the in-memory filterCardsByListAndSwimlane, a new List.orphanedCardsSwimlaneIds helper and the cards()/cardsUnfiltered()/allCards() model methods plus the listBody cardsWithLimit render helper. Covered by new cases in tests/swimlaneFilter.test.cjs (18 assertions pass; the #6441 regression guards stay intact)

</details>

Thanks to above GitHub users for their contributions and translators for their translations.

v9.80 2026-07-06 WeKan ® release

This release adds the following new features:

<details> <summary><a href="https://github.com/wekan/wekan/issues/5850">Admin Panel Domains table: pagination, column sort and search (like the Board Table view)</a>. Thanks to xet7.</summary>

** Admin Panel Domains table: pagination, column sort and search (like the Board Table view) **: The Admin Panel > People > Domains table loaded every domain (aggregated from all users) into the browser at once, with a fixed order and no search. It now behaves like the Board Table view: the server aggregates the domains and returns only one small page, so the whole list is never sent to the browser. You can order by the Domain or Users column (click the header to toggle ascending / descending, with a ▲/▼ indicator) and filter with a search box; prev/next controls page through the results. The search + sort + slice runs in the new pure, unit-tested models/lib/domainTablePage.js behind a new getDomainsWithUserCountsPage admin method (server/models/users.js), and the domainGeneral template (client/components/settings/peopleBody.{jade,js,css}) is now self-contained and fetches only the current page. Covered by tests/domainTablePage.test.cjs

</details>

and adds the following tests:

<details> <summary><a href="https://github.com/wekan/wekan/commit/dfa6c78d4f032a698f6feaad189c86da903d27fb">Verified and added a regression test for the board-invitation email language</a>. Thanks to xet7.</summary>

** Verified and added a regression test for the board-invitation email language **: Confirmed that a board-invitation email is localised in the existing recipient's own profile language, or — when the invitee is a new account created by the invite — in the inviter's profile language, defaulting to en (en.i18n.json) when none is set. The behaviour was already correct; the language choice is now extracted into the pure, unit-tested models/lib/inviteEmailLanguage.js used by inviteUserToBoard, and locked in by tests/inviteEmailLanguage.test.cjs

</details>

and fixes the following bugs:

<details> <summary><a href="https://github.com/wekan/wekan/commit/9ef7f4a07a6b40a2582af24b27fe133119adcd18">Linked-card minicard now shows the cover image of the real card</a>. Thanks to 32Dexter and xet7.</summary>

** Linked-card minicard now shows the cover image of the real card **: A linked card (created by "Link card to this card") on one board did not show the cover image of the real card it points at on another board, even though the card's other fields did. A linked card is only a placeholder — its real content lives on the card at linkedId — and every other minicard getter resolves through the real card (getTitle/getReceived/getDue/…), but the cover helpers read this.coverId directly, and a linked card has no coverId of its own. The real card's cover attachment is already published to the linking board (see the "linked cards" / "attachments for linked cards" children of the board publication), so this was purely a client-side resolution gap. Card.cover() and the minicard cover() helper now resolve the cover id through the real card via the pure, unit-tested models/lib/linkedCardCover.js; normal cards are unaffected. Covered by tests/linkedCardCover.test.cjs

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/ac368de06a9b1d7a29dd6e4af8dc2f81fa1e3db7">Fix date-picker calendar stays fully visible when opened low on a scrolled page</a>. Thanks to MarcusDger and xet7.</summary>

** Fix date-picker calendar stays fully visible when opened low on a scrolled page **: Opening a date field (due/start/end date, or a date custom field) low on the screen showed the calendar popup extending past the visible area, and — because the pop-over is position: absolute (document coordinates) — scrolling to reach it moved the calendar along with the page, so the full calendar could never be seen (the workaround was to close it, drag the field to the center and reopen). Popup._getOffset computed the space above/below the opener and the clamped top from the opener's DOCUMENT offset mixed with the VIEWPORT height, ignoring the page scroll, so on a scrolled page the anchored popup landed outside the visible viewport. The geometry now runs in viewport coordinates (subtracting the page scroll) and clamps the popup fully within the visible viewport, then converts back to document coordinates for the absolute style; when the page is not scrolled the output is unchanged. Extracted the math into the pure, unit-tested client/lib/popupOffset.js, used by client/lib/popup.js. Covered by tests/popupOffset.test.cjs

</details>

Thanks to above GitHub users for their contributions and translators for their translations.

v9.79 2026-07-06 WeKan ® release

This release fixes the following bugs:

<details> <summary><a href="https://github.com/wekan/wekan/commit/f8e31745d8104c58dc7bbcb4c1e4c615141a8e82">Fix #6439: Custom (drag order) sort on All Boards page now reorders via drag-and-drop</a>. Thanks to jullbo and xet7.</summary>

** Fix #6439: Custom (drag order) sort on All Boards page now reorders via drag-and-drop **: On the All Boards page the jQuery-ui sortable that reordered boards in the "Custom (drag order)" mode was removed when the page switched to HTML5 drag-and-drop for workspaces, and nothing replaced it, so dragging a board showed a not-allowed cursor and never updated profile.boardSortIndex. Added HTML5 dragover/drop reorder handlers on the board tiles in client/components/boards/boardsList.js (active only in the custom sort mode), a setBoardSortIndexes helper in models/users.js to persist the new order in one write, a drop-hint style in client/components/boards/boardsList.css, and extracted the reorder decision/index math into the pure, unit-tested models/lib/boardSortReorder.js. Covered by tests/boardSortReorder.test.cjs

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/dd90995db74593228d40bc504405620ec09c175d">Fix #6440: '+' add-item button on minicard checklist does nothing</a>. Thanks to jullbo and xet7.</summary>

** Fix #6440: '+' add-item button on minicard checklist does nothing **: On the minicard the checklist add-item <form> is rendered inside the a.minicard-wrapper anchor, so the native form submit event never reached the Blaze event map — the #5565 minicard-checklist work wired only a submit .js-add-checklist-item handler (which never fires there) and gave the Save button no click handler, so clicking "+" Save did nothing. Added an explicit click .js-submit-add-checklist-item-form handler in client/components/cards/minicard.js (mirroring the working edit-item button) that inserts the item, with the blank-input guard and title parsing extracted into the pure, Meteor-free models/lib/checklistItemTitles.js. Card-detail checklists are unaffected. Covered by tests/checklistItemTitles.test.cjs

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/9dca403782d476eb3c593fe614848ef02bb7f5a3">Fix #6441: label filter now applies board-wide across all swimlanes</a>. Thanks to jullbo and xet7.</summary>

** Fix #6441: label filter now applies board-wide across all swimlanes **: A label filter hid non-matching cards in one swimlane (e.g. "Focus") but left another swimlane (e.g. "Background") unfiltered. Each list scopes its cards to the current swimlane while also showing shared/orphaned cards that have no swimlane; that fallback was written as a bare top-level $or, which competes with the board Filter's own top-level $or (label/member criteria) when the two selectors are combined — dropping the label criterion in every swimlane except the default one. The swimlane-membership fallback is now a single swimlaneId: { $in: [id, null, ''] } clause (the same form already used in sidebarFilters.js, cardDetails.js and dialogWithBoardSwimlaneList.js), extracted to the pure, unit-tested models/lib/swimlaneFilter.js and used by client/components/lists/listBody.js and models/lists.js, so no second $or exists and the filter is always $and-combined and applied board-wide. Covered by tests/swimlaneFilter.test.cjs

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/fc4eadedc9109602ee029b60c44eeda869aa98c3">Fix flaky server-side Mocha test (i18n zh-CN "is not a spy")</a>. Thanks to xet7.</summary>

** Fix flaky server-side Mocha test (i18n zh-CN "is not a spy") **: The server-side Mocha suite intermittently reported "1 failing" on the #5756 imports/i18n/i18n.test.js region-tag test with TypeError: [Function] is not a spy. The assertion re-read TAPi18n.i18n.addResourceBundle, and the sinon-chai matcher (routed through chai-as-promised) could evaluate after this suite's afterEach had already run sinon.restore() — at which point the property is the original function, not the stub. The .loadLanguage tests now assert on the captured stub reference (restore unwraps the property but leaves the spy intact), which is deterministic. Reproduced and verified with a standalone sinon/chai script

</details>

Thanks to above GitHub users for their contributions and translators for their translations.

v9.78 2026-07-06 WeKan ® release

This release fixes the following bugs:

<details> <summary><a href="https://github.com/wekan/wekan/commit/b1b414e850c5494dd3849f8dab6db44fecef0196">Fix "Internal Server Error" when signing up despite the account being created</a>. Thanks to Firas-Git and xet7.</summary>

** Fix "Internal Server Error" when signing up despite the account being created **: Registering a new account showed a red "Internal server error" on the sign-up form even though the account was created and could sign in — which typically happens when SMTP is not configured. Root cause: useraccounts' ATCreateUserServer creates the account and then calls Accounts.sendVerificationEmail() (because sendVerificationEmail: true); when SMTP is missing/misconfigured that send throws after the user row is inserted, and with no try/catch the exception leaves the createUser method as an opaque HTTP 500. The verification email is best-effort at sign-up, so Accounts.sendVerificationEmail is now wrapped to log and swallow a transport failure — registration completes and redirects to sign-in — while an "already verified" error is re-thrown so the resend-verification flow still reports it. This mirrors the #5706 reset-password hardening. Covered by tests/verificationEmail.test.cjs

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/cb9c8973092df5aa3112d3d59e3da4d8793c628b">Fix can not add members to a Linked Card</a>. Thanks to ITT5 and xet7.</summary>

** Fix can not add members to a Linked Card **: A linked card (created by "Link card to this card") is only a placeholder on the board that links it — its members are stored on the real card it points at (linkedId), which lives on another board, and Card.getMembers() / assignMember() / unassignMember() already read and write that real card. But the member picker listed the members of the board you were viewing the linked card on, not the board the real card lives on. So on a board that links a card from another board, the picker offered the wrong set of members and toggling them did not behave as a consistent add/remove — "can not add members to the linked card". The picker now resolves a linked card to its real card's board and offers that board's active members (matching where the membership is actually stored), falling back to the current board for normal cards or when the real card isn't loaded yet. Extracted the target-card/target-board resolution into models/lib/linkedCardMembers.js, covered by tests/linkedCardMembers.test.cjs

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/001c258f967caa502bf1d1ebb147b8506596f4bf">Fix can't search numbers in custom fields</a>. Thanks to MarcusDger and xet7.</summary>

** Fix can't search numbers in custom fields **: Searching a board for the value of a number or currency custom field (e.g. a transaction number 2025001, or a currency amount 123) found nothing. Those field types store their value as a JS Number (the inputs save parseInt(...) / Number(...)), but Board.searchCards() only matched custom fields with { value: <regex> } — and a MongoDB / Minimongo regex only matches string values, so it silently skipped every numeric custom field. The search now also adds an exact numeric-equality clause when the term is a plain number (a comma is accepted as a decimal separator, matching the currency input), so numeric custom fields match too; text/title/description matching is unchanged. Card search runs against Minimongo on the client, which — like MongoDB — cannot regex a numeric field, so equality is the correct cross-environment match. Covered by tests/cardSearch.test.cjs

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/2242da4176edcd7ed6bbdd779fe2bafab8457b7c">Fix "Removed nonexistent document" crash during notification_cleanup</a>. Thanks to xet7.</summary>

** Fix "Removed nonexistent document" crash during notification_cleanup **: The scheduled notification-tray cleanup logged Exception in removed observeChanges callback: Error: Removed nonexistent document …. The crash itself came from the old cottz:publish-relations package (since replaced by reywood:publish-composite), but the cleanup that provoked it still fired one un-awaited removeNotification() per expired notification — a separate Users.update $pull each time — so a user with K stale notifications produced K writes to the Users collection, and every publication that republishes user documents re-ran its observers K times in quick bursts (the churn that surfaced the removed-document error), while any rejected write went unhandled. The cleanup now scans only users that have notifications and prunes each user's stale notifications in a single awaited $pull … $in. It also removes an activity's notifications only when every entry for that activity is read and past its removal age (so a freshly re-created unread notification sharing an activity id is not dropped), and guards missing/invalid read timestamps. Covered by tests/notificationCleanup.test.cjs

</details> <details> <summary><a href="https://github.com/wekan/wekan/issues/5698">Fix impossible to select another board in rules</a>. Thanks to Augustin356 and xet7.</summary>

** Fix impossible to select another board in rules **: In the IFTTT-Rules "Move card to the board" and "Link card to the board" actions, the board dropdown was empty for some users — the current board included — while colleagues in the same company could pick boards normally. Root cause: the dropdown filtered the (already access-scoped) client cache with 'members.userId': me, i.e. it only kept boards where the user has a direct member entry. A user who reaches a board through an Organization, Team or email-domain share — but is not listed individually in board.members — matched nothing, so the whole selector came up empty. The dropdown now filters by the same visibility rule as Boards.userBoards() (public OR active member OR active org OR active team OR active domain), so org/team/domain-shared boards appear too, while archived boards, template containers, the user's templates board and internal helper boards stay excluded

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/bc1f149ea373be46a6ac1ab6f71d382194acebeb">Fix board disappeared after adding another user</a>. Thanks to DVNBLMHC and xet7.</summary>

** Fix board disappeared after adding another user **: A board admin adding another user could make the whole board silently vanish from a user's board list, with no archive and "nothing out of order" in the logs. Root cause: the setBoardTeams server method (used by the board Teams/Members management popups) blindly overwrote the board's entire members array with a snapshot sent by the client. When that client's board document was stale — e.g. a member had just been added via inviteUserToBoard on the server and the change had not yet propagated to the client — the overwrite dropped members, and in the worst case the board's own admin, so the board no longer matched the board-list publication (which requires an active membership) and disappeared. Because a wholesale $set: { members } never passes through foreachRemovedMember(), no removeBoardMember activity was logged and no card/watcher/star cleanup ran, which is why the logs looked normal. setBoardTeams now reconciles against the authoritative server-side members instead of trusting the client snapshot: it never drops an active admin, keeps every existing member the client still lists, adds the members the client introduces, and only removes non-admin members the client explicitly omitted (an intentional team-leave) — logging and cleaning up each such removal so it is auditable rather than silent

</details>

Thanks to above GitHub users for their contributions and translators for their translations.

v9.77 2026-07-06 WeKan ® release

This release adds the following updates:

<details> <summary><a href="https://github.com/wekan/wekan/issues/6454">Snap: migrate any existing MongoDB (3, 7, or other) to FerretDB (SQLite) on upgrade, and fix the…</a> Thanks to xet7.</summary>

Snap: migrate any existing MongoDB (3, 7, or other) to FerretDB (SQLite) on upgrade, and fix the upgrade that failed with "could not start migratemongo mongod on the MongoDB 3.x data" ( #6454 ). The WeKan snap now moves EVERY existing MongoDB database onto FerretDB (SQLite) on first boot after an upgrade — text data into SQLite and the CollectionFS + Meteor-Files GridFS attachments/avatars onto the filesystem (files/attachments, files/avatars) — then shuts MongoDB down so only WeKan (Node.js) + FerretDB (SQLite) run. The MongoDB version only decides HOW the source is read: a modern MongoDB (6/7) is read with the mongodb driver, old MongoDB 3.x with the bundled migratemongo 3.2 CLI (mongoexport). This also fixes the reported failure: the old check was a false dichotomy — it probed whether mongod 7 could open the data and, if that short probe failed for ANY reason (journal recovery, a stale mongod.lock, a slow disk, a large oplog), assumed the data was MongoDB 3.x; a healthy MongoDB 7 database then went down the 3.x path, mongod 3.2 also could not open it, and — because mongodb-control execs the migration script — mongod never started, so the MongoDB service failed to activate and looped on every restart. Now a mongod-7-openable database is migrated with the modern importer instead of being misclassified; the mongod-7 readiness probe was lengthened (20s → 45s) so large databases needing recovery are not misread; the temporary source mongod is tracked by pidfile and force-stopped so it never leaves the dbpath locked; and if neither mongod can open the data (unusual/corrupt) or the tools are missing, the snap falls back to a normal MongoDB start (never leaving the service dead) and retries next boot. The MongoDB data is never modified or deleted; the snap only switches to FerretDB once the migration succeeds

</details> <details> <summary>Migration dashboard: use the Admin Panel product name instead of "WeKan". Thanks to xet7.</summary>

Migration dashboard: use the Admin Panel product name instead of "WeKan". If the migrated database has a product name set in Admin Panel (settings.productName), both the Snap and Sandstorm migration progress dashboards now show that name and do not mention WeKan; otherwise they default to WeKan.

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/0d4e48ddd6b9ee086f0aa10d9f7c9893fe1637e4">Retry snapcraft install in the release-all.yml snap job</a>. Thanks to xet7.</summary>

** Retry snapcraft install in the release-all.yml snap job **: The v9.76 release's snap job failed at sudo snap install snapcraft --classic with error: cannot install "snapcraft": too many requests — a transient Snap Store rate limit (429-style throttle) that instantly failed the whole job before any build ran. The install now retries up to 5 times with 30s backoff, so one store throttle no longer fails the release (matching the remote-build retry already in the same job)

</details>

Thanks to above GitHub users for their contributions and translators for their translations.

v9.76 2026-07-06 WeKan ® release

This release adds the following fixes:

<details> <summary><a href="https://github.com/wekan/wekan/commit/8b21158736f9e1360332903707c100bce3d6b164">Fix notification emails linked to /b/undefined/board/&lt;cardId&gt; instead of the real board</a>. Thanks to titver968 and xet7.</summary>

** Fix notification emails linked to /b/undefined/board/<cardId> instead of the real board **: On the server ReactiveCache.getBoard() is async and returns a Promise, but Cards.board() did not await it, so the synchronous Card.originRelativeUrl()/absoluteUrl() interpolated a Promise — board._id and board.slug were undefined, producing /b/undefined/board/<cardId> in card activity notification emails (the client UI was unaffected because this.board() is synchronous there). Fixed by making Card.originRelativeUrl(board)/absoluteUrl(board) accept an already-resolved board and fall back to this.boardId (always available synchronously) when the board is a Promise, and by passing the awaited board from server/models/activities.js so the correct board id and slug are used

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/a2802a850b57ef7ac183ddb581be666d14d43705">Release workflow: publish the Helm chart only after the Docker image is pushed</a>. Thanks to xet7.</summary>

** Release workflow: publish the Helm chart only after the Docker image is pushed **: In .github/workflows/release-all.yml the charts job now depends on the docker job (needs: docker) instead of running in parallel right after bump. GitHub Actions runs a job only when all of its needs jobs succeed, so the wekan/charts Helm chart is published only after the multi-arch image is live on Docker Hub / Quay.io / GHCR (and is skipped if docker fails). This prevents ArtifactHub from scanning a freshly published chart whose image tag does not exist yet and emailing the maintainer about the missing Docker image

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/45fb54794755663307ef89636e2822ef9c65428d">Fix thousands of unsolicited empty "Default" swimlanes created on some boards</a>. Thanks to brlin-tw and xet7.</summary>

** Fix thousands of unsolicited empty "Default" swimlanes created on some boards **: Board.getDefaultSwimline()/getDefaultSwimlineAsync() self-heal a missing default swimlane by reading the board's swimlanes and inserting one if none exist. That check-then-insert is a race: concurrent or repeated server calls for a swimlane-less board each saw zero swimlanes and each inserted a new one, so some boards accumulated 30 000+ empty "Default" swimlanes and became unloadable (the key 'default (en)' returned an object instead of string log is a harmless i18n side effect — the title correctly falls back to the string Default). Follow-up to the client-side #6382 fix, which only stopped the browser from auto-creating them. Fixed by making the server self-heal idempotent: the default swimlane is now upserted with a deterministic _id (<boardId>-default), so the _id unique index guarantees at most one default swimlane per board no matter how many times, or how concurrently, the getters run. archived/type are set explicitly in the $setOnInsert because their schema autoValue/defaultValue only fire on insert, not upsert. Note: this prevents new duplicates; boards that already accumulated thousands still need a one-off cleanup

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/2e7c4ede2a6429048882e68084bb286aedbc42a5">Reduce card flicker on drag by only writing changed fields on a card move</a>. Thanks to mimZD and xet7.</summary>

** Reduce card flicker on drag by only writing changed fields on a card move **: Card.move() wrote boardId/swimlaneId/listId into the update unconditionally, even for a same-board drag to another list (the reported case). Keeping boardId in the $set on every drag re-ran the boardId-gated Cards.after.update hook that re-syncs the card's checklists and checklist items via multi updates, ran the cross-board consistency guard and the denyCrossBoardMove deny-rule DB lookup, and invalidated more reactive dependents than necessary — server work and reactivity churn that contributed to a ~1s card flicker on large boards. Card.move() now writes only the fields that actually change (via the pure computeCardMoveModifier helper) and skips the write entirely when a card is dropped back in the same place, so a same-board move no longer touches boardId or the cross-board hooks. The moveCard/moveCardBoard activity generators already re-check doc.X !== oldX, so trimming the modifier does not drop any activity. Note: this reduces the drag-time work behind the flicker; the residual reactive re-render cost on very large boards is a separate performance topic

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/d5596f05b7ee63a114d3479c34e930f0d9d5549c">Fix DEFAULT_AUTHENTICATION_METHOD env var ignored, and Admin Panel Layout save hanging</a>.</summary>

** Fix DEFAULT_AUTHENTICATION_METHOD env var ignored, and Admin Panel Layout save hanging **: Two related problems with the default login authentication method

</details>
  • Env var ignored: the stored setting was only ever seeded as password and DEFAULT_AUTHENTICATION_METHOD was never applied, so operators setting it (e.g. Kubernetes/Helm DEFAULT_AUTHENTICATION_METHOD: ldap) saw no effect. Startup now applies the env var authoritatively: it seeds the value on a fresh install and, on existing installs, keeps the stored defaultAuthenticationMethod in sync with the env var on every boot (the operator's env is the source of truth), so the method can be configured entirely by env without the Admin Panel. The value is normalized (trimmed + lower-cased), so DEFAULT_AUTHENTICATION_METHOD=LDAP works.
  • Layout save hanging / not persisting: the authentication-method <select> is populated by an async Meteor.call, so clicking **Admin Panel

    Layout > Save** before it loaded sent an empty value for the required defaultAuthenticationMethod field, which silently failed validation — the save looked stuck and nothing changed. The save now falls back to the currently stored method when the select is empty, so a real value is never overwritten by ''. Both paths share one pure helper (resolveDefaultAuthenticationMethod) that never resolves to an empty string. Thanks to joe-speedboat and xet7.

<details> <summary><a href="https://github.com/wekan/wekan/commit/878a24f586d698307bc3af1e65c903147f87a59a">Fix #5808: linking a card to another linked card made both cards inaccessible</a>. Thanks to the reporter and xet7.</summary>

** Fix #5808: linking a card to another linked card made both cards inaccessible **: The "Link to this card" target picker only excluded template cards, so an existing linked card (or a card that already links back to the current board) could be chosen as a link target. That builds a chain/cycle of linkedId pointers, but the card helpers (getTitle/getBoardTitle/getRealId) resolve linkedId only one hop, so such a card renders as an empty/broken pointer and becomes effectively inaccessible (the reported freeze). As the reporter suggested, the fix prevents the configuration rather than allowing it: only a real card — not a linked card/linked board, not one of the linking board's own cards, and not a card that links back to one of them — may now be a link target. This is enforced both in the picker's query and re-checked at creation time (the options can be stale), via the pure isLinkableCardTarget guard, mirroring the existing #3328 parent/subtask cycle guard. Note: this stops new inaccessible links; any already-created ones still need manual cleanup

</details> <details> <summary><a href="https://github.com/wekan/wekan/commit/d00dc6056b241b0b3e383bb5b4e75ba10ed78f56">Fix the "Board not found" flicker (stale-while-revalidate for the client board cache)</a>.</summary>

** Fix the "Board not found" flicker (stale-while-revalidate for the client board cache) **: While viewing a board, the board view could briefly flash the "Board not found" shell — and on WebKit throw a Blaze Can't select in removed DomRange error tearing down the card view. Root cause: the client board cache (imports/lib/dataCache.js) re-fetches its value inside a reactive computation, and when the board doc is momentarily absent from minimongo (a subscription stops and restarts, so Meteor transiently removes the doc) the re-fetch returns undefined and that empty value is surfaced immediately. It self-recovers when the subscription re-delivers the doc, so it presents as a flicker — reliably reproduced only on Firefox/WebKit, where the reactive-render timing hits the window (Chromium did not, which is why it surfaced as browser-specific Playwright failures in 14-voting-watchers and 24-feature-issues). Fixed with an opt-in stale-while-revalidate mode on DataCache, enabled only for getBoard: a transient miss over an already-cached board keeps the last value and re-checks after a short delay, surfacing an empty result only if the board is still gone then (a genuine deletion / access loss). First-ever loads and caches that did not opt in are unchanged. Core decision extracted to the pure shouldDeferCacheMiss helper with unit tests

</details>

Thanks to above GitHub users for their contributions and translators for their translations.

v9.75 2026-07-05 WeKan ® release

This release fixes the following CRITICAL SECURITY ISSUE of ScannerBleed:

<details> <summary><a href="https://github.com/wekan/wekan/commit/1a222c4477e68c76fd6a866954b535fba0a78d05">ScannerBleed</a>.</summary>

** ScannerBleed : shell injection (RCE) via a malicious upload filename in the external antivirus scanner command path** (GHSA-x3xm-pxrv-jg7p, CWE-78 OS Command Injection). Same RCE class as AvatarBleed (CVE-2026-52891, GHSA-35j7-h385-2q9g) and its follow-up regression (CVE-2026-53447 / GHSA-qfqv-42qw-vvwh area), but in a code path that was never covered by those fixes. In models/fileValidation.js, when an admin has configured an external scanner (antivirus) command line with a {file} placeholder, the uploaded file path was interpolated into the command and run through asyncExec (promisify(exec)), which spawns /bin/sh -c and interprets all shell metacharacters: ```js await asyncExec(externalCommandLine.replace("{file}", '"'

  • fileObj.path + '"')); ``` Wrapping the path in double quotes is not a shell boundary — inside double quotes the shell still expands $(...), backticks and \, so a filename such as a`id`.png or $(touch /tmp/pwn).png escaped the argument and executed as the Wekan server process. Any authenticated user who can upload an attachment could trigger it, on servers that have an external scanner configured. Unlike the sibling MIME-detection path (detectMimeFromFile, which already uses execFile with no shell) and unlike the AvatarBleed fix (which strips non-alphanumeric characters from the filename), this scanner path had zero sanitization
</details>
  • Fixed by POSIX single-quote-escaping the interpolated file path via a new shellQuote() helper (wrap in single quotes, escape embedded ' as '\''). Inside single quotes the shell interprets no metacharacters, so a malicious filename can no longer break out of the argument or inject commands, while the admin's arbitrary command line and the exact on-disk path are both preserved. CVSS:3.1 9.9 Critical (AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H).
  • Affected Wekan v9.06 and earlier through the current release; fixed at the upcoming WeKan release. Reported by DavidCarliez. Thanks to DavidCarliez and xet7!

Thanks to above GitHub users for their contributions and translators for their translations.

v9.74 2026-07-05 WeKan ® release

This release fixes the following CRITICAL SECURITY ISSUES of DnsBleed and ExcelBleed:

<details> <summary><a href="https://github.com/wekan/wekan/commit/ef845fe4a0adb82af436313310939cd48c0b1347">DnsBleed</a>.</summary>

** DnsBleed — SSRF filter bypass via DNS-resolving hostname in outgoing webhooks** (GHSA-66m2-4wfr-c45p, CWE-918). Incomplete-fix follow-up to WebhookBleed (GHSA-hc3x-hq3m-663q) and IntegrationBleed / RebindBleed. The synchronous URL validator on outgoing-webhook (board Integrations) URLs in models/integrations.js blocks private/loopback/link-local IPs by regex-matching the URL hostname string and never resolves DNS. A public hostname that resolves to a blocked address — e.g. 169-254-169-254.nip.io169.254.169.254 (cloud metadata), 127-0-0-1.nip.io127.0.0.1, or any attacker-controlled domain with an A/AAAA record pointing at an internal IP — passes that string blocklist. The report notes the underlying weakness: string matching is not an SSRF boundary because it can't see the resolved IP

</details>
  • The reported PoC was already blocked at delivery by the earlier RebindBleed fix: outgoing webhooks are sent through fetchSafe (server/lib/ssrfGuard.js), which resolves the hostname, validates the resolved IP, pins the connection to it and blocks redirects — so 169-254-169-254.nip.io is rejected before any request is made. The REST write paths (POST/PUT /api/boards/:boardId/integrations) were likewise already hardened in WebhookBleed to run the DNS-aware validateAttachmentUrl() at input time.
  • This release completes the fix and removes the drift risk the advisory points at. The delivery guard previously resolved only IPv4 A-records (dns.resolve4), leaving it blind to AAAA (an IPv6-only internal target was merely fail-closed, and legitimate IPv6 webhooks were unreachable) and keeping a second, less-complete private-range block-list that could drift out of sync with the input-time one. fetchSafe now resolves both address families via dns.lookup({ all: true }) — the same resolver call the input validator uses — and validates every resolved IP through the single shared isIpBlocked block-list in models/lib/attachmentUrlValidation.js. The three previously-separate block-lists (schema regex, delivery guard, input validator) are now one source of truth, and the schema-level url validator is documented in code as a non-authoritative first-line UI check only.
  • Affected Wekan v8.36 and later (input-side string validator); the delivery-time SSRF boundary has been in place since v8.35/v8.36 (IntegrationBleed) and v9.32 (WebhookBleed). Reported by 4n207. Thanks to 4n207 and xet7!
<details> <summary><a href="https://github.com/wekan/wekan/commit/7bbd1a3fad5d868fd01d79b5908913e215698e8e">ExcelBleed</a>.</summary>

** ExcelBleed — broken access control in the Excel-export REST route lets any authenticated user export any private board** (GHSA-mwq8-ccpm-r533, CWE-862 / CWE-639). Same un-awaited async-auth bug class as BFLABleed (48 REST endpoints, v9.22), CloneBleed (un-awaited allowIsBoardMemberByCard, v9.35) and TokenBleed. models/exportExcel.js called its access-control guard exporterExcel.canExport(user) without await. Because canExport is async, it returns a Promise (always truthy), so if (exporterExcel.canExport(user) || impersonateDone) was always true and exporterExcel.build(res) ran regardless of the guard's real result — any authenticated user could download the full contents of any board (card titles + descriptions, lists, swimlanes, members, metadata) via GET /api/boards/:boardId/exportExcel, including private boards they are not a member of. The JSON export route (/export) was correctly awaited and returned 403 for the same non-member

</details>
  • Fixed by awaiting the guard — if ((await exporterExcel.canExport(user)) || impersonateDone) — matching every other export route (models/export.js, exportPDF.js, exportExcelCard.js, import.js). The Excel-export route was the lone remaining un-awaited canExport call site.
  • Affected Wekan v9.57.0 (latest) and earlier; present at HEAD until this release. Reported by sec-reex (defensive research, responsible disclosure, read-only PoC). Thanks to sec-reex and xet7!

and adds the following updates:

and fixed the following bugs:

<details> <summary><a href="https://github.com/wekan/wekan/commit/f5a4ece29431fc5e3f72986f089a2089e90599ae">Playwright E2E: fixed three cross-process-contention flakes in the parallel run</a>.</summary>

** Playwright E2E: fixed three cross-process-contention flakes in the parallel run ** The Chromium / Firefox / WebKit browser jobs run as separate processes against ONE shared server + DB, so specs that used fixed identifiers or global cleanups raced each other:

</details>
  • 26-shared-templates.e2e.js seeded a fixed email domain ([email protected]) in all three browsers, hitting E11000 duplicate key on the unique emails.address index. Now uses a unique-per-run token for the org / team / domain and template titles.
  • 38-impersonation.e2e.js cleaned up with a global deleteMany({ reason: 'clickedImpersonate' }) that deleted another browser's in-flight audit record mid-poll. Scoped the cleanup to the test's own adminId.
  • 32-org-team-feature-toggles.e2e.js exercises setAllOrgsFeature / setAllTeamsFeature, which do a global updateMany({}) across every org / team, so two browsers clobbered each other's rows. These browser-agnostic server-method tests now run in a single project (Chromium); Firefox / WebKit skip them. Thanks to xet7.
  • Server-side Mocha suite: fixed a startup crash and the 44 latent failures it had been hiding.
    • imports/i18n/i18n.test.js crashed the whole run at load: chai 6.x plugins (sinon-chai, chai-as-promised) are ESM-only, so use(require('sinon-chai')) handed chai.use() a module namespace instead of the plugin function ("fn is not a function"). Now imports the default export.
    • That unmasked a second load crash — PositionHistory.helpers is not a function: the meteor test entry (server/lib/tests/index.js) never ran the .helpers / .attachSchema shim that server/main.js bootstraps, so the first model to call Collection.helpers({...}) threw. The shim is now imported first in the test entry.
    • With the suite finally running, 44 server tests failed because meteor test only loads what the specs import (not the app's /server/imports). The specs now import the files that register the methods / globals under test (cards.vote / cards.pokerVote, api.attachment.*, cloneBoard, getBackgroundImageURL, applyListWidth, updateListSort, moveChecklist, userPositionHistory.*, archiveBoard, sendSMTPTestEmail, and the Attachments global). Also fixed genuine test bugs: the cards.vote / cards.pokerVote specs were written synchronously against async methods; the header-login trust specs restored env vars with process.env.X = undefined (which stores the string "undefined" and shadowed the trusted-IP allowlist, making every trusted source read as untrusted); the DnsBleed decimal-loopback matcher assumed the integer host survived URL normalisation; and the dependencies-OpenAPI spec could not locate its source file from the built bundle. The two cards.archive / cards.move specs tested Meteor methods that do not exist (archive / move are Minimongo document helpers secured by Cards.allow / Cards.deny, already covered by the cards security tests) and were removed. Server-side Mocha is now 409 passing, 0 failing. Thanks to xet7.
  • Fix translations at Login and Register pages. Thanks to xet7.

Thanks to above GitHub users for their contributions and translators for their translations.