Wekan logo

Wekan

Open source kanban board application built with Meteor

Alternative to: trello


About Versions (218)

v10.98

2026-08-16

Binaries in these bundles

Each bundle carries a Node.js, a FerretDB and the MongoDB Database Tools. Which source has a given CPU varies from release to release - nodejs.org builds some architectures, unofficial-builds others, and the wekan/node-patches build the ones neither of them does - and not every source publishes a checksum. This is what went into this release, and which downloads were checked against a published SHA256.

BundleBinaryFromVersionCheckedSHA256
amd64FerretDBwekan/FerretDBv1.53.0verifiedeae1f0a8f73bfc97…
amd64Node.jsnodejs.orgv24.19.0verified14b342e71204f811…
arm64FerretDBwekan/FerretDBv1.53.0verifiedbdc50caee3ac2849…
arm64Node.jsnodejs.orgv24.19.0verified01443c1e1a29e531…
armhfFerretDBwekan/FerretDBv1.53.0verifiedf7f050207e656d65…
armhfNode.jswekan/node-patchesv24.19.0verifiedb55350f3071b765a…
armv6FerretDBwekan/FerretDBv1.53.0verified34f5f553dcd2bff3…
armv6Node.jswekan/node-patchesv24.19.0verified128ded0cda638c1f…
armv7FerretDBwekan/FerretDBv1.53.0verifiedf7f050207e656d65…
armv7Node.jswekan/node-patchesv24.19.0verified8dbe0a9aa8550ad5…
i386FerretDBwekan/FerretDBv1.53.0verified515b1189b4c7d58f…
i386Node.jswekan/node-patchesv24.19.0verified3b0b3bbfe27daf58…
mac-arm64FerretDBwekan/FerretDBv1.53.0verifiedcb14ffe93e285903…
mac-arm64Node.jsnodejs.orgv24.19.0verified3f1cf157479c1480…
mac-x64FerretDBwekan/FerretDBv1.53.0verifiedd97dfa9afa60aa05…
mac-x64Node.jsnodejs.orgv24.19.0verifiedd35e95230f46f6f0…
ppc64leFerretDBwekan/FerretDBv1.53.0verified481d36a80ec73673…
ppc64leNode.jsnodejs.orgv24.19.0verifiedc510c6ce12f07010…
riscv64FerretDBwekan/FerretDBv1.53.0verified4b43f86eb0f3fa0b…
riscv64Node.jsunofficial-builds.nodejs.orgv24.19.0verifiedcd1f14af28121480…
s390xFerretDBwekan/FerretDBv1.53.0verifiedfe74e03894b84897…
s390xNode.jsnodejs.orgv24.19.0verifieda4792e65962ffa0a…
win-arm64FerretDBwekan/FerretDBv1.53.0verifiedf1186c310a4dd144…
win-arm64Node.jsnodejs.orgv24.19.0verified8502f4a50b458d4c…
win64FerretDBwekan/FerretDBv1.53.0verifiedc6dad7bc8ad56f47…
win64Node.jsnodejs.orgv24.19.0verified57f71ab3652e797d…

A row saying no checksum published is not a failed check - it is a source that publishes nothing to check against. Those are the ones worth fixing at the source.

v10.98 2026-08-16 WeKan ® release

