Wekan
Open source kanban board application built with Meteor
Alternative to: trello
v10.98
2026-08-16Binaries 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.
| Bundle | Binary | From | Version | Checked | SHA256 |
|---|---|---|---|---|---|
| amd64 | FerretDB | wekan/FerretDB | v1.53.0 | verified | eae1f0a8f73bfc97… |
| amd64 | Node.js | nodejs.org | v24.19.0 | verified | 14b342e71204f811… |
| arm64 | FerretDB | wekan/FerretDB | v1.53.0 | verified | bdc50caee3ac2849… |
| arm64 | Node.js | nodejs.org | v24.19.0 | verified | 01443c1e1a29e531… |
| armhf | FerretDB | wekan/FerretDB | v1.53.0 | verified | f7f050207e656d65… |
| armhf | Node.js | wekan/node-patches | v24.19.0 | verified | b55350f3071b765a… |
| armv6 | FerretDB | wekan/FerretDB | v1.53.0 | verified | 34f5f553dcd2bff3… |
| armv6 | Node.js | wekan/node-patches | v24.19.0 | verified | 128ded0cda638c1f… |
| armv7 | FerretDB | wekan/FerretDB | v1.53.0 | verified | f7f050207e656d65… |
| armv7 | Node.js | wekan/node-patches | v24.19.0 | verified | 8dbe0a9aa8550ad5… |
| i386 | FerretDB | wekan/FerretDB | v1.53.0 | verified | 515b1189b4c7d58f… |
| i386 | Node.js | wekan/node-patches | v24.19.0 | verified | 3b0b3bbfe27daf58… |
| mac-arm64 | FerretDB | wekan/FerretDB | v1.53.0 | verified | cb14ffe93e285903… |
| mac-arm64 | Node.js | nodejs.org | v24.19.0 | verified | 3f1cf157479c1480… |
| mac-x64 | FerretDB | wekan/FerretDB | v1.53.0 | verified | d97dfa9afa60aa05… |
| mac-x64 | Node.js | nodejs.org | v24.19.0 | verified | d35e95230f46f6f0… |
| ppc64le | FerretDB | wekan/FerretDB | v1.53.0 | verified | 481d36a80ec73673… |
| ppc64le | Node.js | nodejs.org | v24.19.0 | verified | c510c6ce12f07010… |
| riscv64 | FerretDB | wekan/FerretDB | v1.53.0 | verified | 4b43f86eb0f3fa0b… |
| riscv64 | Node.js | unofficial-builds.nodejs.org | v24.19.0 | verified | cd1f14af28121480… |
| s390x | FerretDB | wekan/FerretDB | v1.53.0 | verified | fe74e03894b84897… |
| s390x | Node.js | nodejs.org | v24.19.0 | verified | a4792e65962ffa0a… |
| win-arm64 | FerretDB | wekan/FerretDB | v1.53.0 | verified | f1186c310a4dd144… |
| win-arm64 | Node.js | nodejs.org | v24.19.0 | verified | 8502f4a50b458d4c… |
| win64 | FerretDB | wekan/FerretDB | v1.53.0 | verified | c6dad7bc8ad56f47… |
| win64 | Node.js | nodejs.org | v24.19.0 | verified | 57f71ab3652e797d… |
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.
| Platform | Binary | From | Version | SHA256 |
|---|---|---|---|---|
| amd64 | Node.js | nodejs.org | v24.19.0 | 14b342e71204f811bde6153be8e04b62aef63c236fef92b55f9c83154b409647 |
| amd64 | FerretDB | wekan/FerretDB | v1.53.0 | eae1f0a8f73bfc979738bfff7284d40fd1bc55de2cc56514721fc155c3624f7d |
| arm64 | Node.js | nodejs.org | v24.19.0 | 01443c1e1a29e531ccad5a46fefa6df490d2189c49f7955904aecdbb0fe86fdc |
| arm64 | FerretDB | wekan/FerretDB | v1.53.0 | bdc50caee3ac28495b42d2130b94a042a9dd6d3a38f732cac02b648f36c891da |
| mac-arm64 | Node.js | nodejs.org | v24.19.0 | 3f1cf157479c1480352083105e13faf9d008ede98e7e157746b6df940d197b94 |
| mac-arm64 | FerretDB | wekan/FerretDB | v1.53.0 | cb14ffe93e285903e5a8a9c1821687ddb5b8a979a11c584bf4af534b272c6d3e |
| mac-x64 | Node.js | nodejs.org | v24.19.0 | d35e95230f46f6f0751df497c56622c6735e05d5e1fb1630996a005b9d328fe4 |
| mac-x64 | FerretDB | wekan/FerretDB | v1.53.0 | d97dfa9afa60aa05f25384327de82efe7b71d958ed24c1f66618284294a65cd3 |
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-sanitization—version.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 reachednew RegExpas 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 all — imports/,
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.