• v2026.9.20 e7f99b74c7

    v2026.9.20
    All checks were successful
    CI / release-scripts (pull_request) Successful in 9s
    security-scan / SBOM (trivy) (pull_request) Successful in 11s
    security-scan / Filesystem scan (trivy fs) (pull_request) Successful in 17s
    security-scan / Python SAST (bandit) (pull_request) Successful in 21s
    security-scan / JS SCA (npm audit) (pull_request) Successful in 35s
    security-scan / Python SCA (pip-audit) (pull_request) Successful in 40s
    CI / release-scripts (push) Successful in 10s
    security-scan / SBOM (trivy) (push) Successful in 13s
    security-scan / Filesystem scan (trivy fs) (push) Successful in 19s
    security-scan / Python SAST (bandit) (push) Successful in 23s
    test-build / build (backend) (pull_request) Successful in 1m10s
    security-scan / JS SCA (npm audit) (push) Successful in 38s
    CI / frontend-test (pull_request) Successful in 1m18s
    test-build / build (frontend) (pull_request) Successful in 1m21s
    security-scan / Python SCA (pip-audit) (push) Successful in 46s
    test-build / build (pull_request) Successful in 0s
    CI / pre-commit (pull_request) Successful in 1m2s
    publish-and-stage / build (backend) (push) Successful in 1m29s
    publish-and-stage / build (frontend) (push) Successful in 1m33s
    CI / pre-commit (push) Successful in 1m33s
    publish-and-stage / build (push) Successful in 0s
    CI / frontend-test (push) Successful in 1m42s
    CI / backend-test (pull_request) Successful in 3m54s
    publish-and-stage / deploy-staging (push) Successful in 1m52s
    CI / backend-test (push) Successful in 4m9s
    CI / e2e (pull_request) Successful in 5m41s
    CI / e2e (push) Successful in 5m3s
    release-and-deploy / release-scripts (push) Successful in 8s
    release-and-deploy / release (push) Successful in 18s
    release-and-deploy / frontend-test (push) Successful in 58s
    release-and-deploy / build (backend) (push) Successful in 1m13s
    release-and-deploy / pre-commit (push) Successful in 1m16s
    release-and-deploy / build (frontend) (push) Successful in 1m23s
    release-and-deploy / build (push) Successful in 0s
    release-and-deploy / backend-test (push) Successful in 3m41s
    release-and-deploy / e2e (push) Successful in 5m31s
    release-and-deploy / ci (push) Successful in 0s
    release-and-deploy / deploy (push) Successful in 1m32s
    Stable

    robbertbos released this 2026-09-20 19:13:53 +00:00 | 93 commits to main since this release

    Note when rolling out

    • This release logs every user out. The production session cookie is
      renamed to __Host-waggle_session and the old name is no longer read, so
      every existing session ends at deploy.
    • WAGGLE_COOKIE_NAME is now WAGGLE_COOKIE_NAME_BASE; the old variable is
      ignored, so move a value set there.
    • Not rolling-safe. The migration renames the track value inbox to
      todo; during a rolling deploy the old pod still writes inbox into a
      column that no longer accepts it. Scale the backend to zero replicas, let
      the migration run, then start the new image. There is no in-place rollback.

    Pagina: Postvak

    • / is now the Postvak: your unread Mattermost channels, one row per channel,
      the threads inside it counted in. Channels you only follow along with go to
      "Meelezen" (/meelezen, g m), between "Nog te doen" and "Leeslijst".
    • A row expands into one chronological stream, opening at your first unread
      message; a thread is one darker block with its replies indented. "Toon
      eerdere berichten" pages back through read history up to 200, and further
      unread messages load as you reach the end.
    • Reading marks nothing read. "Tot hier gelezen" covers only what passed the
      viewport, bounds the channel and each thread on their own, says what it
      leaves ("3 blijven ongelezen") and can be undone. There is no "alles
      markeren als gelezen".
    • Hover or focus a message for its action bar. "Antwoord" opens the card
      composer inline - formatting, bijlagen, @-mentions, thread or channel,
      scheduling - and the emoji button reacts straight away. Neither makes a card:
      a reply shows up in "Uitgaande post" and nowhere else.
    • The message menu puts it on a list ("Doe nu", "Backlog", "Bubble up" with a
      time, or the leeslijst) or opens it in Mattermost. Listing it saves it in
      Mattermost too, and reuses the card it already had, including a finished one.
    • By keyboard: j/k walk the rows and everything an open row shows, r
      answers the focused message, m opens it in Mattermost, Escape closes the
      reply field. →/← step into and out of a row too long to walk. The
      shortcut list is regrouped by what has focus rather than by page.
    • A Meelezen channel you are named in also appears in the Postvak while the
      mention is unread, and stays in Meelezen too.
    • Every channel lands in the Postvak; one you muted in Mattermost starts in
      Meelezen. The row's menu ("Berichten afleveren in") moves it either way, and
      resetting hands the choice back to your Mattermost sidebar.
    • A row names its team beside the channel, with the team's logo or its
      initials: every Mattermost team has a Town Square, so the channel name alone
      identifies nothing.
    • With more than one linked server both pages get a tab bar: "Alle servers",
      which groups rows per server, plus one tab per server with its unread count,
      remembered per browser. "Groepeer Postvak en Meelezen per team"
      (Instellingen, off by default) groups by team instead.
    • A message is drawn by the same component a card uses, attachments and all,
      and one you wrote yourself no longer counts as unread.
    • Without a linked Mattermost account the Postvak and Meelezen now show the
      "Welkom bij Waggle" block instead of a one-line hint: / is the Postvak, so
      it is the first thing a new account sees. Its text no longer says "bewaarde
      berichten", which described only Nog te doen.

    Pagina: Nog te doen

    • The Inbox is now called "Nog te doen" and lives at /nog-te-doen.

    Menu

    • The "Menu" button stays in the header row and scrolls away with it at every
      width, without a frame: nothing floats over the page anymore.

    Fixed

    • A card showed its team's URL slug ("digitale-overheid") instead of its name
      ("Digitale Overheid").
    • A scheduled message no longer fails for good when its Mattermost server
      cannot be reached at send time. A refused base_url (the SSRF guard also
      refuses a name that does not resolve) or an unreadable token now takes the
      backoff-retry path instead of settling as "geen Mattermost-account
      gekoppeld"; an unreadable token used to leave the row stuck in "sending"
      until a restart failed it. A send that does run out of retries now names
      which of the two it was, instead of always blaming an unreachable server.
    • A permalink to a DM or group message pointed at /pl/{id}, which is not a
      route - Mattermost read "pl" as a team name and showed its team-not-found
      page. It goes through /_redirect/pl/{id} now.
    • The composer toolbar no longer drops the emoji button onto a second row. Its
      overflow breakpoints were narrower than the buttons actually measure, so the
      Postvak's inline composer (600px of toolbar, 610px of buttons) wrapped instead
      of collapsing a group; the numbers are now measured, per toolbar variant.
    • Two Postvak rows from different Mattermost accounts could share a kind-id
      key, which gave Vue duplicate keys and lit up two rows at once during the
      tour. A row's identity now leads with its account id.

    Security

    • A live session is re-checked at Keycloak every five minutes
      (WAGGLE_OIDC_REVALIDATE_INTERVAL_SECONDS), so a disabled or deleted user
      loses access without waiting for the cookie to go idle - Keycloak sends no
      back-channel logout for that. Login now asks for offline_access. A Keycloak
      outage is tolerated for a day (WAGGLE_OIDC_REVALIDATE_GRACE_SECONDS) and
      never ends a session.
    • Failed security events reach the audit trail: a refused login, the outcome of
      a back-channel logout, and use of waggle rotate-keys.
    • Refusals a caller can repeat at will - a rejected Origin, a rate-limit hit, a
      stale session cookie, an SSRF-blocked URL - are logged but never audited: a
      flood must not write a row per request.
    • audit_events.ip_hash is now an HMAC under its own HKDF domain
      (waggle-iphash-v1) instead of a hash over the raw secret. Existing rows keep
      the old digest and stop correlating; the raw addresses to re-hash them were
      never stored.
    • Audit retention is now per kind of record: use of a system utility is kept
      180 days (WAGGLE_AUDIT_UTILITY_RETENTION_DAYS, BIO2 8.18.02), everything
      else stays on 90. docs/security.md records the reasoning and lists which
      logs we generate.
    • The SSRF guard on a Mattermost server URL checks every resolved address of
      both families, so an IPv6 literal (https://[::1]/) no longer slips past.
      A hostname that does not resolve is refused, and carrier-grade NAT, NAT64,
      6to4 and Teredo are named explicitly.
    • An hourly session_purge loop deletes oidc_sessions rows idle longer than
      the session-cookie window, so an abandoned login no longer keeps its
      encrypted Keycloak tokens at rest.
    • The session and PKCE-state cookies carry the __Host- prefix in production,
      so a sibling on the shared parent domain can no longer plant one of the same
      name. Development keeps the bare name, which plain HTTP forces.
    • The channel and team name cache is keyed per account, not only per server: a
      shared key answered a private channel's name to any caller that reached the
      cache. The account part is an HMAC under a new waggle-cachekey-v1 domain.
    • Outbound URLs no longer reach the pod log: httpx and httpcore log at
      WARNING, and an HTTP error is sanitised before it is logged and redacted
      in the traceback.
    • docs/security.md records what the client address is worth: the ZAD router
      passes a client-supplied X-Forwarded-For through unchanged, so the audit
      origin is an indication rather than evidence and the pre-login rate limiter's
      IP fallback can be rotated around. Raised with the platform team.
    • A failed login no longer writes an audit row per attempt: the throttle keys
      on the reason instead of the client-supplied origin, which was rotatable. The
      row that does get written now carries how many occurrences it stands for and
      how many distinct origins they came from, per process. The addresses are not
      recorded: the caller picks them, so one could name an uninvolved third party.
    • POST /api/auth/backchannel-logout refuses an implausible token (wrong
      issuer, missing events/jti, a nonce, a stale iat, an oversized body)
      and a replayed jti before it verifies any signature.

    Under the hood

    • Seven more ruff rulesets on the backend: PLE, PGH (no bare noqa or
      type: ignore), T10, RSE, ISC004, PERF and FURB. Fifteen findings,
      one of them real - a .replace("Z", "+00:00") that fromisoformat handles
      by itself - and no rule suppressed to get there.
    • Fixed: the JS SCA (npm audit) job went red on about 2% of all runs, because
      npm does not retry the POST its audit endpoint needs. It retries now, unless
      npm actually returned a report - a finding still fails on the spot.
    • The ruff pre-commit hooks run uv run --project backend ruff instead of a
      pinned mirror that sat eight minor releases behind pyproject.toml - CI
      lints with the version just lint uses. Renovate's pre-commit manager is
      opted into, so the remaining hook pins stop freezing.
    • A released ## [vX] changelog section is frozen: CI compares every tagged
      section against git show <tag>:CHANGELOG.md and fails on a difference. The
      Forgejo release is generated at tag time, so an edit afterwards left the
      published notes and the file disagreeing with no warning anywhere.
    • Fixed: the release-scripts CI job failed whenever a second run checked out
      the workspace mid-test. The section tests read a snapshot of CHANGELOG.md
      instead of the file under them, and the retry that papered over it is gone.
    • Scheduling a message is 120 an hour rather than 30, on the card route and the
      Postvak one alike, and a thread's messages 120 a minute: expanding one row
      spends a request per thread in it.
    • Renovate keeps marked behind dashboard approval while
      markedjs/marked#4098 is
      open: every new release stays listed there, without a weekly PR that our own
      regression test is only going to fail.
    • CI runs on node 24, like the frontend image. Renovate dropped the setup-node
      bump silently: its digest pin now covers actions only, not node-version.
    • The session cookie is an OpenAPI security scheme (SessionCookie), so a
      generated client can see that authentication exists. A test asserts which
      operations are public.
    • The axe-core e2e gate now runs the full default ruleset (WCAG 2.0/2.1 A and
      AA plus best practice) on every route, instead of three named rules. Measured
      clean on all of them; the narrower set was hiding what it did not check.
    • GET /api/mm/team-image/{team_id} proxies a team logo with the stored token,
      like the avatar route, but only for a team you are in. A team without a logo
      answers 404, never a placeholder: the caller needs the error to fall back to
      initials.
    • POST /api/mm/reactions and DELETE /api/mm/reactions/{post_id}/{emoji}
      react to a message without a card: account-scoped, with the same
      membership-before-lookup check as the rest of the Postvak.
    • POST /api/mm/reply and POST /api/mm/reply/scheduled send a Postvak reply
      now or later, without a card. The thread root travels on the outgoing row in a
      new nullable root_id, which is what a scheduled reply needs at fire time; a
      card row still resolves its root from the card. See WDR-0011.
    • New /api/mm/unread endpoints back the Postvak: one row per unread channel,
      carrying the threads inside it, their counts and its sidebar-seeded priority,
      the unread window of a channel or thread, and the two bounded read-cursor
      writes. A channel id in a
      request is checked against the caller's own memberships before any lookup;
      rendering never marks anything read.
    • The Postvak listing reuses the composer's 300-second per-account cache for the
      team list, sidebar categories and group names: a poll costs 10 Mattermost
      calls instead of 36 for a five-team account, which keeps a shared MM rate
      limit out of reach.
    • POST /api/cards/from-post promotes a message. It saves the post in
      Mattermost before inserting the card, because an active card outside the
      saved list is auto-completed by the next sync run, and unsaves again when the
      insert fails. On conflict the existing card is revived rather than left
      alone: a no-op would strand another saved post on every repeat promote.
    • A Mattermost card leaving "Voltooid" is saved in Mattermost again, which
      closes a gap that predates the Postvak: completing a card unsaves it, so
      dragging it back out left Waggle showing work whose source side was gone.
    • PUT and DELETE /api/mm/unread/triage store and clear a per-channel triage
      override in the new triage_rules table. Absence of a row is meaningful, so a
      rule is only ever written where the user disagreed with the sidebar;
      resolution reads channel rule, team rule, sidebar seed, feed. The rules
      travel in the export bundle.
    • Postvak rows for a DM or group conversation carry the participants' user ids,
      so the page can draw their avatars. They come from the lookups the row's name
      already needed, at no extra Mattermost call.

    Container images