In short: one CRITICAL fix, and the whole of logging in reworked around it. WeKan’s brute-force lockout counted an attacker’s failed logins against the victim’s account rather than against the address they came from, so anyone who knew a username could lock its owner out from every address, repeatably — and a correct password was refused while the lock held, and counted as another failure. Usernames are public, so any account was a target and an administrator was as easy to lock out as anyone else. Reported by daniais as JamBleed. Two GitHub CodeQL alerts on one line of release tooling go with it. Around that fix: increasing delays after a wrong password, per source address rather than per account; a record of who logs in from where, both directions, which is what the new Problems / Offices pane groups into the offices an admin recognises — “London”, with the flag, rather than 100.100.100.100; and the reason it exists, which is that blocking an ADDRESS would take a whole office off WeKan at once, so WeKan blocks the account. Admin Panel / Problems now keeps ONE summary row per problem — a count, a window, and who tried it how often — instead of a document per event that grew with the attack it was recording, and gains an API pane answering the opposite question: not what went wrong, but who called which REST endpoint and how often. Then: v10.97 shipped a bundle that could not start, the third release in a row stopped by the same habit. Trimming what a bundle carries is measured by a graph of what the server can reach, and that graph read require() only. Meteor compiles an ESM import to module.link(), so every ESM import in every Meteor package was invisible to it: it called live code dead, and the bundle shipped without nodemailer-openpgp, which packages/email.js links on its first tick. The reachable count goes from 211 to 450 with the fix — the measurement was wrong rather than merely optimistic — and the category it justified is withdrawn: 61.3 MiB becomes 40.0 MiB. What changes beyond that one fault is the check: a release now has to start the bundle it built and see it reach its database before it may carry it. That fix had a cost nobody saw for a day: its package loaded on the CLIENT too, so require('crypto') reached the browser bundle and every page died on load with Cannot find module 'stream' — fixed here, and the whole class is now guarded. CHANGELOG.md is 2.5 MB lighter of history, keeping the current MONTH while older months and years move to old-CHANGELOG/. And Build WeKan release bundle is a menu entry now, building what a release would publish rather than a plain meteor build — so “does it start at all” no longer takes a release to answer. Below that: the Sandstorm pack that was throwing its own trim away, Admin Panel / People showing who is locked again, a location in an admin table opening a map through the card’s own chooser, the Problems route and template finally called what the menu calls them, and the documentation refiled to match the menu — including Directory-Structure.md, which had been describing the tree as it was in 2017.

PlatformBinaryFromVersionSHA256
amd64Node.jsnodejs.orgv24.19.014b342e71204f811bde6153be8e04b62aef63c236fef92b55f9c83154b409647
amd64FerretDBwekan/FerretDBv1.53.0eae1f0a8f73bfc979738bfff7284d40fd1bc55de2cc56514721fc155c3624f7d
arm64Node.jsnodejs.orgv24.19.001443c1e1a29e531ccad5a46fefa6df490d2189c49f7955904aecdbb0fe86fdc
arm64FerretDBwekan/FerretDBv1.53.0bdc50caee3ac28495b42d2130b94a042a9dd6d3a38f732cac02b648f36c891da
mac-arm64Node.jsnodejs.orgv24.19.03f1cf157479c1480352083105e13faf9d008ede98e7e157746b6df940d197b94
mac-arm64FerretDBwekan/FerretDBv1.53.0cb14ffe93e285903e5a8a9c1821687ddb5b8a979a11c584bf4af534b272c6d3e
mac-x64Node.jsnodejs.orgv24.19.0d35e95230f46f6f0751df497c56622c6735e05d5e1fb1630996a005b9d328fe4
mac-x64FerretDBwekan/FerretDBv1.53.0d97dfa9afa60aa05f25384327de82efe7b71d958ed24c1f66618284294a65cd3

This release fixes the following CRITICAL SECURITY ISSUE of JamBleed:

Logging in - who the brute-force lockout is protecting, and from whom.

JamBleed: the account lockout counted an attacker's failures against the victim. Thanks to daniais and xet7.

wekan-accounts-lockout kept ONE counter per user — services.accounts-lockout.failedAttempts — with no notion of where the attempts came from. Any unauthenticated attacker who knew a username could spend three wrong passwords and lock that account out from every address, repeatably, for as long as they cared to keep going. Usernames are public in normal WeKan use — board and card members are listed — so choosing a target was trivial, and an administrator was as easy to lock out as anyone else. Reproduced on v10.91:

  attacker, address A : Incorrect / Incorrect / Too many attempts
  victim,   address B : (correct password) Too many attempts

Affected from v10.59, and not before, for a reason worth keeping in view. The flat counter is much older, but until the LockoutBleed fix (GHSA-2g94-9x3m-hv37) the hooks gated on English error strings that Meteor’s ambiguousErrorMessages had already rewritten, so the counter never moved and no account ever locked. Making the lockout WORK is what made this reachable — a fix that turns on a mechanism inherits whatever that mechanism gets wrong.

Two faults, and the second is the one that hurts:

  • the counter was global, so an attacker’s failures were charged to the victim’s account rather than to the attacker’s address;
  • a correct password was refused while the lock held, and counted as a further failure on the way. The old code allowed an attempt only when there was no error AND no lock, so the owner typing the right password fell through to the same throw as the attacker.

Both are fixed in the decision itself, now a pure module. The counter is per (user, source address): the address comes from X-Forwarded-For under HTTP_FORWARDED_COUNT, the same rule server/lib/loginAttemptThrottle.js uses so a lockout and a throttle cannot disagree about who somebody is, and only the position hops from the right is read, so a forged header cannot pick its own bucket. The key is a SHA-256 prefix — an IPv4 address is all dots and cannot be a Mongo field name, and a locked account should not carry a list of the addresses that attacked it.

