Compare commits

..
14 Commits
Author SHA1 Message Date
github-actions[bot] 77239ee100 Bump versions for v13.06 2026-11-10 22:50:32 +00:00
Lauri Ojansivu 3913fbd507 Prepare v13.06 release 2026-11-11 00:49:29 +02:00
Lauri Ojansivu c45359a759 CHANGELOG: CodeQL alert #601 and its siblings in the tests; the oldest October release archived to stay under GitHub's limit.
Thanks to GitHub CodeQL and xet7 !
2026-11-11 00:38:03 +02:00
Lauri Ojansivu d482856e03 Tests: no path is quoted into a bash -c script, CodeQL alert #601 and its siblings.
CodeQL flagged js/shell-command-injection-from-environment (CWE-78/88) in
tests/npmRetry.test.cjs: its bash -c script set
node_bin=${JSON.stringify(prog)} and ran bash ${JSON.stringify(script)},
pasting a path under the temporary directory and one under the checkout
into the script text. JSON's quotes are not the shell's: a path with $(,
a backtick or $ would change the command. The paths now go in through
the environment ("$NODE_BIN", "$NPM_RETRY_SCRIPT"), so the script is a
constant - and NODE_BIN is not node_bin, so the test still shows the
unbound variable it exists to show.

The guard written for alerts #549 and #550 (tests/testShellPathsFromEnv
.test.cjs) only knew `source "${path}"`. It now refuses any value quoted
into a -c script - "${...}" or ${JSON.stringify(...)} - across the tests,
while a script built from the test's own snippets stays allowed; with
the fixes taken away it names every offender. It found the same fault in
two more places, fixed as arguments ("$1", "$2"):
tests/releaseDownloads.test.cjs, which quoted fetch.sh and a temporary
file into its umask tests, and tests/macosSandbox.test.cjs, which quoted
the home and temporary paths into its sandbox probe.

The finding was in tests, not in WeKan, so there is no attempt to show
in Admin Panel -> Problems.

Thanks to GitHub CodeQL and xet7 !
2026-11-11 00:37:39 +02:00
Lauri Ojansivu 72ee10a3ed CHANGELOG: the merged REST overtime and card member fixes, their follow-ups, and cleared card dates.
Thanks to AhmedLukman and xet7 !
2026-11-11 00:08:21 +02:00
Lauri Ojansivu 32590ee7ab Card members and assignees: adding one whose account is gone no longer throws.
#6889 guarded the removal halves of the cardMembers and cardAssignees
hooks, which read user.username after a lookup that finds nobody for a
deleted account. The joining halves had the same read: the REST route
checks the user before adding, but a client $addToSet, a copied or
imported card, or a rule can still add such an id, and the hook threw.
Both now record the join with the id and no username, as a removal does.

tests/cardMemberRemoval.test.cjs runs the real hooks: adding a member
and an assignee whose account is gone records the join without
throwing; and, tree-wide, no `const x = user.field;` follows a
ReactiveCache.getUser() lookup without a guard.

Thanks to AhmedLukman and xet7 !
2026-11-11 00:07:53 +02:00
Lauri Ojansivu 569756b66c REST card overtime: an isOverTime stored before #6888 becomes isOvertime.
#6888 makes the REST card PUT write its isOverTime parameter to the
schema's isOvertime field. Every overtime set through the API before it
- since b895e61237 - was stored under isOverTime, which the UI and every
reader ignore: those cards still show no overtime.

A schema upgrade step, card-overtime-field, carries that over on the
next start: isOverTime becomes isOvertime where the card has none, the
value the UI already shows wins where it has both, and the stray field
is removed. Two updateMany batches and an $unset, version-gated and
idempotent like the steps beside it.

tests/schemaUpgradeSteps.test.cjs pins the fold, the precedence and a
second pass finding nothing; as negatives, no work on cards without the
field, and - tree-wide - no server code writing isOverTime into a card.