A correct password is allowed FIRST, before anything reads the lock, and clears the state behind it — including the pre-fix flat fields, so an account left locked by the old counter is freed by its owner’s next correct login rather than by a wait with no visible end. Hammering during a lock no longer extends it either, or the denial of service returns inside the mechanism meant to stop it.

The three methods that wrote the flat counter are removed rather than left unreachable. A lockout firing is now recorded and shows in Admin Panel → Problems — on the lock only, not on every refused attempt during one, or an attacker could fill that page by holding down a key. tests/lockoutPerSourceAddress.test.cjs is 19 tests, driving the decision as arithmetic rather than through a server: the reported attack, the correct password during a lock, that the lock still fires and still expires, the forwarded-header rules, that malformed state reads as nothing yet rather than throwing — a lockout that threw on an unexpected document would lock everybody out of a database that had one — and that every construction of AccountsLockout passes the reporter, since there are two and a reload that dropped it would stop recording attempts while the guard kept working.

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

The release version is no longer built into a regular expression. Thanks to xet7.

Two alerts on one line of releases/changelog-open-next.mjs, and both were right:

  • #433, js/incomplete-sanitizationversion.replace(/\./g, '\\.') escapes dots and not backslashes, which is the classic half-escape: a version containing a backslash would have escaped the backslash and left the next character bare.
  • #432, js/regex-injection — the version is an argv value, so it reached new RegExp as a pattern.

Neither is exploitable with a version release-all.sh computed from the CHANGELOG, and the script is release tooling rather than anything a user reaches. But the fix worth making is the one that removes the question instead of answering it, which is CodeQL’s own first recommendation: design so that sanitization is not needed. The heading is found with startsWith on the exact text now, so there is no pattern to escape and nothing to inject into, and a guard fails if a RegExp is built there again.

Shipped in the same commit as the JamBleed fix above.

and adds the following new features:

Logging in - what happens between a wrong password and the next attempt.

Increasing delays after a wrong password, per source address. Thanks to xet7.

Checked before any of it was written, because a second mechanism beside an existing one is worse than no mechanism: the DDP login already has wekan-accounts-lockout, the REST login has server/lib/loginAttemptThrottle.js per address, and server/lib/loginTimingDefense.js equalises timing so a missing user cannot be told from a wrong password. Nothing anywhere did increasing delays. So this extends the lockout decision rather than adding a rival to it.

The lockout on its own is a STEP FUNCTION: two failures cost nothing, the third costs sixty seconds. A guesser spends the free attempts of every window and waits, and somebody who mistyped their password gets no sign they are one attempt from being locked out. A delay that GROWS - 1s, 2s, 4s, 8s, capped - costs a guesser far more than it costs a person, and it degrades instead of slamming shut: the account is never unavailable, only slower to try again.

It is per (user, source address), like the counter it sits beside and for the same reason - an attacker must not be able to slow down the account’s owner - and a correct password is still allowed immediately, delay or no delay. Somebody who did not have to guess has proved they are not who this is for. An attempt refused as too early is not counted, either: letting it count would let an attacker lock an address out FASTER by trying faster.

Admin Panel / Problems - what the page records, and what it shows.

One summary row per problem, with who tried it and how often. Thanks to xet7.

A guard on a path an attacker controls fires as fast as they can send. One document per event meant the database grew WITH the attack, the Problems page became a scroll of near-identical lines, and the one event that mattered was buried under ten thousand that did not. The admin’s question is never “list every attempt” - it is what is happening, how much, since when, and who.

So each problem is ONE row that accumulates: a count, the firstAt … at window it covers, and the actors, each with a count of their own — username1 25, 100.100.100.100 30. The actor list is capped with an overflow count, so an attacker rotating addresses cannot turn the summary back into the log it replaced.

Existing per-event rows are folded into their summary on read, so an instance upgrading does not lose what it recorded, and does not keep paying for it.

Who logs in from where, with each account as its initials or avatar. Thanks to xet7.

Checked first, and reused rather than rewritten: the REST throttle and the lockout each already resolve a client address, models/users.js has getInitials() and profile.avatarUrl, the board sidebar and cards show members through +userAvatar / +userAvatarInitials, cardDetails.js builds “open in map” links for a dozen providers, and the Admin Panel tables already had an edit-user handler and a userId column. Only reading a location from CDN headers is new.

One successful login writes a tally in BOTH directions - which addresses this account uses, and which accounts use this address - because the second is what says an address is an office, a VPN or a carrier’s NAT rather than one person. Both are capped with an overflow count.

The location comes from a header something in front of WeKan already set (Cloudflare, Fastly, CloudFront, Vercel, Google Cloud, or a hand-configured proxy). WeKan geolocates nothing itself: no database to ship, no lookup of a user’s address against a third party. And because anything a client can send it can forge, a location is display only - a name beside an address and a map link, never a decision. Nothing blocks, allows or rate-limits on the strength of one.

Problems / Offices groups those logins into the places they come from. Thanks to xet7.

A pane at /admin/problems/office, through the shared table page every other report here uses (Table.md) - same layout, same search, same paginator - so it needed no design of its own.

The columns are what an admin actually asks. Location: the country flag and the city, “London” rather than 100.100.100.100, so somebody recognises their own offices at a glance and the flag says WHICH London; empty when nothing resolved it. Address: the IPv4 or IPv6 it really is. People: each account as its initials, or its avatar where it has one, with its own login count beside it - the same way the board sidebar shows members, and clicking one opens the same edit-user popup as everywhere else. Logins and the window they fall in.

WHY IT EXISTS AT ALL: an address that many accounts log in from is an office, and anything that reacted to a security event by blocking the ADDRESS would take all of them off WeKan at once - the admin would see “one address blocked” rather than “eighty people locked out”. WeKan blocks the ACCOUNT that caused the event. This pane is what lets an admin see the shape of their own users, and what would make an address-level action visibly reckless if one were ever proposed.

Problems / API: who called which endpoint, and how often. Thanks to xet7.

Every other pane under Problems answers what went WRONG. This one answers what is being DONE: POST /api/boards called 34 times by alice last month, 812 times yesterday by nobody with an account. Without it the only record of REST API use is whatever the reverse proxy happens to keep, and most instances keep none. The table is username, endpoint, calls, the window they fall in and the addresses, sorted by calls - a usage report’s question is what is used most, where a problem report’s is what happened last.

The name is the route pattern, not the path. /api/boards/:boardId/lists is one endpoint; /api/boards/abc123/lists and ten thousand of its siblings are that one endpoint being used. Naming rows after paths would put a row per board in the collection - the one-row-per-event cost this page exists to remove - and a request that matched NO route is counted under a single (no route) name rather than under the path it invented, because a 404 sweep is an attacker walking a wordlist and a row per guess would let them fill the database.

One row per account and endpoint, which makes the account part of a row’s identity - the one deliberate exception to the rule every other stream follows. There the question is “what is happening” and the caller would multiply the rows; here “who called what” IS the report, and the cardinality is bounded by real accounts times real endpoints. The account is stored by ID, so a rename does not split its history in two.

Nothing per request. Ordinary API traffic is not rare the way a guard firing is rare, so calls are counted in memory and folded on a timer - a thousand requests become one write. Counting hooks the middleware chain rather than the routes, so a route cannot be added without being counted. The pane is the shared event-stream report with a different column list, not a second table page, and the api stream is deliberately not one of the “problem” streams: an instance serving its API would otherwise report thousands of new problems.

Also fixed while there: the summary rows have had ipv4 and ipv6 fields since the summaries were written, and not one of the four loggers ever filled them, so the two columns the design asked for could not have worked. The fold splits the address now, once, for every stream, and both reports use one shared pair of columns that falls back to classifying the stored ip - so rows written before today display correctly instead of showing two empty columns for all of history.

A location in an Admin Panel table opens a map, through the card's own chooser. Thanks to xet7.

Offices names the places accounts log in from - “London”, with the country’s flag - and a name is only useful if it leads somewhere. Clicking one now asks which map to open it at, from the same eleven providers a card’s location offers.

The same chooser, not a second one. The provider list was eleven options inside cardDetails.jade; it is one template both callers include now, with its helper and its styles, because two lists would be eleven places to add a provider to instead of one and they would disagree the first time only one was edited. What the two do NOT share is the rest: a card’s popup EDITS a location, and an office’s arrived in a CDN header - WeKan did not ask for it and cannot correct it.