Thanks to AhmedLukman and xet7 !
2026-11-11 00:07:26 +02:00
Lauri Ojansivu 50334816ca Card dates: clearing one no longer goes through the timing hook as a set.
The Cards.before.update timing hook read modifier.$set[field] for any
update whose first field was a date, but unsetDue/unsetStart/unsetEnd/
unsetReceived send only a $unset. Without a $set the hook threw a
TypeError; with one beside the $unset (a stamped modifiedAt) it wrote an
a-dueAt with timeValue: undefined, which counted as a due date change
(#6081), matched the "date set or changed" rule triggers (#2474), and
showed in the activity feed as an empty date.

server/lib/timingChangeActivity.js now decides it: a date set records
timeValue, a date cleared records timeOldValue alone - the same record a
rule's removeDate writes, and the one models/lib/boardTimeline.js rewinds
- and clearing a date the card does not have records nothing. The rules
skip a cleared date before matching triggers, as server/triggersDef.js
already said they should, and the feed shows only the date it had.

tests/cardTimingActivity.test.cjs runs the real hook: a set, a $unset
alone and a $unset beside a $set (both used to fail), and clearing
nothing; and, tree-wide, no modifier.$set[...] read that assumes the $set
is there. The two guards that pinned the old inline list and its
modifier.$set[action] read now pin the helper instead.

Thanks to xet7 !
2026-11-11 00:06:31 +02:00
Lauri Ojansivu 9821478329 Merge pull request #6889 from AhmedLukman/codex/card-member-removal
Fix removal of card members and assignees with missing user accounts
2026-11-10 23:49:48 +02:00
Lauri Ojansivu ad5c35e9f3 Merge pull request #6888 from AhmedLukman/codex/rest-card-overtime
Fix REST card overtime persistence
2026-11-10 23:49:23 +02:00
Ahmed Lukman 416c69dfe2 Revert "Use optional chaining for removed assignment users"
This reverts commit 0acc9955d0.

Thanks to AhmedLukman and xet7 !
2026-11-10 16:59:39 +03:00
Ahmed Lukman 0acc9955d0 Use optional chaining for removed assignment users
Read the optional username safely when removing a card member or assignee whose account is missing.

Thanks to AhmedLukman and xet7 !
2026-11-10 16:52:39 +03:00
Ahmed Lukman 5e9d1cb630 Fix removal of missing card members and assignees
Allow card assignment removal when the target user no longer exists. Keep normal membership activity and no-op behavior intact, with unit and REST/browser regression coverage.

Thanks to AhmedLukman and xet7 !
2026-11-10 16:27:40 +03:00
Ahmed Lukman f74ef4c40d Fix REST card overtime persistence
Keep accepting the isOverTime request parameter and write its validated value to the isOvertime schema field used by card readers and the UI.

Correct the handler regression and cover JSON/form edits, persistence, API reads, reactive display, reloads and rejected requests.
2026-11-06 19:04:30 +03:00
31 changed files with 1190 additions and 499 deletions

No files matched your search

+103 -432
View File
@@ -124,6 +124,109 @@ its reason.
</details>
# v13.06 2026-11-11 WeKan ® release
**In short:** **CodeQL** alert #601 in a test is fixed, with two more tests
that had the same fault. A card's **overtime** set through the REST API is
saved where WeKan reads it, and cards that lost it before are repaired at
startup. Card **members and assignees** whose account was deleted can be removed
and added without an error, and clearing a card **date** no longer passes as a
date set to the rules, the due date count and the activity feed.
This release fixes the following SECURITY ISSUES found by GitHub CodeQL code scanning:
<details>
<summary><a href="https://github.com/wekan/wekan/commit/d482856e03129d13c6e171a1503db60d81ab5166">Alert #601: no path is quoted into a bash -c script in the tests</a>. Thanks to GitHub CodeQL and xet7.</summary>
CodeQL flagged a shell command built from environment values (CWE-78, CWE-88)
in `tests/npmRetry.test.cjs`, whose `bash -c` script pasted a temporary path
and a checkout path into its text with `JSON.stringify` - JSON's quotes are not
the shell's, so a path with `$(`, a backtick or `$` would change the command.
The paths now go in through the environment. The guard written for alerts #549
and #550 only knew `source "${path}"`; it now refuses any value quoted into a
`-c` script across the tests, and found the same fault in
`tests/releaseDownloads.test.cjs` and `tests/macosSandbox.test.cjs`, fixed as
arguments. The finding was in tests, not in WeKan, so there is no attempt to
show in Admin Panel → Problems.
</details>
and fixes the following bugs:
**Card overtime** - what the REST API stores, and what it stored before.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/f74ef4c40d85f9f6b91b8f4ecfc7372f67d32100">A REST card update saves isOverTime as the isOvertime WeKan reads</a>. Thanks to AhmedLukman.</summary>
A PUT with `{"isOverTime": true}` answered 200 and changed nothing anyone saw:
the handler wrote `isOverTime`, while the schema and the UI use `isOvertime`.
The request keeps its spelling and the validated value is stored in
`isOvertime`. Browser tests cover JSON and form updates, the stored value, a
REST read, the open card, a reload, and refused requests, and the unit test
pins the stored field name.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/569756b66c0fc3e3c41aba93fa8311994c6fa754">An isOverTime stored before that fix becomes isOvertime at startup</a>. Thanks to AhmedLukman and xet7.</summary>
Every overtime set through the API since `b895e61237` was stored under the
field nothing reads. The `card-overtime-field` schema upgrade step folds it
into `isOvertime` where the card has none, keeps the value the UI already shows
where it has both, and removes the stray field - version-gated and idempotent
like the steps beside it. Tests pin the fold, the precedence and a second pass
finding nothing; as negatives, no work on cards without the field, and no
server code writing `isOverTime` into a card.
</details>
**Card members and assignees** - people whose account is gone.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/5e9d1cb6305a4a36f9cb890c7f5babb66ab4f43c">A member or assignee whose account is gone can be removed</a>. Thanks to AhmedLukman.</summary>
Removing a card member or assignee whose user account no longer exists read
`user.username` of nobody and answered HTTP 500, so the stale id could never
be removed. Both removal hooks now look the name up safely and still record
the removed id. Unit tests run the real hooks, and browser tests cover stale,
missing, repeated and normal removals with their authentication and scope
checks.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/32590ee7ab89b41ca6f764a58ec0f43d56d48ab8">Adding a member or assignee whose account is gone no longer throws either</a>. Thanks to AhmedLukman and xet7.</summary>
The joining halves of the same two hooks had the same read. The REST route
checks the user before adding, but a client update, a copied or imported card,
or a rule can still add such an id. Both now record the join with the id and
no username. Tests run the real hooks, and a tree-wide test finds no
`const x = user.field;` straight after a user lookup without a guard.
</details>
**Card dates** - what clearing a date records.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/50334816caf227237124b007c53a467c8f6919f5">Clearing a card date no longer passes through the timing hook as a date set</a>. Thanks to xet7.</summary>
The hook that records `a-dueAt` and the other date activities read
`modifier.$set[field]` for every date update, but clearing a date sends only a
`$unset`: without a `$set` the hook threw, and with one beside it an `a-dueAt`
without a value was written - counted as a due date change (#6081), matched by
the "date set or changed" rule triggers (#2474) and shown as an empty date in
the activity feed. Now a date set records its value, a date cleared records
only the date it had - as a rule's removeDate does, and as the board timeline
rewinds - and clearing a date the card does not have records nothing. The rules
skip a cleared date, and the feed shows only the date it had. Tests run the real
hook with a set, a `$unset` alone and a `$unset` beside a `$set`, and a
tree-wide test finds no `modifier.$set[...]` read that assumes the `$set` is
there.
</details>
Thanks to above GitHub users for their contributions and translators for their translations.
# v13.05 2026-11-10 WeKan ® release
**In short:** WeKan is updated to **Meteor 3.6**. The **Android** bundles
@@ -8356,435 +8459,3 @@ workflow that is code is imported disabled. Pinned by
</details>
Thanks to above GitHub users for their contributions and translators for their translations.
# v12.41 2026-10-14 WeKan ® release
**In short:** **custom board roles**, **per-list permissions** and boards
**every signed-in user may edit**; board **keys** (`DEV-7`) and **shared
labels**; WeKan serves **HTTPS** itself; **Bun single files** and **musl
bundles and AppImages** for Alpine; on **Sandstorm**, webhooks and list sync
leave through the Powerbox and the REST API takes a grain API key. Seventeen
locale files that held **another
language**, and six full of machine-fill prefixes, are rewritten in their
own, and fourteen **GitHub CodeQL** alerts are fixed.
This release fixes the following SECURITY ISSUES found by GitHub CodeQL code scanning:
<details>
<summary><a href="https://github.com/wekan/wekan/commit/2352f1f44f">HTML tags are removed until none are left, and no input is spliced into a pattern</a>. Thanks to GitHub CodeQL and xet7.</summary>
- **#570, #571:** an HTML-only email became card text with one pass of each
removal, so a script element spliced together by removing an inner one
survived, and its contents reached the card. Both removals now run until
nothing changes.
- **#574:** the single file's settings writer put the setting name into a
regular expression. A setting must be an environment variable name and is
compared literally, and a value must be one line. The kept-versions reader
now reads file names literally too.
- **#572, #573, #575-#580:** tests escaped only some characters, or matched
example hostnames with unescaped dots; they compare literally or escape
fully now.
`tests/tagStrippingFixedPoint.test.cjs` now also catches the `<[^>]+>`
spelling it missed, which also found the search-index text of Office
documents: never rendered, so recorded as such. `tests/emailToCard.test.cjs`
fails on the old code. Nothing here is an attempt the server refuses at run
time, so no Admin Panel / Problems key is added.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/167ccf9e2d">An email's HTML becomes text in one pass, with no pattern to take apart</a>. Thanks to GitHub CodeQL and xet7.</summary>
CodeQL alerts #581 and #582 were raised on the fix above: email to card
still removed `<script>` elements and tags with regular expressions, looped
to a fixed point through a helper that CodeQL does not follow. `htmlToText`
in `models/lib/emailToCard.js` now walks the characters once. A tag is
dropped whole, a script, style or head element goes with what is in it (also
unclosed), an unfinished tag is not kept as text, and a `<` that does not
start a tag stays text - except right before another `<`, where it would
read as a tag once the next one is gone. `tests/emailToCard.test.cjs` covers
those cases, and `tests/tagStrippingFixedPoint.test.cjs` no longer accepts a
fixed-point helper and now also catches a whole element removed by pattern,
in every file. Nearly every HTML email has a `<style>`, so a removed element
is ordinary use, not an attempt: no Admin Panel / Problems key is added.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/73fcfb8545">No HTTP response sends the request back, tests included</a>. Thanks to GitHub CodeQL and xet7.</summary>
CodeQL alert #583: the HTTPS test's server answered with the request's own
URL in the body, the shape of reflected cross-site scripting. It now names
the page by comparison. `tests/noReflectedRequest.test.cjs` keeps the shape
out of every file. Test code only, so no Admin Panel / Problems key is added.
</details>
and adds the following new features:
**Board roles** - who may do what on a board, and where a membership comes from.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/50bb989eb8">Custom board roles, built in the Admin Panel from what a member may do</a>. Thanks to xet7.</summary>
In Admin Panel / People / Roles a site admin builds named roles from five
capabilities: sees all cards, comments, edits, moves cards, invites. A board
admin then gives one to a member in the same popup as the built-in roles.
Board admin stays a built-in role. Every check asks the capability table,
which answers from the custom role. The member's built-in flags carry the
strongest built-in role that grants nothing more, so code reading a flag
never grants more, and a deleted role leaves exactly that. This holds for
every combination, which `tests/customBoardRoles.test.cjs` checks.
Invitations, REST reads, imports and member mapping follow it. The comment
form, reactions and chat now ask the comment capability, so a No comments
member is no longer offered reactions the server refuses. The new strings are
translated in all 234 languages, and the Roles Status table gains a Move cards
column. See [Board roles](docs/Features/Members/Roles.md#custom-roles).
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/50bb989eb8">A membership that came through a team or organization leaves with it</a>. Thanks to mfnalex and xet7.</summary>
Joining a team already added its boards; leaving it now takes away the
memberships it gave, and only those. Each records where it came from
(`grantedBy`), so a direct membership is never touched and a board admin is
kept. A board admin's own add or removal is final. This covers user edits,
LDAP, OIDC, the board sidebar, and organizations that propagate their
members. `tests/groupBoardMembership.test.cjs` covers the decisions, and a
runtime test joins, leaves and rejoins a team against a real database.
</details>
**Outgoing requests** - webhooks and downloads.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/78957e14cb">Outgoing webhooks go through an HTTP proxy, with the SSRF checks kept</a>. Thanks to gbarre and xet7.</summary>
`HTTP_PROXY`, `HTTPS_PROXY` and `NO_PROXY` work for every outgoing request
(on the snap: `http-proxy`, `https-proxy`, `no-proxy`). The destination is
still resolved and checked first, and the proxy is given that checked
address, never the name: an `http://` request goes in absolute form, an
`https://` one through a `CONNECT` tunnel, with TLS still checked against the
real name. A destination the guard refuses never reaches the proxy.
`tests/outboundProxy.test.cjs` runs a real proxy on loopback. See
[Proxy](docs/Features/Webhooks/Proxy.md).
</details>
**Who may edit where** - boards open to every signed-in user, and lists with
rules of their own.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/7595727917">A board can let every signed-in user edit it, when the Admin Panel allows it</a>. Thanks to Meeques and xet7.</summary>
Admin Panel / Settings / Visibility gains "Allow boards that every signed-in
user may edit", off by default. With it on, a board admin of a public board
or one open to every signed-in user can turn it on for that board in the
visibility popup. A signed-in non-member may then comment, edit and move
cards like a Normal member - never change the board - and their first edit
makes them a Normal member, so every change stays attributed. A member keeps
their own role, a member a board admin removed stays removed, and rules,
webhooks, inbound email and list sync never act with a non-member's rights.
Board-wide settings such as list width and sticky headers stay members-only.
`tests/instanceEditing.test.cjs` and a runtime test cover it. See
[Visibility](docs/Features/Admin-Panel/Settings/Visibility.md).
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/7595727917">Lists can allow or refuse editing and commenting per role</a>. Thanks to Meeques and xet7.</summary>
The list menu gains List permissions, for board admins: for each role -
built-in, custom, and on an open board everyone signed in - Edit and Comment
are as on the board, allowed, or not allowed in this list. The issue's
example works: a read-only board with one list where everybody may add cards
and comment. A board admin is never restricted, and a removed member stays
removed. The server applies it to every card, checklist, attachment and
comment write and to moves into the list, and the REST API honours a list's
refusal on every per-list card route. Only the board admin method writes the
setting; a direct write is refused and shown in Admin Panel / Problems.
`tests/listPermissions.test.cjs` and a runtime test cover it. See
[List permissions](docs/Features/Board/List-Permissions.md).
</details>
**Boards and cards** - keys, card numbers and labels shared between boards.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/7595727917">Every board has a key, and card numbers show as KEY-7</a>. Thanks to justinr1234 and xet7.</summary>
A key is 2-10 capital letters and digits, unique in the instance, suggested
from the board's title (Development Team: DT) and set for every existing
board at startup. A board admin can change it in the rename popup. Cards show
`DEV-7` instead of `#7`, and searching for `DEV-7` finds that card. The
per-board numbers themselves are unchanged, so nothing is renumbered.
`tests/boardKey.test.cjs` and a runtime test cover it. See
[Board key](docs/Features/Board/Boards/Board-Key.md).
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/7595727917">Shared labels for every board, an organization's boards or a team's</a>. Thanks to tiFabGrinch, ejlkemper and xet7.</summary>
Admin Panel / Labels keeps labels shared by every board, or by the boards of
one organization or team. A board offers them in its labels popup beside its
own, and using one copies it to the board linked to the shared label, so
renaming it or changing its colour in the Admin Panel changes it everywhere
it is used. Deleting a shared label leaves it on those boards as their own.
Only labels a member's own organizations and teams share are sent to them.
`tests/sharedLabels.test.cjs` and a runtime test cover it. See
[Shared labels](docs/Features/Board/Shared-Labels.md).
</details>
**Servers** - running WeKan without a proxy in front of it.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/1bf7a62cab">WeKan serves HTTPS itself, when a certificate is configured</a>. Thanks to 68040 and xet7.</summary>
With `HTTPS_PORT`, `HTTPS_CERT_FILE` and `HTTPS_KEY_FILE` set (a path or
the PEM itself), WeKan also listens for HTTPS, on the same app, and
`HTTPS_REDIRECT=true` sends plain-HTTP requests there. Without them nothing
changes, and a proxy such as Caddy stays the recommendation for production.
The snap has `https-port`, `https-cert-file`, `https-key-file` and
`https-redirect`. `tests/httpsServer.test.cjs` makes a real TLS connection.
See [HTTPS without a proxy](docs/Platforms/Webserver/HTTPS-without-proxy.md).
</details>
**Platforms** - more ways to run WeKan.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/b1e58094f4">Bun single files: one executable per Bun platform, musl included</a>. Thanks to xet7.</summary>
Each Bun bundle also comes as one executable:
`wekan-<version>-bun-<target>-single-file` for Linux x64 and arm64 (glibc
and musl, so Alpine too), macOS and Windows. It is the published bundle ZIP,
unchanged, inside that bundle's own Bun, built on the platform itself, so the
runtime is the one the bundle was tested with. The first start unpacks the
bundle once into a cache directory, without a second Bun, and every start
runs it with the executable itself as Bun. Like the Bun bundle it needs
`MONGO_URL` and `ROOT_URL`. Its ZIP reader refuses any entry or link that
would land outside the cache directory. Before it is published each file is
checked: it names itself, refuses to start without a database, unpacks once
and loads every native addon of the bundle. `tests/bunSingleFile.test.cjs`
covers the reader and its refusals. See
[Single file](docs/Platforms/Single-File.md#bun-single-files).
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/a1d2994c1c">musl bundles and AppImages for Alpine and the other musl systems</a>. Thanks to xet7.</summary>
Every release also has `wekan-<version>-linux-x64-musl.zip` and
`-linux-arm64-musl.zip`, with FerretDB, and the AppImages
`WeKan-<version>-x86_64-musl` and `-aarch64-musl`. Each is the release's
Linux bundle rebuilt inside Alpine on a runner of its CPU: the musl Node.js of
the same version, checked against its SHA256, and every native addon
compiled against musl. Before it is published, each addon's architecture is
checked, sharp must load, and the bundle must start and answer HTTP 200; the
AppImages are started inside Alpine too. `tests/muslBundles.test.cjs`
covers the build and the workflows. See [Alpine](docs/Platforms/FOSS/OS/Alpine.md).
</details>
**Sandstorm** - reaching out of a grain, and into it.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/2cc0b59a51">Outgoing webhooks leave a grain through the Powerbox</a>. Thanks to xet7.</summary>
A grain has no network but the bridge proxy, so a webhook was never sent.
Each saved webhook now offers "Allow this grain to reach this address", which
opens the Powerbox for exactly that address; once approved, the webhook goes
through the bridge with that grant. The grant belongs to the destination, not
to a person, and is approved as the address's parent path so the bridge
delivers to the exact address. Off Sandstorm nothing changes: every sender
still goes through the SSRF guard. `tests/sandstormWebhookEgress.test.cjs`
runs it against a fake bridge proxy. See
[Powerbox](docs/Platforms/FOSS/Container/Sandstorm/Powerbox-External-APIs.md#outgoing-webhooks).
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/2cc0b59a51">The REST API works with a Sandstorm API key</a>. Thanks to xet7.</summary>
A Sandstorm API key keeps the Authorization header for itself, so WeKan saw
no user. In a grain the bridge says who is calling, and WeKan now acts as
that user - only in a grain, only for an account that exists and is enabled,
and a key without the participate permission reads only. Off Sandstorm the
`X-Sandstorm-*` headers are never believed.
`tests/sandstormApiIdentity.test.cjs`
covers both sides.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/d53b4da799">List sync from GitHub, GitLab, Gitea and Forgejo works in a grain</a>. Thanks to xet7.</summary>
A board member who may write saves a list's sync source and allows the grain
to reach that tracker's API in the Powerbox; the periodic sync then goes
through the bridge with that grant, which belongs to the tracker, so no one
needs to be signed in. GitLab's token is sent as `private_token`, because the
bridge drops `PRIVATE-TOKEN`, so private GitLab projects sync too; GitHub,
Gitea and Forgejo sync public repositories, whose token headers cannot pass
the bridge. Jira signs in only with an `Authorization` header, which the
bridge replaces, so it is refused in a grain with that reason. Off Sandstorm
nothing changes. `tests/sandstormSyncEgress.test.cjs` runs it against a fake
bridge proxy. See [Powerbox](docs/Platforms/FOSS/Container/Sandstorm/Powerbox-External-APIs.md#list-sync).
</details>
and closes the following already-done feature request:
- [Each member chooses how often email notifications come: never, periodically or instantly](https://github.com/wekan/wekan/commit/3f88fb39a5).
Thanks to saurabharch and xet7.
and fixes the following bugs:
**Lists and swimlanes** - what goes with a list, and where it is shown.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/0a3797dbf6">Archiving a list archives its cards, and restoring it brings them back</a>. Thanks to Raymond-Voogt and xet7.</summary>
Only template lists archived their cards, so archiving any other list left
its cards visible in the Lists, Table and Calendar views. The server now
archives the cards of every list with it, whichever way it is archived - the
list menu, the Archive sidebar, the REST API - and restoring the list
restores exactly those cards, the rule #2292 gave swimlanes. A card that was
archived on its own stays archived. Cards archived with the list get the
list's own archive time, so a browser clock that differs from the server's
cannot keep them from coming back. `tests/archiveListCards.test.cjs` and a
runtime test cover it.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/ebf2a019a6">Lists missing their swimlane counts only lists that lost it, and Restore never hides cards</a>. Thanks to Raymond-Voogt and xet7.</summary>
Admin Panel / Problems counted every board-wide list whose history recorded
the swimlane it was created in, so lists made since the automatic
un-binding stopped (#6515, in v10.31) were counted too, and Restore would
have bound them there, hiding the cards they hold in other swimlanes. Now a
list created after v10.31 is never offered, and a list with any card in
another swimlane is never restored. A list that really lost its binding is
still restored. `tests/listSwimlaneRestore.test.cjs` and a runtime test
cover both rules.
</details>
**Board roles** - helpers that read a role.
- [Five role helpers that answered the same for everybody: Import never offered, rule controls for every member, the last admin not protected, OIDC default-board roles](https://github.com/wekan/wekan/commit/548834c4ee).
Thanks to xet7.
and fixes the following build bug:
- [The OpenBSD bundles are built on OpenBSD 7.8, where Node.js 26 now builds](https://github.com/wekan/wekan/commit/209e3b0c47).
Thanks to xet7.
and updates the following documentation:
- [Outgoing webhooks to Node-RED, and why an internal address is refused](https://github.com/wekan/wekan/commit/a0de507143).
Thanks to yeurplc and xet7.
- [Uberspace 7: install, the update script, and email on one environment line](https://github.com/wekan/wekan/commit/a0de507143).
Thanks to jeyca and xet7.
and updates the following translations:
**Languages that held another language** - each locale file now says what its
tag says.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/f3c92273ae">Hong Kong Chinese in Traditional characters, and Flemish in Dutch</a>. Thanks to xet7.</summary>
Hong Kong Chinese had 90 values in Simplified Chinese; they are now
Traditional. Flemish held 158 values in about a dozen other languages, Venda
and Vietnamese among them, and they are now Flemish, with its 225 values
still in English filled too.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/ba6d0014f7">Locale files that held another language are rewritten in their own</a>. Thanks to xet7.</summary>
Romanian held Italian, French and Spanish; Aromanian, Ladin, Romansh and
Venetian held Italian; Galician held Portuguese, Acehnese Malay, Venda Zulu,
Slovak Czech and Polish, Upper Sorbian Czech, Volapük Esperanto and Bislama
Tok Pisin, and Uzbek in Arabic script was half Latin script. Each is now in
its own language. Machine-fill prefixes and notes ("Faka-Tonga:", "Kay
willaymi:", "Wolayttatto:", "WeKan:" and others) are gone from Tongan,
Quechua, Wolaytta, Upper Sorbian, Aragonese, Friulian and Luganda. Chinese
no longer calls a board a wooden plank, and List actions no longer reads as
"list the actions" in seven languages. Values already reviewed - pinned by a
test, corrected by an audit, or quoted by another string - are kept.
`tests/wrongLanguageLocales.test.cjs` keeps the old languages and prefixes
out and checks every placeholder against English. Native speakers are asked
to review these languages.
</details>
**Igbo** - notification, integration, workflow and recovery settings.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/238354f6a54a234c9db1cf88a9436d15087c6ba0">Translate Igbo notification delivery and integration settings</a>. Thanks to xet7.</summary>
Translate 42 notification and integration strings, preserving grouping,
schedules, quiet hours, inherited webhook settings, identity omission and
literal link-rule syntax. Direct technical wording needs fluent review.
Both translation and guarded-correction suites pass with 30,943 corrections.
All 21 human-preference checks pass; 38,430 existing Chinese values are
unchanged.
One notification browser case passes syntax and discovery; execution is pending.
All-language translation and semantic verification remain unfinished.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/daac3ead0dceb451bdb69970cc5b19678d17ce6d">Translate Igbo workflow and permission controls</a>. Thanks to xet7.</summary>
Translate 19 workflow, directory-sync and permission messages, preserving
Wrike status behavior, environment identifiers and subtask completion choices.
Technical prose is lower confidence and needs fluent review.
Both translation and guarded-correction suites pass with 30,962 corrections.
All 21 human-preference checks pass; 38,430 existing Chinese values are
unchanged.
Three browser cases pass syntax and discovery; execution remains pending.
Igbo has 69 detected placeholders left. All-language semantic review is
unfinished.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/5c5391fb98d3619308aa2ba3328f47db870e5655">Translate Igbo stopped list sync recovery messages</a>. Thanks to xet7.</summary>
Translate 18 recovery messages, preserving applied changes, discarded pending
writes, replay restrictions and the oldest-50 display limit. Recovery prose
needs fluent review.
Both translation and guarded-correction suites pass with 30,980 corrections.
All 21 human-preference checks pass; 38,430 existing Chinese values are
unchanged.
One browser case passes syntax and discovery; execution remains pending.
All-language translation and semantic verification remain unfinished.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/8c25ce78b94d65d3a7fd4540bdb065443a6c6dbd">Translate Igbo interrupted import recovery messages</a>. Thanks to xet7.</summary>
Translate 21 messages, preserving permanent deletion, retained partial boards,
source-file limitations, untouched foreign boards and recovery limits.
Direct recovery prose needs fluent review.
Both translation and guarded-correction suites pass with 31,001 corrections.
All 21 human-preference checks pass; existing Chinese values are unchanged
(38,430 checked). One browser case passes syntax and discovery; execution is
pending. All-language translation and semantic verification remain unfinished.
</details>
Thanks to above GitHub users for their contributions and translators for their translations.
+11 -10
View File
@@ -5,6 +5,17 @@ each one changed and the project status are in
Written by `node releases/changelog-short.mjs` from CHANGELOG-big.md:
make changes there.
# v13.06 2026-11-11 WeKan ® release
**CodeQL** alert #601 in a test is fixed, with two more tests
that had the same fault. A card's **overtime** set through the REST API is
saved where WeKan reads it, and cards that lost it before are repaired at
startup. Card **members and assignees** whose account was deleted can be removed
and added without an error, and clearing a card **date** no longer passes as a
date set to the rules, the due date count and the activity feed.
[All changes in v13.06](CHANGELOG-big.md#v1306-2026-11-11-wekan--release)
# v13.05 2026-11-10 WeKan ® release
WeKan is updated to **Meteor 3.6**. The **Android** bundles
@@ -37,13 +48,3 @@ archive is briefly unreachable: the build waits for it and retries once.
to them, through every card method.
[All changes in v13.02](CHANGELOG-big.md#v1302-2026-11-09-wekan--release)
# v13.01 2026-11-08 WeKan ® release
Translations: the strings still in English are written in **33
more languages**, **Sardinian** is Sardinian instead of Italian and **Moroccan
Arabic** is Darija instead of Persian. Dependabot's updates of **mailparser**,
**isomorphic-git**, **pdfjs-dist** and the S3 libraries are checked and kept,
with the S3 client brought to the version the upload library needs.
[All changes in v13.01](CHANGELOG-big.md#v1301-2026-11-08-wekan--release)
+1 -1
View File
@@ -38,7 +38,7 @@ FROM --platform=$BUILDPLATFORM debian:trixie AS bundle
ARG TARGETARCH
ARG TARGETVARIANT
ARG VERSION=13.05
ARG VERSION=13.06
ARG DEBIAN_FRONTEND=noninteractive
COPY --chmod=755 releases/check-telemetry.py /tmp/check-telemetry.py
+1 -1
View File
@@ -1,5 +1,5 @@
appId: wekan-public/apps/77b94f60-dec9-0136-304e-16ff53095928
appVersion: "v13.05.0"
appVersion: "v13.06.0"
files:
userUploads:
- README.md
+4 -1
View File
@@ -195,7 +195,10 @@ template(name="activity")
if(currentData.timeKey)
| {{_ activity.activityType }}
= ' '
i(title=currentData.timeValue).activity-meta {{ displayDate currentData.timeValue 'LLL' }}
//- A date cleared has no new value (server/lib/timingChangeActivity.js):
only the one it had, below, rather than an empty date.
if currentData.timeValue
i(title=currentData.timeValue).activity-meta {{ displayDate currentData.timeValue 'LLL' }}
if (currentData.timeOldValue)
= ' '
| {{{activityMessage "previous_as" }}}
@@ -10,7 +10,7 @@ This is without container (without Docker or Snap).
Right click and download files 1-4:
1. [wekan-13.05-win64.zip](https://github.com/wekan/wekan/releases/download/v13.05/wekan-13.05-win64.zip) or newest available at https://github.com/wekan/wekan/releases/
1. [wekan-13.06-win64.zip](https://github.com/wekan/wekan/releases/download/v13.06/wekan-13.06-win64.zip) or newest available at https://github.com/wekan/wekan/releases/
2. [node.exe](https://nodejs.org/dist/latest-v26.x/win-x64/node.exe)
@@ -22,7 +22,7 @@ Right click and download files 1-4:
6. Double click `mongodb-windows-x86_64-7.0.34-signed.msi` . In installer, uncheck downloading MongoDB compass.
7. Unzip `wekan-13.05-win64.zip` , inside it is directory `bundle`, to it copy other files:
7. Unzip `wekan-13.06-win64.zip` , inside it is directory `bundle`, to it copy other files:
```
bundle (directory)
+4 -4
View File
@@ -3912,7 +3912,7 @@ async function cardMembers(userId, doc, fieldNames, modifier) {
if (modifier.$addToSet && modifier.$addToSet.members) {
memberId = modifier.$addToSet.members;
const user = await ReactiveCache.getUser(memberId);
const username = user.username;
const username = user && user.username;
if (!(doc.members || []).includes(memberId)) {
await Activities.insertAsync({
userId,
@@ -3931,7 +3931,7 @@ async function cardMembers(userId, doc, fieldNames, modifier) {
if (modifier.$pull && modifier.$pull.members) {
memberId = modifier.$pull.members;
const user = await ReactiveCache.getUser(memberId);
const username = user.username;
const username = user && user.username;
// Check that the former member is member of the card
if ((doc.members || []).includes(memberId)) {
await Activities.insertAsync({
@@ -3955,7 +3955,7 @@ async function cardAssignees(userId, doc, fieldNames, modifier) {
if (modifier.$addToSet && modifier.$addToSet.assignees) {
assigneeId = modifier.$addToSet.assignees;
const user = await ReactiveCache.getUser(assigneeId);
const username = user.username;
const username = user && user.username;
if (!(doc.assignees || []).includes(assigneeId)) {
await Activities.insertAsync({
userId,
@@ -3973,7 +3973,7 @@ async function cardAssignees(userId, doc, fieldNames, modifier) {
if (modifier.$pull && modifier.$pull.assignees) {
assigneeId = modifier.$pull.assignees;
const user = await ReactiveCache.getUser(assigneeId);
const username = user.username;
const username = user && user.username;
// Check that the former assignee is assignee of the card
if ((doc.assignees || []).includes(assigneeId)) {
await Activities.insertAsync({
+433 -1
View File
@@ -14,7 +14,439 @@ Releases per day:
| 11 | 6 |
| 12 | 5 |
| 13 | 1 |
| 14 | 1 |
| 14 | 2 |
# v12.41 2026-10-14 WeKan ® release
**In short:** **custom board roles**, **per-list permissions** and boards
**every signed-in user may edit**; board **keys** (`DEV-7`) and **shared
labels**; WeKan serves **HTTPS** itself; **Bun single files** and **musl
bundles and AppImages** for Alpine; on **Sandstorm**, webhooks and list sync
leave through the Powerbox and the REST API takes a grain API key. Seventeen
locale files that held **another
language**, and six full of machine-fill prefixes, are rewritten in their
own, and fourteen **GitHub CodeQL** alerts are fixed.
This release fixes the following SECURITY ISSUES found by GitHub CodeQL code scanning:
<details>
<summary><a href="https://github.com/wekan/wekan/commit/2352f1f44f">HTML tags are removed until none are left, and no input is spliced into a pattern</a>. Thanks to GitHub CodeQL and xet7.</summary>
- **#570, #571:** an HTML-only email became card text with one pass of each
removal, so a script element spliced together by removing an inner one
survived, and its contents reached the card. Both removals now run until
nothing changes.
- **#574:** the single file's settings writer put the setting name into a
regular expression. A setting must be an environment variable name and is
compared literally, and a value must be one line. The kept-versions reader
now reads file names literally too.
- **#572, #573, #575-#580:** tests escaped only some characters, or matched
example hostnames with unescaped dots; they compare literally or escape
fully now.
`tests/tagStrippingFixedPoint.test.cjs` now also catches the `<[^>]+>`
spelling it missed, which also found the search-index text of Office
documents: never rendered, so recorded as such. `tests/emailToCard.test.cjs`
fails on the old code. Nothing here is an attempt the server refuses at run
time, so no Admin Panel / Problems key is added.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/167ccf9e2d">An email's HTML becomes text in one pass, with no pattern to take apart</a>. Thanks to GitHub CodeQL and xet7.</summary>
CodeQL alerts #581 and #582 were raised on the fix above: email to card
still removed `<script>` elements and tags with regular expressions, looped
to a fixed point through a helper that CodeQL does not follow. `htmlToText`
in `models/lib/emailToCard.js` now walks the characters once. A tag is
dropped whole, a script, style or head element goes with what is in it (also
unclosed), an unfinished tag is not kept as text, and a `<` that does not
start a tag stays text - except right before another `<`, where it would
read as a tag once the next one is gone. `tests/emailToCard.test.cjs` covers
those cases, and `tests/tagStrippingFixedPoint.test.cjs` no longer accepts a
fixed-point helper and now also catches a whole element removed by pattern,
in every file. Nearly every HTML email has a `<style>`, so a removed element
is ordinary use, not an attempt: no Admin Panel / Problems key is added.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/73fcfb8545">No HTTP response sends the request back, tests included</a>. Thanks to GitHub CodeQL and xet7.</summary>
CodeQL alert #583: the HTTPS test's server answered with the request's own
URL in the body, the shape of reflected cross-site scripting. It now names
the page by comparison. `tests/noReflectedRequest.test.cjs` keeps the shape
out of every file. Test code only, so no Admin Panel / Problems key is added.
</details>
and adds the following new features:
**Board roles** - who may do what on a board, and where a membership comes from.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/50bb989eb8">Custom board roles, built in the Admin Panel from what a member may do</a>. Thanks to xet7.</summary>
In Admin Panel / People / Roles a site admin builds named roles from five
capabilities: sees all cards, comments, edits, moves cards, invites. A board
admin then gives one to a member in the same popup as the built-in roles.
Board admin stays a built-in role. Every check asks the capability table,
which answers from the custom role. The member's built-in flags carry the
strongest built-in role that grants nothing more, so code reading a flag
never grants more, and a deleted role leaves exactly that. This holds for
every combination, which `tests/customBoardRoles.test.cjs` checks.
Invitations, REST reads, imports and member mapping follow it. The comment
form, reactions and chat now ask the comment capability, so a No comments
member is no longer offered reactions the server refuses. The new strings are
translated in all 234 languages, and the Roles Status table gains a Move cards
column. See [Board roles](docs/Features/Members/Roles.md#custom-roles).
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/50bb989eb8">A membership that came through a team or organization leaves with it</a>. Thanks to mfnalex and xet7.</summary>
Joining a team already added its boards; leaving it now takes away the
memberships it gave, and only those. Each records where it came from
(`grantedBy`), so a direct membership is never touched and a board admin is
kept. A board admin's own add or removal is final. This covers user edits,
LDAP, OIDC, the board sidebar, and organizations that propagate their
members. `tests/groupBoardMembership.test.cjs` covers the decisions, and a
runtime test joins, leaves and rejoins a team against a real database.
</details>
**Outgoing requests** - webhooks and downloads.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/78957e14cb">Outgoing webhooks go through an HTTP proxy, with the SSRF checks kept</a>. Thanks to gbarre and xet7.</summary>
`HTTP_PROXY`, `HTTPS_PROXY` and `NO_PROXY` work for every outgoing request
(on the snap: `http-proxy`, `https-proxy`, `no-proxy`). The destination is
still resolved and checked first, and the proxy is given that checked
address, never the name: an `http://` request goes in absolute form, an
`https://` one through a `CONNECT` tunnel, with TLS still checked against the
real name. A destination the guard refuses never reaches the proxy.
`tests/outboundProxy.test.cjs` runs a real proxy on loopback. See
[Proxy](docs/Features/Webhooks/Proxy.md).
</details>
**Who may edit where** - boards open to every signed-in user, and lists with
rules of their own.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/7595727917">A board can let every signed-in user edit it, when the Admin Panel allows it</a>. Thanks to Meeques and xet7.</summary>
Admin Panel / Settings / Visibility gains "Allow boards that every signed-in
user may edit", off by default. With it on, a board admin of a public board
or one open to every signed-in user can turn it on for that board in the
visibility popup. A signed-in non-member may then comment, edit and move
cards like a Normal member - never change the board - and their first edit
makes them a Normal member, so every change stays attributed. A member keeps
their own role, a member a board admin removed stays removed, and rules,
webhooks, inbound email and list sync never act with a non-member's rights.
Board-wide settings such as list width and sticky headers stay members-only.
`tests/instanceEditing.test.cjs` and a runtime test cover it. See
[Visibility](docs/Features/Admin-Panel/Settings/Visibility.md).
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/7595727917">Lists can allow or refuse editing and commenting per role</a>. Thanks to Meeques and xet7.</summary>
The list menu gains List permissions, for board admins: for each role -
built-in, custom, and on an open board everyone signed in - Edit and Comment
are as on the board, allowed, or not allowed in this list. The issue's
example works: a read-only board with one list where everybody may add cards
and comment. A board admin is never restricted, and a removed member stays
removed. The server applies it to every card, checklist, attachment and
comment write and to moves into the list, and the REST API honours a list's
refusal on every per-list card route. Only the board admin method writes the
setting; a direct write is refused and shown in Admin Panel / Problems.
`tests/listPermissions.test.cjs` and a runtime test cover it. See
[List permissions](docs/Features/Board/List-Permissions.md).
</details>
**Boards and cards** - keys, card numbers and labels shared between boards.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/7595727917">Every board has a key, and card numbers show as KEY-7</a>. Thanks to justinr1234 and xet7.</summary>
A key is 2-10 capital letters and digits, unique in the instance, suggested
from the board's title (Development Team: DT) and set for every existing
board at startup. A board admin can change it in the rename popup. Cards show
`DEV-7` instead of `#7`, and searching for `DEV-7` finds that card. The
per-board numbers themselves are unchanged, so nothing is renumbered.
`tests/boardKey.test.cjs` and a runtime test cover it. See
[Board key](docs/Features/Board/Boards/Board-Key.md).
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/7595727917">Shared labels for every board, an organization's boards or a team's</a>. Thanks to tiFabGrinch, ejlkemper and xet7.</summary>
Admin Panel / Labels keeps labels shared by every board, or by the boards of
one organization or team. A board offers them in its labels popup beside its
own, and using one copies it to the board linked to the shared label, so
renaming it or changing its colour in the Admin Panel changes it everywhere
it is used. Deleting a shared label leaves it on those boards as their own.
Only labels a member's own organizations and teams share are sent to them.
`tests/sharedLabels.test.cjs` and a runtime test cover it. See
[Shared labels](docs/Features/Board/Shared-Labels.md).
</details>
**Servers** - running WeKan without a proxy in front of it.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/1bf7a62cab">WeKan serves HTTPS itself, when a certificate is configured</a>. Thanks to 68040 and xet7.</summary>
With `HTTPS_PORT`, `HTTPS_CERT_FILE` and `HTTPS_KEY_FILE` set (a path or
the PEM itself), WeKan also listens for HTTPS, on the same app, and
`HTTPS_REDIRECT=true` sends plain-HTTP requests there. Without them nothing
changes, and a proxy such as Caddy stays the recommendation for production.
The snap has `https-port`, `https-cert-file`, `https-key-file` and
`https-redirect`. `tests/httpsServer.test.cjs` makes a real TLS connection.
See [HTTPS without a proxy](docs/Platforms/Webserver/HTTPS-without-proxy.md).
</details>
**Platforms** - more ways to run WeKan.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/b1e58094f4">Bun single files: one executable per Bun platform, musl included</a>. Thanks to xet7.</summary>
Each Bun bundle also comes as one executable:
`wekan-<version>-bun-<target>-single-file` for Linux x64 and arm64 (glibc
and musl, so Alpine too), macOS and Windows. It is the published bundle ZIP,
unchanged, inside that bundle's own Bun, built on the platform itself, so the
runtime is the one the bundle was tested with. The first start unpacks the
bundle once into a cache directory, without a second Bun, and every start
runs it with the executable itself as Bun. Like the Bun bundle it needs
`MONGO_URL` and `ROOT_URL`. Its ZIP reader refuses any entry or link that
would land outside the cache directory. Before it is published each file is
checked: it names itself, refuses to start without a database, unpacks once
and loads every native addon of the bundle. `tests/bunSingleFile.test.cjs`
covers the reader and its refusals. See
[Single file](docs/Platforms/Single-File.md#bun-single-files).
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/a1d2994c1c">musl bundles and AppImages for Alpine and the other musl systems</a>. Thanks to xet7.</summary>
Every release also has `wekan-<version>-linux-x64-musl.zip` and
`-linux-arm64-musl.zip`, with FerretDB, and the AppImages
`WeKan-<version>-x86_64-musl` and `-aarch64-musl`. Each is the release's
Linux bundle rebuilt inside Alpine on a runner of its CPU: the musl Node.js of
the same version, checked against its SHA256, and every native addon
compiled against musl. Before it is published, each addon's architecture is
checked, sharp must load, and the bundle must start and answer HTTP 200; the
AppImages are started inside Alpine too. `tests/muslBundles.test.cjs`
covers the build and the workflows. See [Alpine](docs/Platforms/FOSS/OS/Alpine.md).
</details>
**Sandstorm** - reaching out of a grain, and into it.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/2cc0b59a51">Outgoing webhooks leave a grain through the Powerbox</a>. Thanks to xet7.</summary>
A grain has no network but the bridge proxy, so a webhook was never sent.
Each saved webhook now offers "Allow this grain to reach this address", which
opens the Powerbox for exactly that address; once approved, the webhook goes
through the bridge with that grant. The grant belongs to the destination, not
to a person, and is approved as the address's parent path so the bridge
delivers to the exact address. Off Sandstorm nothing changes: every sender
still goes through the SSRF guard. `tests/sandstormWebhookEgress.test.cjs`
runs it against a fake bridge proxy. See
[Powerbox](docs/Platforms/FOSS/Container/Sandstorm/Powerbox-External-APIs.md#outgoing-webhooks).
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/2cc0b59a51">The REST API works with a Sandstorm API key</a>. Thanks to xet7.</summary>
A Sandstorm API key keeps the Authorization header for itself, so WeKan saw
no user. In a grain the bridge says who is calling, and WeKan now acts as
that user - only in a grain, only for an account that exists and is enabled,
and a key without the participate permission reads only. Off Sandstorm the
`X-Sandstorm-*` headers are never believed.
`tests/sandstormApiIdentity.test.cjs`
covers both sides.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/d53b4da799">List sync from GitHub, GitLab, Gitea and Forgejo works in a grain</a>. Thanks to xet7.</summary>
A board member who may write saves a list's sync source and allows the grain
to reach that tracker's API in the Powerbox; the periodic sync then goes
through the bridge with that grant, which belongs to the tracker, so no one
needs to be signed in. GitLab's token is sent as `private_token`, because the
bridge drops `PRIVATE-TOKEN`, so private GitLab projects sync too; GitHub,
Gitea and Forgejo sync public repositories, whose token headers cannot pass
the bridge. Jira signs in only with an `Authorization` header, which the
bridge replaces, so it is refused in a grain with that reason. Off Sandstorm
nothing changes. `tests/sandstormSyncEgress.test.cjs` runs it against a fake
bridge proxy. See [Powerbox](docs/Platforms/FOSS/Container/Sandstorm/Powerbox-External-APIs.md#list-sync).
</details>
and closes the following already-done feature request:
- [Each member chooses how often email notifications come: never, periodically or instantly](https://github.com/wekan/wekan/commit/3f88fb39a5).
Thanks to saurabharch and xet7.
and fixes the following bugs:
**Lists and swimlanes** - what goes with a list, and where it is shown.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/0a3797dbf6">Archiving a list archives its cards, and restoring it brings them back</a>. Thanks to Raymond-Voogt and xet7.</summary>
Only template lists archived their cards, so archiving any other list left
its cards visible in the Lists, Table and Calendar views. The server now
archives the cards of every list with it, whichever way it is archived - the
list menu, the Archive sidebar, the REST API - and restoring the list
restores exactly those cards, the rule #2292 gave swimlanes. A card that was
archived on its own stays archived. Cards archived with the list get the
list's own archive time, so a browser clock that differs from the server's
cannot keep them from coming back. `tests/archiveListCards.test.cjs` and a
runtime test cover it.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/ebf2a019a6">Lists missing their swimlane counts only lists that lost it, and Restore never hides cards</a>. Thanks to Raymond-Voogt and xet7.</summary>
Admin Panel / Problems counted every board-wide list whose history recorded
the swimlane it was created in, so lists made since the automatic
un-binding stopped (#6515, in v10.31) were counted too, and Restore would
have bound them there, hiding the cards they hold in other swimlanes. Now a
list created after v10.31 is never offered, and a list with any card in
another swimlane is never restored. A list that really lost its binding is
still restored. `tests/listSwimlaneRestore.test.cjs` and a runtime test
cover both rules.
</details>
**Board roles** - helpers that read a role.
- [Five role helpers that answered the same for everybody: Import never offered, rule controls for every member, the last admin not protected, OIDC default-board roles](https://github.com/wekan/wekan/commit/548834c4ee).
Thanks to xet7.
and fixes the following build bug:
- [The OpenBSD bundles are built on OpenBSD 7.8, where Node.js 26 now builds](https://github.com/wekan/wekan/commit/209e3b0c47).
Thanks to xet7.
and updates the following documentation:
- [Outgoing webhooks to Node-RED, and why an internal address is refused](https://github.com/wekan/wekan/commit/a0de507143).
Thanks to yeurplc and xet7.
- [Uberspace 7: install, the update script, and email on one environment line](https://github.com/wekan/wekan/commit/a0de507143).
Thanks to jeyca and xet7.
and updates the following translations:
**Languages that held another language** - each locale file now says what its
tag says.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/f3c92273ae">Hong Kong Chinese in Traditional characters, and Flemish in Dutch</a>. Thanks to xet7.</summary>
Hong Kong Chinese had 90 values in Simplified Chinese; they are now
Traditional. Flemish held 158 values in about a dozen other languages, Venda
and Vietnamese among them, and they are now Flemish, with its 225 values
still in English filled too.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/ba6d0014f7">Locale files that held another language are rewritten in their own</a>. Thanks to xet7.</summary>
Romanian held Italian, French and Spanish; Aromanian, Ladin, Romansh and
Venetian held Italian; Galician held Portuguese, Acehnese Malay, Venda Zulu,
Slovak Czech and Polish, Upper Sorbian Czech, Volapük Esperanto and Bislama
Tok Pisin, and Uzbek in Arabic script was half Latin script. Each is now in
its own language. Machine-fill prefixes and notes ("Faka-Tonga:", "Kay
willaymi:", "Wolayttatto:", "WeKan:" and others) are gone from Tongan,
Quechua, Wolaytta, Upper Sorbian, Aragonese, Friulian and Luganda. Chinese
no longer calls a board a wooden plank, and List actions no longer reads as
"list the actions" in seven languages. Values already reviewed - pinned by a
test, corrected by an audit, or quoted by another string - are kept.
`tests/wrongLanguageLocales.test.cjs` keeps the old languages and prefixes
out and checks every placeholder against English. Native speakers are asked
to review these languages.
</details>
**Igbo** - notification, integration, workflow and recovery settings.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/238354f6a54a234c9db1cf88a9436d15087c6ba0">Translate Igbo notification delivery and integration settings</a>. Thanks to xet7.</summary>
Translate 42 notification and integration strings, preserving grouping,
schedules, quiet hours, inherited webhook settings, identity omission and
literal link-rule syntax. Direct technical wording needs fluent review.
Both translation and guarded-correction suites pass with 30,943 corrections.
All 21 human-preference checks pass; 38,430 existing Chinese values are
unchanged.
One notification browser case passes syntax and discovery; execution is pending.
All-language translation and semantic verification remain unfinished.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/daac3ead0dceb451bdb69970cc5b19678d17ce6d">Translate Igbo workflow and permission controls</a>. Thanks to xet7.</summary>
Translate 19 workflow, directory-sync and permission messages, preserving
Wrike status behavior, environment identifiers and subtask completion choices.
Technical prose is lower confidence and needs fluent review.
Both translation and guarded-correction suites pass with 30,962 corrections.
All 21 human-preference checks pass; 38,430 existing Chinese values are
unchanged.
Three browser cases pass syntax and discovery; execution remains pending.
Igbo has 69 detected placeholders left. All-language semantic review is
unfinished.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/5c5391fb98d3619308aa2ba3328f47db870e5655">Translate Igbo stopped list sync recovery messages</a>. Thanks to xet7.</summary>
Translate 18 recovery messages, preserving applied changes, discarded pending
writes, replay restrictions and the oldest-50 display limit. Recovery prose
needs fluent review.
Both translation and guarded-correction suites pass with 30,980 corrections.
All 21 human-preference checks pass; 38,430 existing Chinese values are
unchanged.
One browser case passes syntax and discovery; execution remains pending.
All-language translation and semantic verification remain unfinished.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/8c25ce78b94d65d3a7fd4540bdb065443a6c6dbd">Translate Igbo interrupted import recovery messages</a>. Thanks to xet7.</summary>
Translate 21 messages, preserving permanent deletion, retained partial boards,
source-file limitations, untouched foreign boards and recovery limits.
Direct recovery prose needs fluent review.
Both translation and guarded-correction suites pass with 31,001 corrections.
All 21 human-preference checks pass; existing Chinese values are unchanged
(38,430 checked). One browser case passes syntax and discovery; execution is
pending. All-language translation and semantic verification remain unfinished.
</details>
Thanks to above GitHub users for their contributions and translators for their translations.
# v12.40 2026-10-14 WeKan ® release
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "wekan",
"version": "v13.05.0",
"version": "v13.06.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wekan",
"version": "v13.05.0",
"version": "v13.06.0",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "wekan",
"version": "v13.05.0",
"version": "v13.06.0",
"description": "Open-Source kanban",
"private": true,
"repository": {
+3 -3
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf8" />
<title>WeKan REST API v13.05</title>
<title>WeKan REST API v13.06</title>
<!-- needed for adaptive design -->
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
@@ -484,11 +484,11 @@ data-styled.g658[id="sc-hHttOM"]{content:"dKSNwp,"}/*!sc*/
<body>
<div id="redoc"><div hidden=""><script>try{var m=localStorage.getItem("redoc.colorMode");if(m!=="light"&&m!=="dark")m=matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light";document.documentElement.classList.add(m)}catch(e){}</script></div><div style="--navbar-height:0px;--navbar-stack-height:0px" class="sc-kIbBWU fFQMWf"><div class="sc-cjUTkN dGsweu menu-content"><div class="sc-jqwVmx jjzLa-d"><div data-testid="search-trigger" class="sc-cToEys dffrbr"><div class="sc-cRcubC ddjiE"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" data-component-name="icons/SearchIcon/SearchIcon" class="sc-cHazmW gTzQi"><path d="M14.5 13.7931L10.724 10.017C11.6313 8.9277 12.0838 7.5305 11.9872 6.11608C11.8907 4.70165 11.2525 3.37891 10.2055 2.423C9.15857 1.4671 7.78336 0.951637 6.36602 0.983845C4.94867 1.01605 3.5983 1.59345 2.59582 2.59593C1.59335 3.5984 1.01595 4.94877 0.983738 6.36612C0.95153 7.78347 1.46699 9.15867 2.4229 10.2057C3.3788 11.2526 4.70155 11.8908 6.11597 11.9873C7.53039 12.0839 8.92759 11.6314 10.0169 10.7241L13.793 14.5001L14.5 13.7931ZM2.00001 6.50012C2.00001 5.6101 2.26393 4.74007 2.7584 4.00005C3.25286 3.26003 3.95567 2.68325 4.77794 2.34266C5.6002 2.00207 6.505 1.91295 7.37792 2.08658C8.25083 2.26022 9.05266 2.6888 9.68199 3.31814C10.3113 3.94747 10.7399 4.7493 10.9135 5.62221C11.0872 6.49513 10.9981 7.39993 10.6575 8.22219C10.3169 9.04446 9.7401 9.74726 9.00008 10.2417C8.26006 10.7362 7.39003 11.0001 6.50001 11.0001C5.30694 10.9988 4.16312 10.5243 3.31949 9.68064C2.47587 8.83701 2.00133 7.69319 2.00001 6.50012Z"></path></svg><span>Loading...</span></div></div></div><div class="sc-dNLPWG dtcefb"><ul data-component-name="Menu/Menu" class="sc-kBPbfe ctPApS"><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-default" role="listitem"><a class="sc-iJONRL iBHKJo" href="/" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="true" class="sc-epufQc fYmDZR"><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="WeKan REST API" class="sc-eStdmu iyAVQU">WeKan REST API</span></span></div></li></a></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-default" role="listitem"><a class="sc-iJONRL iBHKJo" href="/section/production-security-concerns" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc itBMtf"><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Production Security Concerns" class="sc-eStdmu liVEkC">Production Security Concerns</span></span></div></li></a></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-group" role="listitem"><a class="sc-iJONRL iBHKJo" href="/login" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc hKPPiy"><div class="sc-jokKPH jtaKKl"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" color="--tree-content-color-default" data-component-name="icons/ChevronRightIcon/ChevronRightIcon" class="sc-iUlCMS kEEIsB"><path d="M11 8L6.00002 13L5.30002 12.3L9.60002 8L5.30002 3.7L6.00002 3L11 8Z"></path></svg></div><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Login" class="sc-eStdmu liVEkC">Login</span></span></div></li></a><div style="display:none;height:0px;overflow:hidden" class="sc-hgJgCR dlNiXx"></div></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-group" role="listitem"><a class="sc-iJONRL iBHKJo" href="/attachmentstoragesettings" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc hKPPiy"><div class="sc-jokKPH jtaKKl"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" color="--tree-content-color-default" data-component-name="icons/ChevronRightIcon/ChevronRightIcon" class="sc-iUlCMS kEEIsB"><path d="M11 8L6.00002 13L5.30002 12.3L9.60002 8L5.30002 3.7L6.00002 3L11 8Z"></path></svg></div><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="AttachmentStorageSettings" class="sc-eStdmu liVEkC">AttachmentStorageSettings</span></span></div></li></a><div style="display:none;height:0px;overflow:hidden" class="sc-hgJgCR dlNiXx"></div></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-group" role="listitem"><a class="sc-iJONRL iBHKJo" href="/settings" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc hKPPiy"><div class="sc-jokKPH jtaKKl"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" color="--tree-content-color-default" data-component-name="icons/ChevronRightIcon/ChevronRightIcon" class="sc-iUlCMS kEEIsB"><path d="M11 8L6.00002 13L5.30002 12.3L9.60002 8L5.30002 3.7L6.00002 3L11 8Z"></path></svg></div><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Settings" classLine truncated
<div id="redoc"><div hidden=""><script>try{var m=localStorage.getItem("redoc.colorMode");if(m!=="light"&&m!=="dark")m=matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light";document.documentElement.classList.add(m)}catch(e){}</script></div><div style="--navbar-height:0px;--navbar-stack-height:0px" class="sc-kIbBWU fFQMWf"><div class="sc-cjUTkN dGsweu menu-content"><div class="sc-jqwVmx jjzLa-d"><div data-testid="search-trigger" class="sc-cToEys dffrbr"><div class="sc-cRcubC ddjiE"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" data-component-name="icons/SearchIcon/SearchIcon" class="sc-cHazmW gTzQi"><path d="M14.5 13.7931L10.724 10.017C11.6313 8.9277 12.0838 7.5305 11.9872 6.11608C11.8907 4.70165 11.2525 3.37891 10.2055 2.423C9.15857 1.4671 7.78336 0.951637 6.36602 0.983845C4.94867 1.01605 3.5983 1.59345 2.59582 2.59593C1.59335 3.5984 1.01595 4.94877 0.983738 6.36612C0.95153 7.78347 1.46699 9.15867 2.4229 10.2057C3.3788 11.2526 4.70155 11.8908 6.11597 11.9873C7.53039 12.0839 8.92759 11.6314 10.0169 10.7241L13.793 14.5001L14.5 13.7931ZM2.00001 6.50012C2.00001 5.6101 2.26393 4.74007 2.7584 4.00005C3.25286 3.26003 3.95567 2.68325 4.77794 2.34266C5.6002 2.00207 6.505 1.91295 7.37792 2.08658C8.25083 2.26022 9.05266 2.6888 9.68199 3.31814C10.3113 3.94747 10.7399 4.7493 10.9135 5.62221C11.0872 6.49513 10.9981 7.39993 10.6575 8.22219C10.3169 9.04446 9.7401 9.74726 9.00008 10.2417C8.26006 10.7362 7.39003 11.0001 6.50001 11.0001C5.30694 10.9988 4.16312 10.5243 3.31949 9.68064C2.47587 8.83701 2.00133 7.69319 2.00001 6.50012Z"></path></svg><span>Loading...</span></div></div></div><div class="sc-dNLPWG dtcefb"><ul data-component-name="Menu/Menu" class="sc-kBPbfe ctPApS"><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-default" role="listitem"><a class="sc-iJONRL iBHKJo" href="/" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="true" class="sc-epufQc fYmDZR"><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="WeKan REST API" class="sc-eStdmu iyAVQU">WeKan REST API</span></span></div></li></a></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-default" role="listitem"><a class="sc-iJONRL iBHKJo" href="/section/production-security-concerns" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc itBMtf"><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Production Security Concerns" class="sc-eStdmu liVEkC">Production Security Concerns</span></span></div></li></a></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-group" role="listitem"><a class="sc-iJONRL iBHKJo" href="/login" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc hKPPiy"><div class="sc-jokKPH jtaKKl"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" color="--tree-content-color-default" data-component-name="icons/ChevronRightIcon/ChevronRightIcon" class="sc-iUlCMS kEEIsB"><path d="M11 8L6.00002 13L5.30002 12.3L9.60002 8L5.30002 3.7L6.00002 3L11 8Z"></path></svg></div><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Login" class="sc-eStdmu liVEkC">Login</span></span></div></li></a><div style="display:none;height:0px;overflow:hidden" class="sc-hgJgCR dlNiXx"></div></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-group" role="listitem"><a class="sc-iJONRL iBHKJo" href="/attachmentstoragesettings" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc hKPPiy"><div class="sc-jokKPH jtaKKl"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" color="--tree-content-color-default" data-component-name="icons/ChevronRightIcon/ChevronRightIcon" class="sc-iUlCMS kEEIsB"><path d="M11 8L6.00002 13L5.30002 12.3L9.60002 8L5.30002 3.7L6.00002 3L11 8Z"></path></svg></div><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="AttachmentStorageSettings" class="sc-eStdmu liVEkC">AttachmentStorageSettings</span></span></div></li></a><div style="display:none;height:0px;overflow:hidden" class="sc-hgJgCR dlNiXx"></div></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-group" role="listitem"><a class="sc-iJONRL iBHKJo" href="/settings" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc hKPPiy"><div class="sc-jokKPH jtaKKl"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" color="--tree-content-color-default" data-component-name="icons/ChevronRightIcon/ChevronRightIcon" class="sc-iUlCMS kEEIsB"><path d="M11 8L6.00002 13L5.30002 12.3L9.60002 8L5.30002 3.7L6.00002 3L11 8Z"></path></svg></div><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Settings" classLine truncated
<script type="module">
import { hydrate } from "https://cdn.redocly.com/redoc/v3.0.1/bundles/redoc.standalone.js";
const __redoc_definition = {"openapi":"3.2.0","info":{"title":"WeKan REST API","version":"v13.05","description":"The REST API allows you to control and extend WeKan with ease.\n\nIf you are an end-user and not a dev or a tester, [create an issue](https://github.com/wekan/wekan/issues/new) to request new APIs.\n\n> All API calls in the documentation are made using `curl`. However, you are free to use Java / Python / PHP / Golang / Ruby / Swift / Objective-C / Rust / Scala / C# or any other programming languages.\n\n# Production Security Concerns\nWhen calling a production WeKan server, ensure it is running via HTTPS and has a valid SSL Certificate. The login method requires you to post your username and password in plaintext, which is why we highly suggest only calling the REST login api over HTTPS. Also, few things to note:\n\n* Only call via HTTPS\n* Implement a timed authorization token expiration strategy\n* Ensure the calling user only has permissions for what they are calling and no more\n"},"paths":{"/users/login":{"post":{"operationId":"login","summary":"Login with REST API","tags":["Login"],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["username","password"],"properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"}}}},"application/x-www-form-urlencoded":{"schema":{"type":"object","required":["username","password"],"properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"}}}}},"description":"Login credentials","required":true},"responses":{"200":{"description":"Successful authentication","content":{"*/*":{"schema":{"type":"object","required":["id","token","tokenExpires"],"properties":{"id":{"type":"string","description":"User ID"},"token":{"type":"string","description":"Authentication token\n"},"tokenExpires":{"type":"string","format":"date-time","description":"Token expiration date\n"}}}}}},"400":{"description":"Error in authentication\n","content":{"*/*":{"schema":{"type":"object","properties":{"error":{"type":"string"},"reason":{"type":"string"}}}}}},"default":{"description":"Error in authentication\n"}}}},"/users/register":{"post":{"operationId":"register","summary":"Register with REST API","description":"Notes:\n - You will need to provide the token for any of the authenticated methods.\n","tags":["Login"],"requestBody":{"content":{"application/x-www-form-urlencoded":{"schema":{"type":"object","properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"},"email":{"description":"Your email\n","type":"string"}},"required":["username","password","email"]}}},"required":true},"responses":{"200":{"description":"Successful registration","content":{"*/*":{"schema":{"items":{"properties":{"id":{"type":"string"},"token":{"type":"string"},"tokenExpires":{"type":"string"}}}}}}},"400":{"description":"Error in registration\n","content":{"*/*":{"schema":{"items":{"properties":{"error":{"type":"number"},"reason":{"type":"string"}}}}}}},"default":{"description":"Error in registration\n"}}}},"/api/admin/attachment-settings":{"get":{"operationId":"get_attachment_settings","summary":"Get the attachment storage / upload-block settings (GlobalAdmin)","description":"Only the global admin can call this. Returns the Admin Panel >\n Attachments settings document, including `limitSettings.attachmentsUploadBlocked`\n and `limitSettings.avatarsUploadBlocked` plus the size limits. Cloud-storage\n secret keys (S3 secretAccessKey, Azure accountKey/connectionString, GCS\n credentials) are masked and never returned; a boolean `\u003cfield>Set` marker\n shows whether a value exists.\n","tags":["AttachmentStorageSettings","Settings"],"security":[{"UserSecurity":[]}],"responses":{"200":{"description":"200 response","content":{"application/json":{"schema":{"type":"Object"}}}}}},"put":{"operationId":"update_attachment_settings","summary":"Update the attachment storage / upload-block settings (GlobalAdmin)","description":"Only the global admin can call this. The request body is a\n partial settings object (for example\n `{\"limitSettings\": {\"avatarsUploadBlocked\": true}}`). It is applied through\n the same update path as the `updateAttachmentStorageSettings` Meteor method,\n so validation and `$set` semantics are identical: cloud-storage secrets left\n blank are preserved. The masked, updated settings document is returned.\n","tags":["AttachmentStorageSettings","Settings"],"security":[{"UserSecurity":[]}],"responses":{"200":{"description":"200 response","content":{"application/json":{"schema":{"type":"Object"}}}}}}},"/api/admin/domains":{"get":{"operationId":"get_admin_domains","summary":"List email domains with their user counts (GlobalAdmin)","description":"Only the global admin can call this. CountsLine truncated
const __redoc_definition = {"openapi":"3.2.0","info":{"title":"WeKan REST API","version":"v13.06","description":"The REST API allows you to control and extend WeKan with ease.\n\nIf you are an end-user and not a dev or a tester, [create an issue](https://github.com/wekan/wekan/issues/new) to request new APIs.\n\n> All API calls in the documentation are made using `curl`. However, you are free to use Java / Python / PHP / Golang / Ruby / Swift / Objective-C / Rust / Scala / C# or any other programming languages.\n\n# Production Security Concerns\nWhen calling a production WeKan server, ensure it is running via HTTPS and has a valid SSL Certificate. The login method requires you to post your username and password in plaintext, which is why we highly suggest only calling the REST login api over HTTPS. Also, few things to note:\n\n* Only call via HTTPS\n* Implement a timed authorization token expiration strategy\n* Ensure the calling user only has permissions for what they are calling and no more\n"},"paths":{"/users/login":{"post":{"operationId":"login","summary":"Login with REST API","tags":["Login"],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["username","password"],"properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"}}}},"application/x-www-form-urlencoded":{"schema":{"type":"object","required":["username","password"],"properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"}}}}},"description":"Login credentials","required":true},"responses":{"200":{"description":"Successful authentication","content":{"*/*":{"schema":{"type":"object","required":["id","token","tokenExpires"],"properties":{"id":{"type":"string","description":"User ID"},"token":{"type":"string","description":"Authentication token\n"},"tokenExpires":{"type":"string","format":"date-time","description":"Token expiration date\n"}}}}}},"400":{"description":"Error in authentication\n","content":{"*/*":{"schema":{"type":"object","properties":{"error":{"type":"string"},"reason":{"type":"string"}}}}}},"default":{"description":"Error in authentication\n"}}}},"/users/register":{"post":{"operationId":"register","summary":"Register with REST API","description":"Notes:\n - You will need to provide the token for any of the authenticated methods.\n","tags":["Login"],"requestBody":{"content":{"application/x-www-form-urlencoded":{"schema":{"type":"object","properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"},"email":{"description":"Your email\n","type":"string"}},"required":["username","password","email"]}}},"required":true},"responses":{"200":{"description":"Successful registration","content":{"*/*":{"schema":{"items":{"properties":{"id":{"type":"string"},"token":{"type":"string"},"tokenExpires":{"type":"string"}}}}}}},"400":{"description":"Error in registration\n","content":{"*/*":{"schema":{"items":{"properties":{"error":{"type":"number"},"reason":{"type":"string"}}}}}}},"default":{"description":"Error in registration\n"}}}},"/api/admin/attachment-settings":{"get":{"operationId":"get_attachment_settings","summary":"Get the attachment storage / upload-block settings (GlobalAdmin)","description":"Only the global admin can call this. Returns the Admin Panel >\n Attachments settings document, including `limitSettings.attachmentsUploadBlocked`\n and `limitSettings.avatarsUploadBlocked` plus the size limits. Cloud-storage\n secret keys (S3 secretAccessKey, Azure accountKey/connectionString, GCS\n credentials) are masked and never returned; a boolean `\u003cfield>Set` marker\n shows whether a value exists.\n","tags":["AttachmentStorageSettings","Settings"],"security":[{"UserSecurity":[]}],"responses":{"200":{"description":"200 response","content":{"application/json":{"schema":{"type":"Object"}}}}}},"put":{"operationId":"update_attachment_settings","summary":"Update the attachment storage / upload-block settings (GlobalAdmin)","description":"Only the global admin can call this. The request body is a\n partial settings object (for example\n `{\"limitSettings\": {\"avatarsUploadBlocked\": true}}`). It is applied through\n the same update path as the `updateAttachmentStorageSettings` Meteor method,\n so validation and `$set` semantics are identical: cloud-storage secrets left\n blank are preserved. The masked, updated settings document is returned.\n","tags":["AttachmentStorageSettings","Settings"],"security":[{"UserSecurity":[]}],"responses":{"200":{"description":"200 response","content":{"application/json":{"schema":{"type":"Object"}}}}}}},"/api/admin/domains":{"get":{"operationId":"get_admin_domains","summary":"List email domains with their user counts (GlobalAdmin)","description":"Only the global admin can call this. CountsLine truncated
const __redoc_options = {"skipBundle":true,"disableTelemetry":false};
hydrate(__redoc_definition, __redoc_options, document.getElementById('redoc'));
+1 -1
View File
@@ -1,7 +1,7 @@
swagger: '2.0'
info:
title: WeKan REST API
version: v13.05
version: v13.06
description: |
The REST API allows you to control and extend WeKan with ease.
+2 -2
View File
@@ -38,9 +38,9 @@ check() {
echo "=== MongoDB 7.0.14 (amd64 and arm64 native; amd64 also used via QEMU for armhf/s390x/ppc64el) ==="
check "MongoDB 7.0.14 amd64 (x86_64, ubuntu2204)" \
"https://fastdl.mongodb.org/linux/mongodb-linux-x86_64-ubuntu2204-7.0.45.tgz"
"https://fastdl.mongodb.org/linux/mongodb-linux-x86_64-ubuntu2204-7.0.48.tgz"
check "MongoDB 7.0.14 arm64 (aarch64, ubuntu2204)" \
"https://fastdl.mongodb.org/linux/mongodb-linux-aarch64-ubuntu2204-7.0.45.tgz"
"https://fastdl.mongodb.org/linux/mongodb-linux-aarch64-ubuntu2204-7.0.48.tgz"
echo " NOTE MongoDB Community has no prebuilt builds for s390x, ppc64le, or arm/v7."
echo " The amd64 binary above is also used on those arches via qemu-x86_64-static."
echo ""
+2 -2
View File
@@ -22,10 +22,10 @@ const pkgdef :Spk.PackageDefinition = (
appTitle = (defaultText = "Wekan"),
# The name of the app as it is displayed to the user.
appVersion = 1305,
appVersion = 1306,
# Increment this for every release.
appMarketingVersion = (defaultText = "13.05~2026-11-10"),
appMarketingVersion = (defaultText = "13.06~2026-11-10"),
# Human-readable presentation of the app version.
minUpgradableAppVersion = 0,
+28
View File
@@ -59,6 +59,10 @@
// rejected by FerretDB ("infinity values are not
// allowed"), aborting MongoDB->FerretDB migration ->
// reset to a finite 0
// card-overtime-field #6888: REST PUT stored isOverTime, which nothing
// reads; the schema and UI field is isOvertime ->
// folded into isOvertime where that is unset, and
// the stray field removed
// card-children-boardid #6793: comments/reactions/text notes/attachments
// still carrying the board their card was moved
// AWAY from -> the card's current board id
@@ -863,6 +867,30 @@ const steps = [
},
},
{
// card-overtime-field (#6888): the REST card PUT stored its isOverTime
// parameter under that spelling (b895e61237 until #6888), while the
// schema, the UI and every reader use isOvertime - so an overtime set
// through the API answered 200 and was seen nowhere. #6888 writes the
// right field from now on; this carries over what was written before.
// Where a card has both, isOvertime - the one shown - is kept. Two
// updateMany batches and an $unset; idempotent, nothing left after.
name: 'card-overtime-field',
async check(db) {
return !!(await db.collection('cards').findOne({ isOverTime: { $exists: true } }, { projection: { _id: 1 } }));
},
async run(db) {
const cards = db.collection('cards');
let fixed = 0;
for (const value of [true, false]) {
const r = await cards.updateMany({ isOverTime: value, isOvertime: { $exists: false } }, { $set: { isOvertime: value } });
fixed += (r && r.modifiedCount) || 0;
}
await cards.updateMany({ isOverTime: { $exists: true } }, { $unset: { isOverTime: '' } });
return { fixed, unresolved: 0 };
},
},
{
// checklist-minicard-unset: `showChecklistAtMinicard: false` written by the
// old schema default, which now means something it did not mean then.
+40
View File
@@ -0,0 +1,40 @@
// The card date a Cards.before.update records as its a-<field> activity:
// the update's first changed field, when it is one of the four dates.
//
// A date SET carries timeValue; a date CLEARED (unsetDue/unsetStart/unsetEnd/
// unsetReceived send only a $unset, or a null value) carries timeOldValue
// alone - the same record a rule's removeDate writes
// (server/lib/syncRuleCardCommand.js), and the one models/lib/boardTimeline.js
// rewinds a card's dates through. The hook used to read modifier.$set[field]
// whatever the update was, so a $unset alone threw a TypeError inside it and,
// with a $set beside it, wrote timeValue: undefined.
//
// A cleared date is not "a card's date is set or changed" (#2474,
// server/triggersDef.js): isTimingClear() lets the rules skip it.
export const TIMING_FIELDS = ['receivedAt', 'dueAt', 'startAt', 'endAt'];
const has = (o, k) => !!o && typeof o === 'object' && Object.prototype.hasOwnProperty.call(o, k);
// { field, set: true, value } for a date set, { field, set: false } for a
// date cleared, or null when the update is not a change of a date.
export function timingChange(fieldNames, modifier) {
const fields = (Array.isArray(fieldNames) ? fieldNames : []).filter(name => name !== 'dateLastActivity');
const field = fields[0];
if (!TIMING_FIELDS.includes(field) || !modifier) return null;
const set = modifier.$set;
if (has(set, field)) {
const value = set[field];
if (value === null || value === undefined || value === '') return { field, set: false };
return { field, set: true, value };
}
if (has(modifier.$unset, field)) return { field, set: false };
return null;
}
// Is this activity a card date cleared - an a-<date> without a timeValue?
export function isTimingClear(activity) {
if (!activity || typeof activity.activityType !== 'string') return false;
const field = activity.activityType.replace(/^a-/, '');
return activity.activityType.startsWith('a-') && TIMING_FIELDS.includes(field) &&
(activity.timeValue === undefined || activity.timeValue === null || activity.timeValue === '');
}
+11 -7
View File
@@ -17,6 +17,7 @@ const { applyCardBoardConsistency } = require('/server/lib/cardBoardConsistency'
const { stampCardListEntry } = require('/models/lib/cardListEntry');
import { titleChanged } from '/server/lib/titleChangeActivity';
import { descriptionChanged } from '/server/lib/descriptionChangeActivity';
import { timingChange } from '/server/lib/timingChangeActivity';
const { collectionWriteSucceeded } = require('/server/lib/collectionWriteOutcome');
const { deferSyncRecording, deferSyncLabelActivities } = require('/server/lib/syncRecordingScope');
const { movedScrumMetadata } = require('/models/lib/scrumCopy');
@@ -1264,13 +1265,15 @@ Cards.after.update(async function(userId, doc, fieldNames) {
});
Cards.before.update(async (userId, doc, fieldNames, modifier) => {
const fields = fieldNames.filter(name => name !== 'dateLastActivity');
const timingaction = ['receivedAt', 'dueAt', 'startAt', 'endAt'];
const action = fields[0];
if (fields.length > 0 && timingaction.includes(action)) {
// A date set carries timeValue, a date cleared ($unset) timeOldValue alone;
// neither reads a $set that is not there (server/lib/timingChangeActivity.js).
const change = timingChange(fieldNames, modifier);
if (change) {
// A durable rule date action writes this activity from its saved plan.
if (deferSyncRecording('timing', doc)) return;
const value = modifier.$set[action];
const action = change.field;
// Clearing a date the card does not have changes nothing.
if (!change.set && !doc[action]) return;
const oldvalue = doc[action] || '';
const activityType = `a-${action}`;
const card = await ReactiveCache.getCard(doc._id);
@@ -1296,7 +1299,7 @@ Cards.before.update(async (userId, doc, fieldNames, modifier) => {
cardId: doc._id,
cardTitle: doc.title,
timeKey: action,
timeValue: value,
...(change.set ? { timeValue: change.value } : {}),
timeOldValue: oldvalue,
listId: card.listId,
swimlaneId: card.swimlaneId,
@@ -1985,7 +1988,8 @@ WebApp.handlers.put(
if ('isOverTime' in scalars.set) {
await Cards.direct.updateAsync(
{ _id: paramCardId, listId: paramListId, boardId: paramBoardId, archived: false },
{ $set: { isOverTime: scalars.set.isOverTime } },
// Keep the REST parameter spelling; the schema and UI use isOvertime.
{ $set: { isOvertime: scalars.set.isOverTime } },
);
updated = true;
}
+3
View File
@@ -27,6 +27,7 @@ import { relativeDateOffset } from '/models/lib/relativeDateOffset';
import { resolveRuleSwimlaneId, resolveRuleListId } from '/models/lib/ruleActionResolve';
import { cardTitleMatchList, cardTitleFilterMatches } from '/models/lib/ruleCardTitleFilter';
import { triggerMatchesWithVars } from '/models/lib/ruleTriggerVars';
import { isTimingClear } from '/server/lib/timingChangeActivity';
import { allowIsBoardMemberWithWriteAccess } from '/server/lib/utils';
import { tripCanary } from '/server/lib/canary';
import { substituteVars, recipientVars } from '/models/lib/ruleVarsSubstitute';
@@ -424,6 +425,8 @@ export const RulesHelper = {
return { activity: { ...activity, boardId: card.boardId }, followedFrom: activity.boardId };
},
async findMatchingRules(activity) {
// A card date cleared is not "a card's date is set or changed" (#2474).
if (isTimingClear(activity)) return [];
const activityType = activity.activityType;
const matchingRules = [];
if (TriggersDef[activityType] !== undefined) {
+5 -5
View File
@@ -19,7 +19,7 @@
# base: core24 (not core26) · no build-base: devel · grade: stable
# ─────────────────────────────────────────────────────────────────────────────
name: wekan
version: '13.05'
version: '13.06'
base: core24
confinement: strict
grade: stable
@@ -258,7 +258,7 @@ parts:
# mongodb-control disables the mongodb service when database=ferretdb.
*) echo "No MongoDB server for ${CRAFT_ARCH_BUILD_FOR}; FerretDB-only arch, skipping mongod."; exit 0 ;;
esac
MONGO_URL="https://fastdl.mongodb.org/linux/mongodb-linux-${MONGO_ARCH}-ubuntu2204-7.0.45.tgz"
MONGO_URL="https://fastdl.mongodb.org/linux/mongodb-linux-${MONGO_ARCH}-ubuntu2204-7.0.48.tgz"
bash "${CRAFT_PROJECT_DIR:-${SNAPCRAFT_PROJECT_DIR:-$PWD}}/releases/fetch.sh" -o mongodb.tgz "$MONGO_URL"
tar -xzf mongodb.tgz --strip-components=1
mkdir -p "$SNAPCRAFT_PART_INSTALL/bin"
@@ -703,18 +703,18 @@ parts:
armhf) WEKAN_ARCH="armhf" ;;
*) echo "Unsupported architecture: ${CRAFT_ARCH_BUILD_FOR}"; exit 1 ;;
esac
WEKAN_ZIP="wekan-13.05-${WEKAN_ARCH}.zip"
WEKAN_ZIP="wekan-13.06-${WEKAN_ARCH}.zip"
echo "Cleaning environment first"
rm -rf .build
mkdir .build
cd .build
#wget https://github.com/wekan/wekan/releases/download/v13.05/wekan-13.05-amd64.zip
#wget https://github.com/wekan/wekan/releases/download/v13.06/wekan-13.06-amd64.zip
# Retry: a release asset can 404 for a short while right after it is
# uploaded (CDN/propagation lag), and a plain wget treated that as
# fatal. --retry-on-http-error covers the 404/5xx window; a genuinely
# missing bundle still fails after the tries are spent.
wget --tries=20 --waitretry=20 --retry-on-http-error=404,403,500,502,503 \
"https://github.com/wekan/wekan/releases/download/v13.05/${WEKAN_ZIP}"
"https://github.com/wekan/wekan/releases/download/v13.06/${WEKAN_ZIP}"
unzip "${WEKAN_ZIP}"
python3 ../releases/check-telemetry.py --bundle bundle
rm "${WEKAN_ZIP}"
+107
View File
@@ -0,0 +1,107 @@
'use strict';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const source = fs.readFileSync(path.join(__dirname, '../models/cards.js'), 'utf8');
const start = source.indexOf('async function cardMembers(');
const end = source.indexOf('async function cardLabels(', start);
assert.ok(start >= 0 && end > start, 'membership activity hooks are present');
function hooks(user) {
const activities = [];
const context = {
ReactiveCache: { getUser: async () => user },
Activities: { insertAsync: async activity => activities.push(activity) },
};
vm.runInNewContext(source.slice(start, end), context);
return { ...context, activities };
}
for (const [field, hook, idKey, joined, removed] of [
['members', 'cardMembers', 'memberId', 'joinMember', 'unjoinMember'],
['assignees', 'cardAssignees', 'assigneeId', 'joinAssignee', 'unjoinAssignee'],
]) {
const card = { _id: 'card', boardId: 'board', listId: 'list', swimlaneId: 'lane' };
test(`${field}: removing an absent user is a no-op without activity`, async () => {
const h = hooks(undefined);
for (const membership of [undefined, [], ['other']]) {
await h[hook]('actor', { ...card, [field]: membership }, [field], { $pull: { [field]: 'missing' } });
}
assert.equal(h.activities.length, 0);
});
test(`${field}: a deleted user's assignment can be removed and recorded`, async () => {
const h = hooks(undefined);
await h[hook]('actor', { ...card, [field]: ['deleted'] }, [field], { $pull: { [field]: 'deleted' } });
assert.equal(h.activities.length, 1);
assert.deepEqual({ ...h.activities[0] }, {
userId: 'actor', username: undefined, activityType: removed,
boardId: card.boardId, cardId: card._id, [idKey]: 'deleted',
listId: card.listId, swimlaneId: card.swimlaneId,
});
});
test(`${field}: normal joins and removals retain the username and activity scope`, async () => {
const h = hooks({ username: 'member' });
await h[hook]('actor', { ...card, [field]: [] }, [field], { $addToSet: { [field]: 'member' } });
await h[hook]('actor', { ...card, [field]: ['member'] }, [field], { $pull: { [field]: 'member' } });
assert.deepEqual(h.activities.map(activity => activity.activityType), [joined, removed]);
for (const activity of h.activities) {
assert.equal(activity.username, 'member');
assert.equal(activity[idKey], 'member');
assert.equal(activity.userId, 'actor');
assert.equal(activity.cardId, card._id);
}
await h[hook]('actor', { ...card, [field]: [] }, [field], { $pull: { [field]: 'member' } });
await h[hook]('actor', { ...card, [field]: ['member'] }, ['title'], { $pull: { [field]: 'member' } });
assert.equal(h.activities.length, 2);
});
}
// The join halves had the same unguarded user.username (#6889 follow-up):
// REST checks the user before adding, but a client $addToSet, a copied or
// imported card, or a rule can add an id whose account is gone.
for (const [field, hook, idKey, joined] of [
['members', 'cardMembers', 'memberId', 'joinMember'],
['assignees', 'cardAssignees', 'assigneeId', 'joinAssignee'],
]) {
test(`${field}: adding a user whose account is gone does not throw, and records the id`, async () => {
const h = hooks(undefined);
const card = { _id: 'card', boardId: 'board', listId: 'list', swimlaneId: 'lane' };
await assert.doesNotReject(() => h[hook]('actor', { ...card, [field]: [] }, [field], { $addToSet: { [field]: 'gone' } }));
assert.equal(h.activities.length, 1);
assert.equal(h.activities[0].activityType, joined);
assert.equal(h.activities[0][idKey], 'gone');
assert.equal(h.activities[0].username, undefined);
});
}
// Negative, tree-wide: a user looked up is never dereferenced before it is
// known to exist - the shape both halves of #6889 had.
test('nowhere: `const x = user.field;` straight after getUser() without a guard', () => {
const root = path.join(__dirname, '..');
const offenders = [];
const walk = dir => {
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
if (['node_modules', '.meteor', 'tests', 'packages', 'public'].includes(e.name) || e.name.startsWith('_build') || e.name.startsWith('.')) continue;
const p = path.join(dir, e.name);
if (e.isDirectory()) walk(p);
else if (p.endsWith('.js')) {
const lines = fs.readFileSync(p, 'utf8').split('\n');
lines.forEach((line, i) => {
const m = /const (\w+) = await ReactiveCache\.getUser\(/.exec(line);
if (!m) return;
const next = (lines.slice(i + 1).find(l => l.trim()) || '').trim();
if (new RegExp(`^const \\w+ = ${m[1]}\\.\\w+;`).test(next)) offenders.push(`${path.relative(root, p)}:${i + 2}`);
});
}
}
};
for (const dir of ['server', 'models', 'client', 'imports']) walk(path.join(root, dir));
assert.deepEqual(offenders, []);
});
+132
View File
@@ -0,0 +1,132 @@
'use strict';
// A card date cleared ($unset - unsetDue/unsetStart/unsetEnd/unsetReceived)
// went through the Cards.before.update timing hook, which read
// modifier.$set[field] whatever the update was: a $unset alone threw a
// TypeError inside the hook, and with a $set beside it an a-dueAt with
// timeValue: undefined was written - counted as a due date change and
// matched by the "date set or changed" rule triggers. Now a date set
// carries timeValue, a date cleared timeOldValue alone (as a rule's
// removeDate records it, and models/lib/boardTimeline.js rewinds), and the
// rules skip a clear.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const { deferSyncRecording } = require('../server/lib/syncRecordingScope');
const root = path.join(__dirname, '..');
const read = file => fs.readFileSync(path.join(root, file), 'utf8');
const helper = () => import('../server/lib/timingChangeActivity.js');
test('timingChange: a date set, a date cleared, or no date change', async () => {
const { timingChange } = await helper();
const at = new Date('2099-12-31T17:00:00Z');
assert.deepEqual(timingChange(['dueAt'], { $set: { dueAt: at } }), { field: 'dueAt', set: true, value: at });
assert.deepEqual(timingChange(['dateLastActivity', 'startAt'], { $set: { dateLastActivity: at, startAt: at } }),
{ field: 'startAt', set: true, value: at });
// The bug: a $unset alone, and a $unset beside a stamped $set.
assert.doesNotThrow(() => timingChange(['dueAt'], { $unset: { dueAt: '' } }));
assert.deepEqual(timingChange(['dueAt'], { $unset: { dueAt: '' } }), { field: 'dueAt', set: false });
assert.deepEqual(timingChange(['endAt', 'modifiedAt'], { $unset: { endAt: '' }, $set: { modifiedAt: at } }), { field: 'endAt', set: false });
assert.deepEqual(timingChange(['receivedAt'], { $set: { receivedAt: null } }), { field: 'receivedAt', set: false });
// Negative: not a date change.
for (const [fields, modifier] of [[['title'], { $set: { title: 'T' } }], [['title', 'dueAt'], { $set: { title: 'T', dueAt: at } }],
[[], {}], [['dueAt'], null], [['dueAt'], { $inc: { dueAt: 1 } }]]) {
assert.equal(timingChange(fields, modifier), null, JSON.stringify([fields, modifier]));
}
});
test('isTimingClear: an a-<date> without timeValue, and nothing else', async () => {
const { isTimingClear } = await helper();
assert.equal(isTimingClear({ activityType: 'a-dueAt', timeOldValue: new Date(1) }), true);
assert.equal(isTimingClear({ activityType: 'a-startAt', timeValue: null }), true);
assert.equal(isTimingClear({ activityType: 'a-dueAt', timeValue: new Date(1) }), false);
for (const activityType of ['a-changedTitle', 'addComment', 'a-isOvertime', undefined]) {
assert.equal(isTimingClear({ activityType }), false, String(activityType));
}
});
async function timingHook(card) {
const { timingChange } = await helper();
const src = read('server/models/cards.js');
const start = src.indexOf('Cards.before.update(async (userId, doc, fieldNames, modifier) => {\n // A date set carries timeValue');
const end = src.indexOf('// Issue #3619', start);
assert.ok(start > 0 && end > start, 'the timing hook is where this test looks for it');
const hooks = [], activities = [];
const context = {
Cards: { before: { update: fn => hooks.push(fn) } }, timingChange, deferSyncRecording, console,
ReactiveCache: { getCard: async () => card, getUser: async () => ({ username: 'writer' }) },
Lists: { direct: { updateAsync: async () => 1 } }, add: () => ({ toISOString: () => 'x' }), now: () => 0,
Activities: { insertAsync: async doc => activities.push(doc) },
};
vm.runInNewContext(src.slice(start, end), context);
return { activities, run: (doc, fieldNames, modifier) => hooks[0]('user', doc, fieldNames, modifier) };
}
test('the timing hook records a set, records a clear without throwing, and nothing for clearing nothing', async () => {
const card = { _id: 'card', listId: 'list', swimlaneId: 'lane', list: async () => ({ _id: 'list', boardId: 'board' }) };
const h = await timingHook(card);
const old = new Date('2026-01-01T00:00:00Z'), at = new Date('2099-12-31T17:00:00Z');
const doc = { _id: 'card', boardId: 'board', title: 'T', dueAt: old };
await h.run(doc, ['dueAt'], { $set: { dueAt: at } });
assert.equal(h.activities[0].activityType, 'a-dueAt');
assert.equal(h.activities[0].timeValue, at);
assert.equal(h.activities[0].timeOldValue, old);
// The bug: this threw "Cannot read properties of undefined".
await assert.doesNotReject(() => h.run(doc, ['dueAt'], { $unset: { dueAt: '' } }));
const cleared = h.activities[1];
assert.equal(cleared.activityType, 'a-dueAt');
assert.ok(!Object.hasOwn(cleared, 'timeValue'), 'a clear carries no new date');
assert.equal(cleared.timeOldValue, old);
// A stamped $set beside the $unset: still a clear, never timeValue: undefined.
await h.run(doc, ['dueAt', 'modifiedAt'], { $unset: { dueAt: '' }, $set: { modifiedAt: at } });
assert.ok(!Object.hasOwn(h.activities[2], 'timeValue'));
// Negative: clearing a date the card does not have records nothing.
await h.run({ ...doc, dueAt: undefined }, ['dueAt'], { $unset: { dueAt: '' } });
assert.equal(h.activities.length, 3);
});
test('a clear is recorded as a rule\'s removeDate records it', () => {
// server/lib/syncRuleCardCommand.js leaves timeValue out when the date is gone.
const rule = read('server/lib/syncRuleCardCommand.js');
assert.match(rule, /Object\.hasOwn\(after, field\) \? \{ timeValue: after\[field\] \} : \{\}/);
assert.match(read('server/models/cards.js'), /\.\.\.\(change\.set \? \{ timeValue: change\.value \} : \{\}\)/);
});
test('the rules skip a cleared date before matching any trigger', () => {
const src = read('server/rulesHelper.js');
const body = src.slice(src.indexOf('async findMatchingRules(activity) {'));
assert.ok(body.indexOf('if (isTimingClear(activity)) return [];') > 0);
assert.ok(body.indexOf('if (isTimingClear(activity)) return [];') < body.indexOf('TriggersDef[activityType]'),
'skipped before the triggers are matched');
});
test('the activity feed shows no empty date for a clear', () => {
const jade = read('client/components/activities/activities.jade');
assert.match(jade, /if currentData\.timeValue\n\s+i\(title=currentData\.timeValue\)/);
});
// Negative, tree-wide: no hook reads a computed key from a modifier's $set
// without knowing the $set is there - the shape of this bug.
test('nowhere: a modifier.$set[...] read that assumes the $set exists', () => {
const skip = new Set(['node_modules', '.meteor', '.build', '_build', '.tools', 'tests', 'packages', 'public']);
const offenders = [];
const walk = dir => {
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
if (skip.has(e.name) || e.name.startsWith('_build')) continue;
const p = path.join(dir, e.name);
if (e.isDirectory()) walk(p);
else if (p.endsWith('.js')) {
read(path.relative(root, p)).split('\n').forEach((line, i) => {
// A read (right of =, or an argument), not the assignment of a modifier we built.
if (/[=(,]\s*modifier\.\$set\[/.test(line) && !/modifier\.\$set\s*&&|\?\.\[/.test(line)) {
offenders.push(`${path.relative(root, p)}:${i + 1}`);
}
});
}
}
};
for (const dir of ['server', 'models', 'client', 'imports']) walk(path.join(root, dir));
assert.deepEqual(offenders, []);
});
+5 -1
View File
@@ -52,7 +52,11 @@ test('#4912: description changes log activityType a-changedDescription', () => {
});
test('#4912: due/start/end/received date changes log activityType a-${action}', () => {
assert.match(cardsSrc, /const timingaction = \[\s*'receivedAt',\s*'dueAt',\s*'startAt',\s*'endAt',?\s*\];/);
// The four dates moved from an inline list into server/lib/timingChangeActivity.js
// when the hook stopped reading a $set a cleared date does not have.
const timingSrc = fs.readFileSync(path.join(__dirname, '../server/lib/timingChangeActivity.js'), 'utf8');
assert.match(timingSrc, /export const TIMING_FIELDS = \[\s*'receivedAt',\s*'dueAt',\s*'startAt',\s*'endAt',?\s*\];/);
assert.match(cardsSrc, /const change = timingChange\(fieldNames, modifier\);/);
assert.match(cardsSrc, /const activityType = `a-\$\{action\}`;/);
});
+3 -2
View File
@@ -51,9 +51,10 @@ test('on macOS: the profile really refuses the home directory and allows the che
const profile = path.join(tmp, 'test.sb');
fs.writeFileSync(profile, `(version 1)\n(allow default)\n(deny file-read* file-write* (subpath "/Users") (subpath "/Volumes"))\n`
+ `(allow file-read* file-write* (subpath ${JSON.stringify(root)}))\n`);
// The paths are arguments, not script text (CodeQL #601's shape).
const r = spawnSync('sandbox-exec', ['-f', profile, '/bin/bash', '-c',
`ls ${JSON.stringify(home + '/Desktop')} >/dev/null 2>&1 && echo home=read || echo home=refused;`
+ `touch ${JSON.stringify(tmp + '/ok')} && echo checkout=written`], { encoding: 'utf8' });
'ls "$1" >/dev/null 2>&1 && echo home=read || echo home=refused; touch "$2" && echo checkout=written',
'_', home + '/Desktop', tmp + '/ok'], { encoding: 'utf8' });
assert.match(r.stdout, /home=refused/);
assert.match(r.stdout, /checkout=written/);
} finally {
+8 -4
View File
@@ -218,12 +218,16 @@ test('a caller\'s function in npm-retry.sh fails as it did, and its program work
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'npmretry-'));
const prog = path.join(dir, 'fake-npm');
fs.writeFileSync(prog, '#!/bin/sh\necho "installed $*"\n', { mode: 0o755 });
const run = body => spawnSync('bash', ['-c', `set -u; node_bin=${JSON.stringify(prog)}; npm() { "$node_bin" "$@"; }; export -f npm; ${body}`],
{ encoding: 'utf8', env: { ...process.env, NPM_RETRY_SLEEPS: '0' } });
const viaFunction = run(`bash ${JSON.stringify(script)} npm install`);
// The paths go in through the environment, never into the script text
// (CodeQL alert #601, js/shell-command-injection-from-environment): a
// temporary directory with a quote, `$(` or a backtick would change it.
// NODE_BIN is not node_bin, so npm-retry.sh's bash still lacks the variable.
const run = body => spawnSync('bash', ['-c', `set -u; node_bin="$NODE_BIN"; npm() { "$node_bin" "$@"; }; export -f npm; ${body}`],
{ encoding: 'utf8', env: { ...process.env, NPM_RETRY_SLEEPS: '0', NODE_BIN: prog, NPM_RETRY_SCRIPT: script } });
const viaFunction = run('bash "$NPM_RETRY_SCRIPT" npm install');
assert.notStrictEqual(viaFunction.status, 0, 'the function, without its variable, fails');
assert.match(viaFunction.stdout + viaFunction.stderr, /node_bin: unbound variable/);
const viaProgram = run(`bash ${JSON.stringify(script)} "$node_bin" install`);
const viaProgram = run('bash "$NPM_RETRY_SCRIPT" "$node_bin" install');
assert.strictEqual(viaProgram.status, 0, viaProgram.stdout + viaProgram.stderr);
assert.match(viaProgram.stdout, /installed install/);
});
@@ -0,0 +1,92 @@
'use strict';
const { test, expect } = require('../fixtures');
const db = require('../helpers/db');
const BoardPage = require('../pages/BoardPage');
const CardPage = require('../pages/CardPage');
const headers = token => ({ Authorization: `Bearer ${token}` });
const cardPath = card => `/api/boards/${card.boardId}/lists/${card.listId}/cards/${card._id}`;
for (const [field, idKey, join, unjoin, avatar] of [
['members', 'memberId', 'joinMember', 'unjoinMember', '.card-details-item-members .js-member'],
['assignees', 'assigneeId', 'joinAssignee', 'unjoinAssignee', '.card-details-item-assignees .js-assignee'],
]) {
test(`${field}: removing an unassigned or nonexistent user succeeds without membership activity`, async ({ request, user, board }) => {
const card = db.findOne('cards', { boardId: board.boardId, title: 'Alpha Card' });
const activities = { cardId: card._id, activityType: { $in: [join, unjoin] } };
for (const target of [user.id, db.uid('missing')]) {
for (let attempt = 0; attempt < 2; attempt++) {
const response = await request.delete(`${cardPath(card)}/${field}/${target}`, { headers: headers(user.token) });
expect(response.status()).toBe(200);
expect(await response.json()).toMatchObject({ _id: card._id, members: [], assignees: [] });
expect(db.findOne('cards', { _id: card._id })[field]).toEqual([]);
expect(db.countDocuments('activities', activities)).toBe(0);
}
}
});
test(`${field}: removing a deleted user's stale assignment persists and records its ID`, async ({ request, user, board }) => {
const card = db.findOne('cards', { boardId: board.boardId, title: 'Alpha Card' });
const deleted = db.uid('deleted');
expect(db.findOne('users', { _id: deleted })).toBeNull();
// Old cards may retain assignments after their user account is gone.
db.updateOne('cards', { _id: card._id }, { $set: { [field]: [deleted, user.id] } });
const response = await request.delete(`${cardPath(card)}/${field}/${deleted}`, { headers: headers(user.token) });
expect(response.status()).toBe(200);
expect((await response.json())[field]).toEqual([user.id]);
expect(db.findOne('cards', { _id: card._id })[field]).toEqual([user.id]);
const read = await request.get(cardPath(card), { headers: headers(user.token) });
expect(read.status()).toBe(200);
expect((await read.json())[field]).toEqual([user.id]);
const activity = { cardId: card._id, activityType: unjoin, [idKey]: deleted };
expect(db.findOne('activities', activity)).toMatchObject({ boardId: card.boardId, listId: card.listId });
const repeat = await request.delete(`${cardPath(card)}/${field}/${deleted}`, { headers: headers(user.token) });
expect(repeat.status()).toBe(200);
expect(db.countDocuments('activities', activity)).toBe(1);
});
test(`${field}: normal assignment and removal update card details and retain activity usernames`, async ({ boardPage: page, request, user, board }) => {
const card = db.findOne('cards', { boardId: board.boardId, title: 'Alpha Card' });
await new BoardPage(page).clickCard(card.listId, card.title);
const details = new CardPage(page);
await details.waitForOpen();
const avatars = details.root.locator(avatar);
await expect(avatars).toHaveCount(0);
const endpoint = `${cardPath(card)}/${field}/${user.id}`;
const add = await request.post(endpoint, { headers: headers(user.token) });
expect(add.status()).toBe(200);
await expect(avatars).toHaveCount(1);
await expect(avatars).toHaveAttribute('title', new RegExp(user.username));
const remove = await request.delete(endpoint, { headers: headers(user.token) });
expect(remove.status()).toBe(200);
expect((await remove.json())[field]).toEqual([]);
expect(db.findOne('cards', { _id: card._id })[field]).toEqual([]);
await expect(avatars).toHaveCount(0);
for (const activityType of [join, unjoin]) {
expect(db.findOne('activities', { cardId: card._id, activityType, [idKey]: user.id }))
.toMatchObject({ username: user.username, boardId: card.boardId });
}
});
}
test('member removal retains authentication, board access, card scope and assignment guards', async ({ request, user, user2, board }) => {
const card = db.findOne('cards', { boardId: board.boardId, title: 'Alpha Card' });
db.updateOne('cards', { _id: card._id }, { $set: { members: [user.id], assignees: [user.id] } });
for (const field of ['members', 'assignees']) {
for (const auth of [{}, headers(user2.token)]) {
const response = await request.delete(`${cardPath(card)}/${field}/${user.id}`, { headers: auth });
expect([401, 403]).toContain(response.status());
}
for (const target of [{ ...card, _id: db.uid('missing') }, { ...card, listId: board.listIds[1] }]) {
const response = await request.delete(`${cardPath(target)}/${field}/${user.id}`, { headers: headers(user.token) });
expect(response.status()).toBe(404);
expect(await response.json()).toEqual({ error: 'Card not found' });
}
const add = await request.post(`${cardPath(card)}/${field}/${db.uid('missing')}`, { headers: headers(user.token) });
expect(add.status()).toBe(400);
expect(await add.json()).toEqual({ error: 'userId is not an active member of this board' });
expect(db.findOne('cards', { _id: card._id })[field]).toEqual([user.id]);
}
expect(db.countDocuments('activities', { cardId: card._id, activityType: { $in: ['joinMember', 'unjoinMember', 'joinAssignee', 'unjoinAssignee'] } })).toBe(0);
});
@@ -0,0 +1,88 @@
'use strict';
const { test, expect } = require('../fixtures');
const db = require('../helpers/db');
const { loginWithToken, openBoard } = require('../helpers/auth');
const BoardPage = require('../pages/BoardPage');
const CardPage = require('../pages/CardPage');
const headers = token => ({ Authorization: `Bearer ${token}` });
const cardPath = card => `/api/boards/${card.boardId}/lists/${card.listId}/cards/${card._id}`;
test('REST overtime persists and updates the open card for JSON and form edits', async ({ boardPage: page, request, user, board }) => {
const card = db.findOne('cards', { boardId: board.boardId, title: 'Alpha Card' });
db.updateOne('boards', { _id: board.boardId }, { $set: { allowsSpentTime: true } });
db.updateOne('cards', { _id: card._id }, { $set: { spentTime: 2, isOvertime: false } });
const bp = new BoardPage(page);
const cp = new CardPage(page);
await bp.clickCard(card.listId, card.title);
await cp.waitForOpen();
const heading = cp.root.locator('.card-details-item-spent .card-details-item-title');
await expect(heading).toContainText('Spent time');
for (const format of ['json', 'form']) {
for (const isOverTime of [true, false]) {
const response = await request.put(cardPath(card), {
headers: headers(user.token),
...(format === 'json' ? { data: { isOverTime } } : { form: { isOverTime: String(isOverTime) } }),
});
expect(response.status()).toBe(200);
expect(await response.json()).toEqual({ _id: card._id });
const stored = db.findOne('cards', { _id: card._id });
expect(stored.isOvertime).toBe(isOverTime);
expect(stored).not.toHaveProperty('isOverTime');
expect(stored.spentTime).toBe(2);
const read = await request.get(cardPath(card), { headers: headers(user.token) });
expect(read.status()).toBe(200);
expect((await read.json()).isOvertime).toBe(isOverTime);
await expect(heading).toContainText(isOverTime ? 'Overtime' : 'Spent time');
}
}
// Omitting the flag during another edit must preserve the saved value.
const enable = await request.put(cardPath(card), { headers: headers(user.token), data: { isOverTime: true } });
expect(enable.status()).toBe(200);
const edit = await request.put(cardPath(card), { headers: headers(user.token), data: { description: 'Keep overtime' } });
expect(edit.status()).toBe(200);
expect(db.findOne('cards', { _id: card._id }).isOvertime).toBe(true);
await page.reload();
// Token-based fixture sessions may be memory-only. Authenticate the new
// page before reopening the private board to check the persisted display.
await loginWithToken(page, user.id, user.token);
await openBoard(page, board.boardId, board.slug);
await bp.clickCard(card.listId, card.title);
await cp.waitForOpen();
await expect(heading).toContainText('Overtime');
});
test('REST overtime rejects invalid, unauthenticated, unauthorized and incorrectly scoped edits without writes', async ({ request, user, user2, board }) => {
const card = db.findOne('cards', { boardId: board.boardId, title: 'Alpha Card' });
db.updateOne('cards', { _id: card._id }, { $set: { isOvertime: true } });
const before = db.findOne('cards', { _id: card._id });
for (const isOverTime of [null, 0, 'yes', {}, []]) {
const response = await request.put(cardPath(card), { headers: headers(user.token), data: { isOverTime, title: 'Renamed' } });
expect(response.status()).toBe(400);
expect(await response.json()).toEqual({ error: 'Invalid isOverTime' });
expect(db.findOne('cards', { _id: card._id })).toEqual(before);
}
for (const auth of [{}, headers(user2.token)]) {
const response = await request.put(cardPath(card), { headers: auth, data: { isOverTime: false } });
expect([401, 403]).toContain(response.status());
expect(db.findOne('cards', { _id: card._id })).toEqual(before);
}
const otherBoard = db.seedBoard({ ownerId: user.id });
try {
for (const target of [
{ ...card, _id: db.uid('missing') },
{ ...card, listId: board.listIds[1] },
{ ...card, boardId: otherBoard.boardId },
]) {
const response = await request.put(cardPath(target), { headers: headers(user.token), data: { isOverTime: false } });
expect(response.status()).toBe(404);
expect(await response.json()).toEqual({ error: 'Card not found' });
expect(db.findOne('cards', { _id: card._id })).toEqual(before);
}
} finally {
db.cleanup({ boardIds: [otherBoard.boardId] });
}
});
+3 -2
View File
@@ -92,7 +92,8 @@ test('a download is readable by everyone, as an AppImage needs (v12.69)', () =>
// after `chmod +x`, and appimagetool refused to pack them.
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fetch-'));
const out = path.join(dir, 'ferretdb');
const r = spawnSync('bash', ['-c', `umask 022; bash "${fetchSh}" -o "${out}" "${base}/ok" && chmod +x "${out}"`],
// Arguments, not script text: the paths cannot change the command (CodeQL #601's shape).
const r = spawnSync('bash', ['-c', 'umask 022; bash "$1" -o "$2" "$3" && chmod +x "$2"', '_', fetchSh, out, `${base}/ok`],
{ encoding: 'utf8', env: Object.assign({}, process.env, { FETCH_SLEEPS: '0' }) });
assert.strictEqual(r.status, 0, r.stderr);
assert.strictEqual((fs.statSync(out).mode & 0o777).toString(8), '755');
@@ -101,7 +102,7 @@ test('a download is readable by everyone, as an AppImage needs (v12.69)', () =>
test('a download still follows a stricter umask (negative)', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fetch-'));
const out = path.join(dir, 'private');
const r = spawnSync('bash', ['-c', `umask 077; bash "${fetchSh}" -o "${out}" "${base}/ok"`],
const r = spawnSync('bash', ['-c', 'umask 077; bash "$1" -o "$2" "$3"', '_', fetchSh, out, `${base}/ok`],
{ encoding: 'utf8', env: Object.assign({}, process.env, { FETCH_SLEEPS: '0' }) });
assert.strictEqual(r.status, 0, r.stderr);
assert.strictEqual((fs.statSync(out).mode & 0o777).toString(8), '600');
+8 -3
View File
@@ -77,17 +77,22 @@ for (const sort of [null, '', 'abc', true, {}, [], 'Infinity', Number.NaN]) {
}
for (const [isOverTime, written] of [[false, false], ['false', false], [true, true], ['true', true]]) {
test(`card PUT writes isOverTime ${JSON.stringify(isOverTime)}`, async () => {
test(`card PUT stores isOverTime ${JSON.stringify(isOverTime)} in the schema's isOvertime field`, async () => {
const f = await fixture();
assert.equal((await f.put({ isOverTime })).code, 200);
assert.deepEqual(f.writes.map(w => w.modifier), [{ $set: { isOverTime: written } }]);
// The API spelling differs from the schema and UI. Checking only the
// request spelling allowed successful responses with no persisted change.
assert.deepEqual(f.writes, [{
selector: { _id: 'card', listId: 'list', boardId: 'board', archived: false },
modifier: { $set: { isOvertime: written } },
}]);
});
}
for (const isOverTime of [null, 0, 'yes', {}]) {
test(`card PUT refuses isOverTime ${JSON.stringify(isOverTime)} with 400`, async () => {
const f = await fixture();
assert.deepEqual(await f.put({ isOverTime }), { code: 400, data: { error: 'Invalid isOverTime' } });
assert.deepEqual(await f.put({ isOverTime, title: 'Renamed' }), { code: 400, data: { error: 'Invalid isOverTime' } });
assert.deepEqual(f.writes, []);
});
}
+9 -6
View File
@@ -57,15 +57,18 @@ test('negative: the 4 date triggers are distinct activityType keys, none shared'
// reuses actually exists and is not something this change invents.
test('the Cards.before.update timing-field hook logs a-receivedAt/a-startAt/a-dueAt/a-endAt', () => {
const block = serverCardsSrc.slice(
serverCardsSrc.indexOf("const timingaction = ['receivedAt', 'dueAt', 'startAt', 'endAt'];"),
);
const block = serverCardsSrc.slice(serverCardsSrc.indexOf('const change = timingChange(fieldNames, modifier);'));
const body = block.slice(0, block.indexOf('\n});') + 4);
assert.ok(/activityType = `a-\$\{action\}`;/.test(body));
assert.ok(/await Activities\.insertAsync\(/.test(body));
// Reads modifier.$set - only fires for a real SET, not for an $unset clear
// (unsetDue/unsetStart/unsetEnd/unsetReceived), matching "set or changed".
assert.ok(/modifier\.\$set\[action\]/.test(body));
// It read modifier.$set[action] to "only fire for a real SET", but a cleared
// date ($unset) went through it too - throwing, or writing timeValue:
// undefined. Now a set carries timeValue and a clear only timeOldValue
// (tests/cardTimingActivity.test.cjs), and "set or changed" still means a
// real value: the rules skip a clear before matching any trigger.
assert.ok(/\.\.\.\(change\.set \? \{ timeValue: change\.value \} : \{\}\)/.test(body));
assert.ok(!/modifier\.\$set\[action\]/.test(body), 'negative: no read of a $set that may not be there');
assert.ok(/if \(isTimingClear\(activity\)\) return \[\];/.test(fs.readFileSync(path.join(__dirname, '../server/rulesHelper.js'), 'utf8')));
});
// --- server/rulesHelper.js: the matcher works generically off TriggersDef --
+47
View File
@@ -956,6 +956,53 @@ await test('nonfinite-sort-repair: repairs every sorted collection (lists/swimla
assert.strictEqual(db._dump('checklistItems')[1].sort, 2);
});
// ── card-overtime-field (#6888) ──────────────────────────────────────────────
const overtimeStep = steps.find(s => s.name === 'card-overtime-field');
await test('card-overtime-field: a REST isOverTime becomes isOvertime, the shown one wins, the stray field goes', async () => {
const db = liveSortDb({ cards: [
{ _id: 'on', isOverTime: true },
{ _id: 'off', isOverTime: false },
{ _id: 'both', isOverTime: true, isOvertime: false },
{ _id: 'ui', isOvertime: true },
{ _id: 'plain' },
] });
assert.strictEqual(await overtimeStep.check(db), true);
const r = await overtimeStep.run(db);
assert.strictEqual(r.fixed, 2);
const byId = Object.fromEntries(db._dump('cards').map(c => [c._id, c]));
assert.deepStrictEqual(byId.on, { _id: 'on', isOvertime: true });
assert.deepStrictEqual(byId.off, { _id: 'off', isOvertime: false });
assert.deepStrictEqual(byId.both, { _id: 'both', isOvertime: false }, 'the value the UI showed is kept');
assert.deepStrictEqual(byId.ui, { _id: 'ui', isOvertime: true });
assert.deepStrictEqual(byId.plain, { _id: 'plain' });
assert.strictEqual(await overtimeStep.check(db), false, 'idempotent: nothing left after one pass');
});
await test('negative: card-overtime-field does not run on cards without the stray field', async () => {
const db = liveSortDb({ cards: [{ _id: 'ui', isOvertime: true }, { _id: 'plain' }] });
assert.strictEqual(await overtimeStep.check(db), false);
});
// Negative, tree-wide: nothing writes the request spelling into a card again.
await test('negative: no server code writes isOverTime into a card document', () => {
const offenders = [];
const walk = dir => {
for (const e of fs.readdirSync(path.join(repoRoot, dir), { withFileTypes: true })) {
if (['node_modules', '.meteor', 'tests', 'packages'].includes(e.name) || e.name.startsWith('_build') || e.name.startsWith('.')) continue;
const rel = path.join(dir, e.name);
if (e.isDirectory()) walk(rel);
else if (rel.endsWith('.js')) {
read(rel).split('\n').forEach((line, i) => {
if (/\$(set|setOnInsert)\s*:\s*\{[^}]*\bisOverTime\s*:/.test(line)) offenders.push(`${rel}:${i + 1}`);
});
}
}
};
for (const dir of ['server', 'models', 'client', 'imports']) walk(dir);
assert.deepStrictEqual(offenders, []);
});
await test('negative: nonfinite-sort-repair does not run on healthy finite sorts', async () => {
const db = liveSortDb({ cards: [{ _id: 'c1', sort: 0 }, { _id: 'c2', sort: 1e300 }, { _id: 'c3', sort: -1e300 }] });
assert.strictEqual(await sortStep.check(db), false);
+31 -6
View File
@@ -1,9 +1,11 @@
'use strict';
// CodeQL js/shell-command-injection-from-environment (alerts #549, #550): tests
// ran `bash -c` with a script that interpolated an absolute path built from
// __dirname, e.g. `source "${helper}"`. A checkout path with a quote, `$(` or a
// backtick would change the command. The path goes in through the environment
// instead (`source "$HELPER"`), so the script text is constant.
// CodeQL js/shell-command-injection-from-environment (alerts #549, #550, #601):
// tests ran `bash -c` with a script that interpolated an absolute path built
// from __dirname or the temporary directory, e.g. `source "${helper}"`,
// `node_bin=${JSON.stringify(prog)}` or `bash "${fetchSh}" -o "${out}"`. A path
// with a quote, `$(` or a backtick would change the command - JSON's quotes are
// not the shell's. The path goes in through the environment (`"$HELPER"`) or
// as an argument (`"$1"`), so the script text is constant.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
@@ -26,4 +28,27 @@ assert.ok(SHAPE.test('`\n source "${configPath}"\n`'));
assert.ok(!SHAPE.test('[\'-c\', \'source "$SOCKET_DENIED_HELPER"; socket_denied\']'));
assert.ok(!SHAPE.test('`${name}. ${detail}`'));
console.log('testShellPathsFromEnv: no test sources an interpolated path');
// #601: any value quoted into a `-c` script - "${...}" or ${JSON.stringify(...)}
// - is data pasted into code. A script built from the test's own snippets
// (${functions}, ${body}) is code, and stays allowed.
const DASH_C = /['"]-c['"],\s*`([^`]*)`/g;
const QUOTED_VALUE = /"\$\{|\$\{JSON\.stringify\(/;
function quotedValues(src) {
const found = [];
for (const m of src.matchAll(DASH_C)) if (QUOTED_VALUE.test(m[1])) found.push(m[1].slice(0, 80));
return found;
}
const quoted = fs.readdirSync(path.join(root, 'tests'))
.filter(name => /\.(c?js|mjs)$/.test(name) && name !== path.basename(__filename))
.flatMap(name => quotedValues(fs.readFileSync(path.join(root, 'tests', name), 'utf8')).map(s => `${name}: ${s}`))
.sort();
assert.deepEqual(quoted, [], 'pass the value as an argument ("$1") or in the environment ("$NAME")');
// Negative: the guard catches #601's shapes...
assert.deepEqual(quotedValues("spawnSync('bash', ['-c', `set -u; node_bin=${JSON.stringify(prog)}; npm`])").length, 1);
assert.deepEqual(quotedValues("spawnSync('bash', ['-c', `umask 022; bash \"${fetchSh}\" -o \"${out}\"`])").length, 1);
// ...and not the fixed forms, nor a script built from the test's own code.
assert.deepEqual(quotedValues("spawnSync('bash', ['-c', 'bash \"$1\" -o \"$2\"', '_', fetchSh, out])").length, 0);
assert.deepEqual(quotedValues("spawnSync('bash', ['-c', `set -u; node_bin=\"$NODE_BIN\"; ${body}`])").length, 0);
assert.deepEqual(quotedValues("spawnSync('bash', ['-c', `${functions}\ntool_repo_dir ${args}`])").length, 0);
console.log('testShellPathsFromEnv: no test sources an interpolated path, nor quotes a value into a -c script');