A cell is a link only when the row HAS coordinates: a city name is not a position, and a map URL built from one would either search for the word or invent a place. The link follows the selection before it is saved, because choosing a provider and finding the link still pointing at the old one reads as broken.

The handler lives on the shared table page rather than on the report - which is also where “clicking a user opens the Edit user popup” went, from the three identical copies each report had written for itself.

and fixes the following bugs:

Logging in - and what the lockout was costing everybody else.

The lockout package is server-only: it was breaking every page in the browser. Thanks to xet7.

The JamBleed fix above hashes the source address of a login attempt with require('crypto'), and its package declared api.mainModule('accounts-lockout.js') with no architecture — which loads it into the CLIENT as well as the server. So the browser bundle pulled in crypto-browserify, which pulls in cipher-base, which does require('stream'), and the page died on load:

  Uncaught Error: Cannot find module 'stream'

before WeKan drew anything at all. The server started perfectly and answered HTTP 200 with a page that could not run.

It is server-only now, which it always should have been: nothing in client/ imports it, and shipping a brute-force lockout’s decision to the browser would hand an attacker the rules even if it cost nothing. A rebuilt client bundle is 758 KB smaller and contains neither crypto-browserify nor cipher-base.

Why nothing caught it. bundle-smoke-boot.sh starts the bundle and waits for it to reach its database, which proves the SERVER image loads — every package linked, every map read. This was the client, and no check looked there. Both crashes that check was written for were server-side, so it answered the question it was asked and the question next door went unasked.

tests/packagesLoadOnTheRightArch.test.cjs pins the class rather than the one package: it reads each package.js for the entry files NOT restricted to the server, follows their imports, and fails when any requires a Node builtin — and does the same for every file under client/. Verified to fail on the real fault. Meteor’s default being both architectures is what makes this silent: the code works, the tests pass, and the cost lands in a browser bundle nobody reads.

Bundles and images - what a build carries, and what it can start without.

The reachability graph must read Meteor's module.link, not only require(). Thanks to Heart1010 and xet7.

Reported as #6602: a Docker instance upgraded from v10.91 to v10.97 would not come up at all. The entry above shipped in a bundle that crash-looped:

  Error: Cannot find module ".../nodemailer-openpgp/lib/nodemailer-openpgp.js"
    at packages/email.js:347

packages/email.js does not require() that package. Meteor compiles an ESM import to its own linker call:

  module.link('nodemailer-openpgp',{openpgpEncrypt(v){openpgpEncrypt=v}},6);

and the scanner only ever looked for require(). So it missed every ESM import in every Meteor package — which is most of them — and reported live code as dead. The reachable count goes from 211 to 450 with the fix: the measurement was badly wrong, not marginally. Four forms count now: require(), module.link(), module.watch(require()) and module.dynamicImport().

The safety worked, which is the one good part. With the corrected graph the policy still named openpgp, the graph VETOED it, and the tool refused and said so rather than deleting it. That entry is removed outright now rather than left to be vetoed every run — the reason is what grants the permission, and this reason was never true.

And the check that was missing both times. v10.96 died on a source map deleted but not un-named; this died on a linked module. Both were reasoned about by reading the code, and reading the code is how both mistakes were made. So releases/bundle-smoke-boot.sh starts the bundle with a database address that cannot answer and requires it to get as far as trying to reach it — which proves the whole server image loaded, because the database is the first thing WeKan needs that the check does not provide. The amd64 build runs it after the trim and the prunes, and every other architecture’s bundle derives from that one.

It is verified against both real failures — a bundle with nodemailer-openpgp removed, and a manifest naming maps that are not there — and each fails with its own diagnosis, because the fix for each is a different one. A bundle that exits quietly or hangs is not a pass either: a smoke test whose failure mode is passing when it learned nothing is worth less than none.

Sandstorm packs the trimmed trees, instead of rebuilding them first. Thanks to xet7.

v10.98’s log shows the trim doing exactly what it was meant to, and then being thrown away:

  --- .meteor-spk/bundle: 856M total        (first pack, fails)
  bundle-trim: removed 5236 files, 355 MiB
  prune-unreachable-npm: removed 28 package(s), 49.9 MiB
  --- .meteor-spk/bundle: 424M total        (424M + 336M deps = 760M, under 1 GiB)
  Building Meteor app...                    <- pack rebuilds it
  App exceeds uncompressed size limit of 1 GiB

meteor-spk pack runs the Meteor build first, so the retry regenerated the bundle and packed the untrimmed one - 405 MiB of trimming discarded between the measurement and the pack. Sandstorm’s own spk pack only packs what is there, so the retry prefers it and falls back to meteor-spk when it is not installed.

A comment in the retry claimed pack REUSED the bundle. It did not, and the log above is what disproved it.

The CHANGELOG and its tooling - a file that grew faster than it was read.

Releases are frequent, so the CHANGELOG tooling stops assuming they are rare. Thanks to xet7.

The maintenance loop here is build.sh option 1 (git pull + git push) and releases/release-all.sh with no arguments, several times a day when a fault is being chased. A release landing in the middle of a piece of work is the normal case, not a special one — and release-all.sh renamed # Upcoming WeKan ® release to # v<NEW> … and left nothing behind, so the next entry written had nowhere correct to go. It landed above the closing Thanks to above GitHub users … line, which is now INSIDE the release just published.

v10.96 and v10.97 both ended up that way. The second was worse than misplaced: an entry already published was EDITED afterwards, so the notes described a smaller, tidier change than the one that shipped — and the one that shipped was the one that stopped the bundle starting.

releases/changelog-open-next.mjs opens the next section as soon as a release is named, carrying the **In short:** placeholder and the binaries table the format guard requires, so the file is valid the moment release-all.sh finishes. Re-running it is a no-op, since release-all.sh can be run again after a failure. The format guard now allows an Upcoming that is empty AND still carries the placeholder — and fails one that has entries and still carries it, because that is a summary nobody replaced.

tests/changelogEntriesBelongToTheirRelease.test.cjs is the check that the home was used: git knows which commits a release contains, so an entry linking a commit that is not an ancestor of its release is in the wrong section. Scoped to the newest three releases on purpose — over the whole file it flags 83 entries back to v2.99, from old release practices and history rewrites, and a guard reporting 83 things nobody will act on is a guard people learn to skip.

CLAUDE.md says all of this where the release instructions are, including the rule the second mistake broke: a released section is a record, not a draft.

CHANGELOG.md keeps the current year, and older years move to old-CHANGELOG/. Thanks to mimZD and xet7.

Reported as #6580: the file had reached 2.6 MB and 51,365 lines across 1,070 releases going back to 2015, which is slow to open and slower to read on the web.

The current year stays in CHANGELOG.md; every older year moves out whole to old-CHANGELOG/<year>.md, with a bullet in # Platforms linking each one. 798 releases move, 272 stay, and the file goes from 2.6 MB to 1.9 MB — 2026 is a big year on its own, because releases here are frequent.

Nothing is deleted and no entry is rewritten. An archived section reads exactly as it did before, for the same reason a released section is never edited in place: it is a record. That git blame is less useful on the split file is accepted rather than worked around — the history is still in git for anyone who wants it, through gitk, git-gui or git log --follow, and being small enough to open is worth more.

Each archive opens with a count of releases per month: a | 2025 | Releases | table over rows of 01, 02 and so on. How busy was 2019 is the first thing a year file is asked and the last thing 159 collapsed sections answer. Only months that had releases get a row: a fixed twelve rows would put ten zeroes in 2015’s table. The table is regenerated from each file’s own headings on every run, so it cannot drift from the sections beneath it, and tests/changelogArchive.test.cjs checks the two against each other.

releases/changelog-archive-years.mjs does it, and it is a January job: run it once when a year turns over. It is idempotent, so a stray run does nothing. Cutting by YEAR rather than by a count of releases is what makes a link into the archive stable — once a year is over, nothing in it moves again.

One thing the script had to learn: eleven years of headings do not agree on their own wording. Of 1,070, 539 say Wekan release, 524 say WeKan ® release, and the rest are one-offs — Sandstorm-only Wekan release, Wekan relase, and one that explains it was NOT released. Matching the strict modern form found only half of them and silently absorbed the others into the section above, so the version and the DATE are what is matched and whatever follows is left alone. Verified by counting headings before and after: 1,070 both times, none lost and none duplicated.

And then the current MONTH, because a year of these releases is still 1.9 MB. Thanks to mimZD and xet7.

Moving whole years out left 1.9 MB, which was still too large, because releases here are FREQUENT: 2026 alone is 272 releases over eight months, and July was 80 on its own. A year is not a small enough unit when a year is that busy.

So the cut is by month. CHANGELOG.md holds the current month plus the Platforms, TODO Later and Upcoming sections; earlier months of the current year go to old-CHANGELOG/<year>/<MM>.md; years that are over stay one file each, because at 30 to 107 KB they are already small and splitting them further would trade a size problem for a “which of thirty files is it in” problem.

2.5 MB becomes 822 KB. Each archive opens with a table of how many releases it holds, per month, so the file says what is in it before a reader scrolls.

Admin Panel / People - who is locked out, and who can undo it.

People shows who is locked again, and why. Thanks to xet7.

Moving the lockout counter to one per (user, source address) - the JamBleed fix above - broke three readers still looking at the flat field it replaced: the People table’s lock icon, its unlock click handler, and the lockedUsers methods behind them. Every account would have shown as unlocked, and no admin could have unlocked one.

That is the “and nowhere else” half of the rule this release adds to CLAUDE.md, missed on the very next fix. models/lib/accountLockout.js is now the one place that knows the shape - locked or not, since when, how many addresses and how many failures between them - so the client helper, the click handler and the server methods cannot drift apart again, and the negative test fails if anything reads the flat field.

The People row says which ADDRESSES are locked and until when, rather than a bare padlock, because “locked” now means something narrower than it used to and an admin should not have to guess how much narrower.

and has the following developer-facing changes:

Admin Panel / Problems - how a pane knows it is the open one.

One active pane, instead of eleven booleans saying the same thing. Thanks to xet7.

Every pane on the Problems page had a ReactiveVar of its own - showSummary, showSecurity, showIntegrity and eight more - on top of activeReport, which already held the id of the open pane. Each one could only ever mean “activeReport equals my id”, so they were forty-four lines restating one value, kept in step by hand: opening a pane reset all eleven and set one.

That made a pane four wiring points instead of one, and missing any of them failed SILENTLY. Filesystem integrity got three of the four - menu entry, setter and template branch - and no helper, and in Blaze an undefined helper is not an error but a falsy value, so the pane drew a blank page while Summary went on counting the problems it could not show. The Offices pane added in this release had the same hole somewhere else: it set no loading.set(false), so opening it would have spun for ever.

The template asks else if isPane 'report-integrity' now, against one helper. Panes that fetch through a method rather than a subscription are a list, and that list is also what fixes Offices. Adding a pane is a menu entry, a branch, and one line saying how it loads.

The guards moved with it. tests/adminPaneHelpers.test.cjs, written when the integrity pane was blank, pins both halves of the new mechanism: the helper must exist, and every id the template branches on must be an id the menu sets - a typo either way is dead template or a blank pane, and neither says anything at runtime. tests/problemsMenuOrder.test.cjs now checks EVERY pane rather than three named ones: each menu entry must be rendered, and must either load itself or have a report config, so a pane that spins for ever cannot ship again. A negative test fails if a per-pane ReactiveVar comes back.

The route and the template say what the menu says. Thanks to xet7.

The pane is called Problems in the menu, its address is /admin/problems, and docs/Features/Admin-Panel/Problems is where it is documented. Two things still called it Reports, from before it was renamed: the route NAME was admin-reports, where its three siblings are the bare page key setting, people and attachments - so the one route whose name did not match its own address - and the template and its three files were adminReports. Both are problems / adminProblems now, across 41 files.

What did NOT change is legacyBase: '/admin-reports'. That is an address people have in bookmarks, and it still redirects.

Security fixes - what one is required to come with.

A test, a negative test, and the attempt visible in Problems. Thanks to xet7.

Two rules added to CLAUDE.md, both learned from fixes in this repository rather than proposed in the abstract.

A test AND a negative test, written so the fault cannot exist ANYWHERE in the codebase rather than only where it was reported. A test that pins one call site leaves the same mistake free to live in the other five, and that is how most of these arrive: SignupBleed’s guard read an option nothing sets and the same shape sat in a second endpoint; the source-map trim was safe on the client and fatal on the server. So the test proves the fix does what it claims - driving the decision itself, as arithmetic, where it can - and the negative test proves the fault is GONE rather than moved, by searching the tree for the shape of it.

And the attempt is visible in Admin Panel / Problems, whenever the fix is one that DENIES an operation. A vulnerability that is fixed silently tells an admin nothing about being attacked through it, and “nobody is trying” and “somebody tries every four seconds” are not the same instance to run. Recorded as a summary, never per event.

The local build - what build.sh produces, and what a release produces.

build.sh and build.bat option 2 build the RELEASE bundle, minus the .zip. Thanks to xet7.

“Build WeKan” ran meteor build .build --directory and stopped there, and that bundle is not the one a release publishes. A release adds the server’s npm modules, three prunes, the sockjs / legacy-client / source-map trim, a verified Node.js, FerretDB, the eight MongoDB Database Tools and a launcher — and three releases running broke in exactly that difference: v10.96 on a source map the trim deleted and left named, v10.97 on a package the prune’s graph could not see, v10.98 on a Sandstorm pack that rebuilt the bundle and threw the trim away. None of it was reproducible locally, because locally there was only meteor build, so “does the bundle a release would publish start at all” took a release, a workflow run and a download.

releases/build-release-bundle.sh runs the workflow’s OWN steps, in its order, with its arguments, for whichever platform it is run on. Not a second implementation of the release — the same scripts, so a fix to one is a fix to both. It makes no zip, no checksum and no provenance row, because those describe a published artifact and this one is a directory to start:

  cd .build/bundle && ./start-wekan.sh

Verified by running it on Linux arm64: an 850M bundle, trim −349 MiB, prune −40.0 MiB, the boot check passed, Node.js v24.19.0 verified against nodejs.org’s SHASUMS256.txt, FerretDB and the eight tools fetched per-arch, 686M with all of it embedded — then started, with FerretDB on SQLite and WeKan answering HTTP 200 fifteen seconds later.

The Setup menu offers it as Build WeKan release bundle, beside Build WeKan development bundle — the plain meteor build that entry used to be, kept because the two answer different questions at very different cost, and the development one now says what it is NOT when it finishes.

Downloads are cached under .tools/bundle-binaries/, and a cache HIT re-checks the published SHA256 rather than trusting a file for having been there before. The test path is deliberately unchanged: it runs the bundle under its own node and mongod, so a hundred megabytes of binaries it will not use is the wrong trade. tests/releaseBundleMatchesWorkflow.test.cjs FINDS the release scripts the workflow runs rather than listing them, so a step added there and not here fails the suite instead of quietly putting the difference back.

and improves the documentation:

docs/ - where a page lives, and how a reader finds it.

Implemented designs move to docs/Features, filed the way the menu is. Thanks to xet7.

A design that describes what WeKan already DOES belongs with the feature it describes, not in the folder for things being proposed. Fifteen docs move - Accessibility, Original-Positions, the thirteen Page/* designs that exist, and the Admin Panel Problems design.

What stays in docs/Design is what the folder is for: the principles (Design-Principles, Monkey-Proof-Software), the comparisons, the roadmap, and the proposals not yet built.

Directory-Structure.md describes the tree WeKan has now. Thanks to TNick and xet7.

It described the tree at commit e2f768c and had gone on describing it for years. Fourteen links pointed at nothing - five files that had moved, seven that no longer exist anywhere - and every one of the fourteen was written as a full https://github.com/wekan/wekan/tree/main/… URL rather than a relative path, which is exactly why none was noticed: a broken relative link is visibly broken in an editor and on GitHub, while an absolute one looks like a link and 404s only for the reader who follows it. All 144 relative links were fine.

And two thirds of the repository was not mentioned at allimports/, packages/, releases/, tests/, docs/, migrations/, server/lib, server/methods, server/routes, models/lib, client/features. The page walked through four directories out of twenty and did not say so, which left a reader unable to tell “not here” from “does not exist”. It opens with a table of the whole tree now, and gains the sections those directories should have had.

tests/docsLinksResolve.test.cjs checks both link forms against the tree, and fails when the page stops mentioning a top-level source directory - the silence being the failure that lasted longest.

Admin Panel docs are filed the way the Admin Panel menu is. Thanks to xet7.

The menu is the structure a reader already has in their head, so the docs match it: one directory per tab, one page per pane, and a README in each listing the panes IN MENU ORDER with the URL slug beside them - so somebody with /admin/problems/integrity in the address bar can search for integrity and find the page.

Settings (7 panes) and People (9) already had a page each and were already in order; they gain the slug column. Problems (17) and Attachments (10) listed almost none of theirs, so their READMEs now index every pane.

WHAT IS NOT DONE is now visible instead of invisible: 15 of the 17 Problems panes and all 10 Attachments panes have no page of their own. They are dashes in the table, and tests/adminPanelDocsMatchMenu.test.cjs counts the dashes against the sentence that states how many there are - so the gap cannot grow quietly, and a pane added to the menu and not to the docs fails the suite.

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