Releases: MSK-Scripts/mskanban
v0.6.0-beta
Completes the entire 25-point "promised-vs-built" gap backlog (#27–#51) on top
of v0.5.0-beta. Auth hardening (per-user login backoff + per-user API
rate-limit, opt-in HaveIBeenPwned check, ReDoS guard, DB-enforced append-only
audit log); transactional security email (account-lock alert + opt-out
login-notifications, fail-open SMTP); sanitized Markdown rendering; the
user-picker custom field; board templates; three extra importers (GitHub
Projects / Jira CSV / Wekan); attachments end-to-end E2EE; GDPR export +
crypto-shred erasure; a metadata-only admin area; a public REST API via
Personal Access Tokens; a Mind-Map board view; automated WCAG 2.1 A/AA axe
scans; table-view virtual scrolling; live CRDT sync for checklists +
comments (ADR 0020); and offline-first — precache cold-start + a
card-scoped offline write outbox (ADR 0021). New ADRs 0016–0021. Still
zero-knowledge end to end: the server only ever sees opaque enc_* envelopes.
Security
- Transactional security emails: account-lock alert + login notification
(#32, ADR 0018). A new
nodemailer/SMTP mailer (src/lib/mail/) sends two security emails: an
account-lock alert whenever repeated failed logins trip the lockout
(always sent; no IP, since the attempts may be the attacker's), and an
opt-out login notification on a sign-in from a new device/IP (detected
viaSession.ipHashwith no prior session). The notification carries
IP + User-Agent + time (all HTML-escaped against a hostile UA) and is
governed byUser.loginNotifications(default ON), toggleable on the
account page (GET/PATCH /api/account/preferences). The mailer is
fail-open: with noSMTP_HOSTconfigured it is a silent no-op, and a
transport error never propagates into the auth flow — a stock instance
sends nothing and never crashes. No SaaS, no telemetry, no third-party
IP leakage; geolocation is intentionally out of scope (§16.8). Email
bodies carry only metadata, never card content or key material. Migration
20260530000001_login_notifications. - Opt-in HaveIBeenPwned compromised-password check (#29,
ADR 0017).
Registration and password-change can reject passwords found in known
breaches via a client-side k-anonymity lookup — the password never
leaves the device; only the first 5 hex chars of its SHA-1 digest are
sent toapi.pwnedpasswords.com. Gated behind
NEXT_PUBLIC_FEATURE_HIBP_CHECK(default OFF), so a stock or
air-gapped instance still makes zero third-party calls (§16.8). The CSP
connect-srcopens the range API only when the flag is on, and the
lookup fails open (a HIBP outage never blocks the user). - Per-user API rate limit on every authenticated request (#28).
requireSession()— the choke point every authenticated route already
passes through — now applies a per-user sliding window
(RATE_LIMIT_API_PER_MIN, default raised to 600/min ≈ 10 req/s, well
above any human burst) as an abuse backstop on top of RBAC + the per-IP
auth limits. It fails open if Redis is unreachable, so a cache blip
can never lock users out of the app. The previously-unused
RATE_LIMIT_AUTH_PER_MIN/RATE_LIMIT_API_PER_MINenv vars are now
actually read (login per-IP budget + the per-user limiter respectively). - Per-user exponential login backoff is now wired (#27). The
backoffMs()curve (1, 2, 4, 8, 16, 30, 60 s → 5-min cap) existed but
was dead code; the login path only had a per-IP fixed window + the hard
account lock. Failed logins (wrong password or wrong second factor)
now arm a Redis-clocked cool-off keyed by the user, so it follows a
targeted account across rotating IPs; the next attempt is rejected with
429 Retry-Afteruntil it expires, and a successful login clears it. - AuditLog is now append-only at the database level (#31). A
BEFORE DELETEtrigger (migration20260529000000_auditlog_append_only)
rejects any attempt to delete an audit row, so history can't be erased
even outside the app. (Single-statement trigger so Prisma's migration
runner applies it cleanly; UPDATEs stay convention-enforced because the
only legitimate one is the GDPRuserId → NULLde-association.) - Two previously-dead audit actions are now emitted (#31):
TWOFA_VERIFY_TOTP/TWOFA_VERIFY_WEBAUTHNon a successful second
factor at login, andRECOVERY_USEwhen a valid recovery proof unseals
the challenge. (EXPORT_REQUEST/ACCOUNT_DELETEremain declared for
the planned DSGVO endpoints, #46.) - ReDoS guard (#30). An ESLint
no-restricted-syntaxrule now bans
new RegExp()/RegExp()so a dynamic, user-influenced pattern can't
be introduced without a conscious eslint-disable +safe-regexreview.
The codebase has no dynamic regex today (all patterns are static
literals); this keeps it that way.
⚠️ Migration required. Runpnpm prisma migrate deploy(prod) /
pnpm prisma migrate dev(local) for the new append-only trigger.
Removed
- 5 unused dependencies + dead MSW skeleton (docs-truth audit, Phase A). A
deep "promised vs. actually built" audit found five runtime/dev dependencies
that were installed but never imported anywhere insrc/:
@tanstack/react-query(server-state is plainfetch+ Zustand),
bullmq(background jobs are in-processsetIntervalschedulers, not a
queue),socket.io+socket.io-client(the WS relay uses nativews), and
iron-session(sessions are the self-written cookie+DB scheme). All five were
removed. The never-wired MSW test skeleton (tests/msw/, a/api/healthstub
from Phase 2 that no test orsetupFilesever imported) was deleted andmsw
dropped from devDependencies. No behavioural change — pure cleanup.
Changed
- Documentation now matches the code (audit Phase A). Corrected
CLAUDE.md
§2 tech-stack (self-written auth, not Auth.js/NextAuth; nativews, not
socket.io; in-process schedulers, not BullMQ; libsodiumcrypto_pwhash, not
argon2-browser), completed the OpenAPI spec (nowv0.6.0, ~70 routes —
added the previously-undocumented Milestones, Automation, and
account/auth-settings routes; the old "56 routes" figure was wrong), clarified
that/api/auth/2fa/verifyis not a standalone endpoint (verification runs
inside/api/auth/login), and made several overstated claims honest in
docs/threat-model.md+CLAUDE.md: exponential login backoff is prepared but
not yet wired,safe-regex/HIBP/login-notification-emails are planned (not
built), Markdown is currently shown as escaped plaintext (no render → no
DOMPurify sink yet), offline mode is read-only + per-description drafts (no
Workbox / write sync-queue), live cursors sit behind a default-off flag, and
style-srcstill uses'unsafe-inline'(onlyscript-srcis nonce-based).
No code behaviour changed; this is the truth-in-docs pass that precedes the
feature backlog.
Added
- Offline-first: cold-start precache + offline write outbox (#38,
ADR 0021). The hand-rolled
service worker now precaches the build's hashed JS/CSS via a manifest
generated duringpnpm build(scripts/gen-precache-manifest.mjs→
public/precache-manifest.json, versioned by the Next build id), so an
installed PWA cold-starts fully offline (cache-first for immutable
/_next/static/, network-first for navigations,/api/never cached). No
Workbox dependency. Plus a card-scoped write outbox: while offline, the
idempotent card mutations (move/reorder, metadata + archive, checklist-item
toggle) are queued in IndexedDB (src/lib/idb/outbox.ts) and replayed in
order on reconnect (src/lib/offline/sync.ts, driven by the new
OfflineSyncheader pill). The server stays authority — a permanent
4xx drops the queued change (last-write-wins) and surfaces a toast; a
transient error keeps the tail for the next attempt. Because only
naturally-idempotent, no-return-value ops are queued, no server route
changed and no idempotency key is needed. The outbox stores ciphertext +
metadata only (request bodies are alreadyenc_*envelopes) and is wiped
on logout. Shared IndexedDB handle extracted tosrc/lib/idb/db.ts(DB v2).
Tests:tests/unit/outbox.test.ts(+7). Deferred (documented): offline
create (card/comment) + comment-edit need client-generated ids /
temp-id reconciliation — a follow-up increment. - Live CRDT sync for checklists + comments (#39,
ADR 0005 +
ADR 0020). With a
card open, checklist and comment changes from other members now appear
live (no SSE-tick + full refetch) and concurrent structural edits
(two people adding items, toggling different items, posting comments at
once) merge conflict-free. Implemented as a live overlay: three
id-keyedY.Maps ride the card's existingY.Docover the same
encrypted WS relay the description uses (no server/relay change), while
REST stays the durable authority and the only side-effect trigger —
so RBAC, the append-only audit log, automation emitters
(comment_added/append_checklist/post_comment), @-mentions,
notifications and the DSGVO export are all untouched. Comment deletes use
a tombstone (no resurrection; authorship is security-relevant); checklist
structure drops keys. Map values are plaintext only inside theY.Doc
(AEAD-encrypted on the wire, wiped from IndexedDB on logout — exactly as
the description draft already works); the server still sees only opaque
envelopes. Newsrc/lib/realtime/card-collab.ts(pure view-merge
builders +CardCollabcontroller, unit-tested incl. a two-peer sync
case); the card drawer r...
v0.5.0-beta
Closes the four headline features that were documented but never actually
built — account recovery, workspace member management, WebAuthn /
passkeys, and public read-only boards — and lands a two-pass
security/correctness audit that fixed 20 bugs (two of them critical
user-facing breakages on reload, plus an SSRF, a cross-tenant IDOR, and a
zero-knowledge logout leak). The whole repository was also reformatted with
Prettier.
⚠️ Migration required. Public boards add threeBoardcolumns —
runpnpm prisma migrate deploy(prod) /pnpm prisma migrate dev(local).
Migration20260528000001_public_boardsis committed.
Added
- Account recovery flow (ADR 0012). The 24-word recovery phrase generated at
sign-up was previously write-only — a forgotten password meant permanent
lockout. There is now a real, zero-knowledge recovery:POST /api/auth/recovery/initreturns the encrypted key material plus a
proof-of-possession challenge sealed to the account's X25519 public key;
only a holder of the phrase can walkphrase → recovery key → User Symmetric Key → private keyto unseal it.POST /api/auth/recoveryverifies that proof
(single-use, 5-min, bound to the user id), then rotates the password-derived
material (authHash+kdfSalt+encSymmetricKey), revokes all sessions,
and logs the user straight in. The keypair and recovery backup survive intact,
so every sealed Workspace Key keeps working. New(auth)/recoverpage; covered
bytests/unit/recovery-flow.test.ts.⚠️ Crypto-relevant. - Workspace member management. Workspaces were effectively single-user — there
was no way to add anyone. Now:POST /api/workspaces/{wsId}/members(with a
Workspace Key the inviter sealed for the invitee's public key),
POST .../members/lookup(resolve a public key by email, ADMIN+),
PATCH /.../members/{userId}(change role) andDELETE /.../members/{userId}
(remove, or leave). RBAC + last-owner protection enforced server-side; new
MembersPanelon the workspace page. Note: removal does not rotate the
Workspace Key (the standard shared-key E2EE limitation — a removed member who
already cached the key keeps what they had; their server access is revoked). - WebAuthn / passkeys as a second factor (ADR 0013). Because the password
derives the Master Key it can't be replaced, so passkeys complement (or stand
in for) TOTP rather than enabling passwordless login. Enrol/manage passkeys on
/2fa; at login the server returnsTWO_FACTOR_REQUIREDwith the available
methods and the form offers a TOTP field and/or a "Use a passkey" button.
Routes under/api/auth/webauthn/*; verification via@simplewebauthn.
⚠️ RP config (WEBAUTHN_RP_ID/_ORIGIN) must match the deployed domain. - Public read-only boards (ADR 0014). Share a board (e.g. a public roadmap)
via a link without breaking zero-knowledge: enabling sharing (ADMIN+) mints a
randompublicSlug; the BoardKey rides only in the link's URL fragment
(/p/<slug>#key=…), which browsers never send to the server. The
unauthenticatedGET /api/public/boards/{slug}serves only the board structure
(columns, non-archived cards, labels, encrypted title) — never members,
comments, assignees, checklists or custom fields. New viewer at/p/[slug],
share controls on the board,BOARD_PUBLIC_ENABLE/_DISABLEaudit events.
Changed
- Login second-factor handshake.
apiErrornow forwardsAppError.meta, and
the login route returns401 { error.meta = { reason: "TWO_FACTOR_REQUIRED", methods } }(no failed-attempt increment for the "no factor yet" step) so the
client knows which factors to offer. A wrong factor still counts toward the
lockout threshold. - Board export/import round-trips
Column.isDone— the "done column" flag is
no longer lost on export → import (analytics keep working on imported boards). envschema gainsNEXT_PUBLIC_WS_URLandAUTOMATION_DUE_WORKER_DISABLED
(previously read raw fromprocess.env), andWEBHOOK_TICK_TOKENnow requires
≥ 20 characters when set.- Repository-wide Prettier pass — formatted ~75 pre-existing non-conforming
files;pnpm format:checkis now clean across the tree.
Fixed
- App dead on reload (two critical AAD bugs). Boards stored every
workspace/board/column/card envelope bound to:new(the planned post-create
rebind to:<id>was never implemented), but several read paths decrypted with
:<id>only and aborted the whole list/board on the first failure. The
workspaces list, the board list, and — worst — every board (columns are
always affected) went blank after a reload. All decrypt sites now try
:new→:<id>with per-item resilience. - SSRF in the webhook dispatcher (security). Outbound delivery dialled the
target URL with no runtime guard and followed redirects; the create-time check
was resolution-free (DNS-rebinding) and missed alternate IP encodings. New
src/lib/security/ssrf.tsresolves every A/AAAA record and rejects
private/reserved addresses at send time, and the fetch now uses
redirect: "manual". Covered bytests/unit/ssrf.test.ts. - Cross-tenant label IDOR (security).
attach/detachonly checked write
access on the label's board, not that the card belonged to the same board — a
member could pin labels onto cards in other workspaces. Now verifies same-board. - Decrypted content survived logout (zero-knowledge leak). Sign-out was a
plain server form POST that only cleared the cookie; the client never wiped the
in-memory key bundle, the IndexedDB plaintext cache, the Yjs drafts, or the
Master Key session envelope. Logout is now a client action that calls
reset()(which alsomkSession.wipe()s). - Webhook double-delivery under multi-worker deployments — the reservation
CAS changed no predicate field, so two workers could both claim a row. Now
CAS-es onnextAttemptAt. - Same-column downward drag landed one slot too high (the dragged card wasn't
excluded before computing neighbours). - Date off-by-one in negative-UTC timezones — the card-drawer inputs, the
calendar buckets, and the kanban/table due-date labels mixed local and UTC
date parts; all now consistently UTC (matching how dates are stored). - SSE Redis subscriber leak when a client aborted during stream start.
- TOTP failures didn't count toward account lockout (only the password path
did); a wrong second factor now increments + can lock. - Comment edit/delete lacked a board-membership check (a removed user with a
stale session could still edit their old comments). - Redis
ensureRedisconnect race — concurrent first callers could double
connect(); now memoised. - Condition-less automation rules couldn't be retyped (a carried-forward
nulltrigger_metawas rejected by the envelope validator). - Board-presence broadcast was up to ~10 s late (no re-broadcast after the
server'sjoinedack); the card-cursor provider stopped marking itself joined
before the server authorised the room.
Security
- Account recovery uses a proof-of-possession challenge so the server never
authorises a password change without proof the caller recovered the account
(ADR 0012). - SSRF guard, cross-tenant label IDOR, and the logout zero-knowledge leak above
are all security-relevant fixes.
Full Changelog: v0.4.0-beta...v0.5.0-beta
v0.4.0-beta
Ships live cursors in the card description editor (ADR 0011 — behind
NEXT_PUBLIC_FEATURE_LIVE_CURSORS, end-to-end encrypted), plus two
operator-facing fixes uncovered while debugging a real production deploy:
the Apache vHost example no longer clobbers Next.js's nonce CSP (which had
silently bricked hydration), and the 2FA enrolment screen no longer
dead-ends. Also adds the brand logo and the missing PWA icon set.
this version.
Added
- Brand logo + PWA icons. The auth screens and the app header now
showpublic/logo.pnginstead of a text-only wordmark, and the
missing PWA icon set (icons/icon-192.png,icon-512.png,
icon-maskable-512.png,apple-touch-icon.png) is generated from the
logo — fixing theGET /icons/icon-192.png 404from the web manifest
and giving installs a real icon.apple-touch-iconwired into the
root metadata;favicon.icowas already referenced. - Live cursors in the card description (ADR 0011, part 2 of 2) —
withNEXT_PUBLIC_FEATURE_LIVE_CURSORS="true", the description field
becomes a CRDT-aware CodeMirror 6 editor (y-codemirror.next)
bound to the same cardY.Text, rendering remote collaborators'
carets + selections in their presence colour. Markdown-source model
unchanged — Save / export / encryption paths are untouched; the
editor is dynamically imported (no SSR) so its bundle never hits the
initial page load, and it falls back to the plain textarea when the
flag is off (default). Consumes the encrypted cursor channel from
part 1. New deps:@codemirror/{state,view,commands,lang-markdown}+
y-codemirror.next. Live cursors are now feature-complete behind
the flag (requires a WS relay redeploy from this version). - Live-cursor transport (ADR 0011, part 1 of 2) — groundwork for
Google-Docs-style cursors in the card description. The WS relay now
forwards a newawarenessmessage type on the existingcard:<id>
room (additive — pre-cursor clients never send it, and it's relayed
as opaque ciphertext like document updates). New client
CardCursorProvider(src/lib/realtime/card-cursors.ts) hosts a Yjs
Awarenessover the card's actualY.Doc, encrypting each frame
under the BoardKey bound tocard:<id>:cursor. It leaves the working
CardWsProviderdoc-sync path untouched (separate message type,
ignoresupdateframes).⚠️ Crypto-relevant. The awareness payload
is PII-free ({ userId, color }only — guarded by a unit test); the
CodeMirror editor that consumes this lands in part 2, behind
NEXT_PUBLIC_FEATURE_LIVE_CURSORS(default off). Operators must
redeploy the WS service from this version before the feature can be
switched on.
Changed
- Release images for pre-release (beta) tags are now
linux/amd64
only (#72). Stable tags still publish multi-archamd64 + arm64;
dropping the emulated arm64 build for betas roughly halves release-CI
time. Pull a stable tag if you need an arm64 image.
Fixed
- 2FA enrolment dead-end — after enabling two-factor auth the user
was stranded on the confirmation screen with no way forward. It now
auto-redirects to/workspacesafter a short pause and shows a
"Continue to your workspaces" link as a manual fallback (works even
if the redirect is blocked). - Apache CSP example clobbered Next.js's nonce CSP (
apache/mskanban.conf.example)
— the vHost setContent-Security-Policyitself, which overrides the
per-request, nonce-based CSP thatsrc/proxy.tsemits for inline
hydration scripts. Result on a real deploy: every inline hydration
<script>is blocked, the page never hydrates, and the whole app is
non-interactive (login/register/boards silently dead, no server-side
log because the request never reaches Node).setifemptydoesn't fix
it either — behindmod_proxyit adds a second, nonce-less CSP and
the browser enforces the intersection. The example now sets no CSP
in Apache (Next.js owns it; every HTML route is covered by the
proxy.tsmatcher) and documents the trap, plus a troubleshooting
entry indocs/deployment/auto-deploy.md. Discovered debugging a
production registration outage.
What's Changed
- ci(release): build arm64 only for stable tags by @Musiker15 in #72
- feat(realtime): live-cursor transport (ADR 0011, part 1/2) by @Musiker15 in #73
- feat(editor): CodeMirror live-cursor description editor (ADR 0011, 2/2) by @Musiker15 in #74
- fix(apache): don't set CSP in the vHost — Next.js owns the nonce CSP by @Musiker15 in #75
- fix(ui): 2FA redirect dead-end + brand logo/favicon/PWA icons by @Musiker15 in #76
- Release v0.4.0-beta by @Musiker15 in #77
Full Changelog: v0.3.0-beta...v0.4.0-beta
v0.3.0-beta
Completes the Automation Engine (all five triggers + four actions, end-to-end zero-knowledge), ships a production hot-fix for the WebSocket relay that had been returning 503 since the first auto-deploy, and clears the remaining v0.2.0-beta clean-up backlog (Column.isDone, Card.startAt export/import round-trip). Also lands ADR 0011 (proposed) for live cursors in the description editor — implementation gated on its acceptance.
Added
- Automation trigger
card_due_reached— the last v1 trigger from
ADR 0010, completing the automation engine. A new in-process
per-minute scheduler (src/lib/automation/due-scheduler.ts, started
frominstrumentation.tslike the webhook worker — no separate
process or systemd unit) scans for cards whosedueAtpassed within
a 7-day look-back on boards that have an active due-rule, claims each
card once via RedisSET NX(30-day TTL), and publishes a
content-freeAUTOMATION_DUE_FIREDboard tick. The next online
client builds thecard_due_reachedevent from its current card
state and runs the rule through the normal evaluator/executor —
the server never decryptsenc_rule, it only reads the already-plain
dueAt. Due rules can be scoped by column or milestone. Disable the
scheduler withAUTOMATION_DUE_WORKER_DISABLED=1. New evaluator +
eventToken tests (43 automation cases total). - Automation action
post_comment— rules can now post a comment as
a side-effect (e.g. "When moved to Done → postClosed on {{date}}").
The body is a template ({{date}}/{{datetime}}tokens, expanded
client-side withsplit/join— no regex/ReDoS) that lives encrypted
inenc_rule; the executor encrypts the rendered comment under the
BoardKey (bound tocomment:new) so it's E2EE exactly like a human
comment. Because it's observable + non-idempotent, it ships with the
Redis SETNX idempotency claim from ADR 0010: the comment POST accepts
an optionalidempotencyKey(<ruleId>:<eventToken>), the server
SETNX automation:claim:<key>with a 60 s TTL, and a lost race returns
{ deduped: true }instead of a duplicate. Redis hiccup → fail open
(post the comment). New rule-builder UI: action picker + comment
textarea.⚠️ Crypto-relevant — see PR. New tests: executor
expandTemplate(5), DSLpost_commentvalidation (3), evaluator
eventToken+ ruleId threading. - Automation triggers
card_label_added+comment_addedare now
live — both existed in the DSL since v1 but their client emitters
were deferred. The card drawer now firescard_label_addedwhen a
label is attached (not on removal) andcomment_addedafter a
comment is successfully posted; both route through the existing
emitAutomationTrigger→evaluateAll→executeAllpath. The rule
builder gained an "Only when this label is added" condition selector
forcard_label_added(scoped to alabelId). No-cascade invariant
documented: automation actions hit the REST API directly and never
re-enter the emitting path, soset_labelcan't loop back into a
card_label_addedrule (ADR 0010 §Risks). New evaluator tests
(13 cases total, +3). - Explicit "Done" columns (
Column.isDone) — columns can now be
pinned as "done" from a ✓ toggle in the column header (with aDONE
badge), replacing the analytics "last column by position = done"
heuristic. Any number of columns can be flagged. New boards seed the
default "Done" column with the flag automatically. Plain
server-visible boolean (likeposition/wipLimit) — no E2EE impact.
Boards created before the flag existed fall back to the legacy
heuristic, so nothing breaks without a backfill. New migration
20260528000000_column_is_done, pureselectDoneColumnIdshelper in
src/lib/analytics/done-columns.ts(5 unit tests). - Card
startdate in foreign importers — the Trello importer now
maps a card'sstartfield, and the CSV importer recognises a
start/startAt/start datecolumn, both feeding the native
startAt. Brings imported boards in line with the Timeline/Gantt
view, which keys range bars offstartAt. docs/deployment/mskanban-ws.service— production systemd unit
for the WebSocket relay (Yjs sync + board presence). Mirrors the
hardening profile ofmskanban.service:User=mskanban,
EnvironmentFile=/opt/mskanban/.env, the full
Protect*/Restrict*/SystemCallFilterset,IPAddressDeny=any
withlocalhost-only allow, zeroCapabilityBoundingSet,
IPAddressAllow=localhost.ExecStart=tsx src/server/ws/index.ts
listens onWS_PORT(default 3001), matching the existing Apache
mod_proxy_wstunnelrule for/api/ws.deploy.shwas already
ready for it — step 8 restartsmskanban-ws.serviceif and only
if the unit is installed, so existing deployments roll forward by
copying the file +systemctl enable --now mskanban-ws.
Fixed
Card.startAtlost on board export/import — the native JSON
export omittedstartAtentirely and the importer never restored it,
so a board round-trip silently dropped every card's start date (and
with it the Gantt range bars).ExportedCardnow carriesstartAt,
exportBoardpopulates it, the Markdown export renders a
start → duerange (or a start-only / due-only marker), and the
importer PATCHesstartAtalongsidedueAt+archived. Pre-#49
snapshots without the field import cleanly (startAt ?? null). New
unit tests:board-export(4 cases) +foreign-import(3 cases).- Production WS-relay outage —
/api/wsreturned 503 since the
firstv0.2.0-betaauto-deploy because no process listened on
127.0.0.1:3001. Effect: cross-user Yjs sync + board presence (the
"real-time collaboration" USP) were dead; single-user editing kept
working because Yjs writes also go through the HTTP PATCH path.
Root cause:v0.2.0-betashipped the deploy framework + WS-restart
hook but not the systemd unit itself — listed as a deferred item.
Now both are in the tree; installing the unit on the server is a
one-liner (seedocs/deployment/auto-deploy.md§4). - Docs drift in
docs/deployment/auto-deploy.md— the "WS unit is
on the to-do list" footnote in §4 is replaced by the actual
install + verify recipe, including anss -ltnpport check and a
curlupgrade-handshake probe that distinguishes the relay being
down (503) from a normal "missing ticket" reject (400).
What's Changed
- chore(deps): bump docker/setup-buildx-action from 3 to 4 by @dependabot[bot] in #58
- chore(deps): bump actions/attest-build-provenance from 1 to 4 by @dependabot[bot] in #59
- chore(deps): bump webfactory/ssh-agent from 0.9.0 to 0.10.0 by @dependabot[bot] in #60
- chore(deps): bump docker/metadata-action from 5 to 6 by @dependabot[bot] in #61
- fix(ci): use default token for sponsors workflow checkout by @Musiker15 in #62
- Fix/update sponsors workflow by @Musiker15 in #63
- fix(deploy): ship mskanban-ws.service systemd unit by @Musiker15 in #64
- fix(export): round-trip Card.startAt through export/import by @Musiker15 in #65
- feat(analytics): explicit Column.isDone flag by @Musiker15 in #66
- feat(automation): wire card_label_added + comment_added triggers by @Musiker15 in #67
- feat(automation): post_comment action + Redis SETNX idempotency by @Musiker15 in #68
- feat(automation): card_due_reached trigger + in-process scheduler by @Musiker15 in #69
- docs(adr): 0011 live cursors in description editor (proposed) by @Musiker15 in #70
- docs(changelog): cut v0.3.0-beta by @Musiker15 in #71
Full Changelog: v0.2.0-beta...v0.3.0-beta
v0.2.0-beta
The first sweep of post-beta work. Highlights: Milestones + Burn-Down + Timeline (Gantt) for time-/scope-based planning; board-level presence via Yjs awareness over the existing E2EE WS relay; Automation Engine v1 (declarative {when, do} rules, zero-knowledge, ADR 0010); MSKanban docs site under docu.msk-scripts.de/ecosystem/mskanban; auto-deploy from GitHub Actions to the production server with a ForceCommand-locked SSH key.
Added
- Milestones (#39 + #40 + #41 + #48) — group cards by deliverable with an optional date window. Server sees the date range + board association (needed for filtering / charts); name + description live encrypted under the BoardKey. New
Milestonemodel, REST routes, board-level manager modal, card-drawer selector, per-card badge, and a Burn-Down chart per milestone in the Analytics view. - Timeline / Gantt view (#49) — fifth board tab next to Board / Calendar / Table / Analytics. Cards with
startAt+dueAtrender as range bars; only-dueAtcards as diamonds; missing-data cards are counted in the footer hint. Day / Week / Month zoom, vertical "today" line, arrow-key pan ++/-zoom (WCAG keyboard alternative). Pure SVG renderer. - Card
startAteditor (#49) — the field has existed in theCardmodel since Phase 3 but was never UI-editable.card-drawer.tsxnow exposes a start-date input alongside the existing due-date input; the Gantt bars depend on it. - Board-level presence (#50) — Yjs
Awarenessover the existing E2EE WebSocket relay. Avatar-stack of online users in the board header (max 5 + "+N"); per-card coloured dots when other users have the card drawer open. Awareness payload is{userId, color, viewing?}only — no email, no titles, no description text. Server only sees opaque ciphertext bound toboard:<id>:awareness. Colour is a deterministic HSL hash ofuserId. NewBoardPresenceProvider(src/lib/realtime/awareness.ts),useBoardPresenceReact hook, and a new room kindboard:<id>on the relay with workspace-member authorisation. - Automation Engine v1 (#52, implements ADR 0010) — declarative
{when, do}rules per board, fully zero-knowledge. Rule bodies live encrypted inenc_rule; a small plaintext trigger envelope (trigger_type+trigger_meta) is mirrored in two new columns so a future BullMQ scheduler can route ticks for time-based triggers without breaking E2EE. The envelope is validated against a strict whitelist on every write so the metadata field cannot become a back channel for plaintext leakage. v1 wired triggers:card_created,card_moved; v1 actions:set_label,assign_member,move_to_column(all idempotent at the repo layer — Redis SETNX claim deferred together withpost_comment). - MSKanban documentation site — published as the third ecosystem entry on the existing Docusaurus instance (docu.msk-scripts.de/ecosystem/mskanban). Seven pages: overview, installation (Docker + bare-metal Apache), getting-started, features, REST API, privacy & security (full key hierarchy + threat model), FAQ.
- Auto-deploy from GitHub Actions to the production server (#54, #55, #56) —
workflow_runtriggers on CI success, SSHes via a ForceCommand-locked action key, runsscripts/deploy.shon the server (self-updating, idempotent, hard-fail on build / migration / health-check errors, optional WS-relay restart, deploy tags for rollback). Strict known_hosts check viaDEPLOY_HOST_FINGERPRINTsecret; noStrictHostKeyChecking=noanywhere. Full setup walkthrough indocs/deployment/auto-deploy.md. - ADRs — 0010 Automation Engine added; previous ADRs index updated.
- New unit-test suites —
automation-dsl(14 cases, #52),automation-evaluator(10 cases, #52),user-color(6 cases, #50),totp(17 cases, #35). 137 tests total at release time.
Changed
KanbanCardview-model gainsstartAt: string \| null(#49) — propagated through all five construction sites inboard-client.tsx(initial load, SSE refetch, create-from-scratch, create-from-template, drawer reconcile). The previously-hardcodedstartAt: nullin the<CardDrawer>wiring is nowopenCard.startAt.- Repo-URL cleanup (#53) — every
github.com/musiker15/mskanbanandghcr.io/musiker15/mskanbanreference inREADME.md,CHANGELOG.md,SECURITY.md,CONTRIBUTING.md,CLAUDE.md,docs/public-launch.md,docs/deployment/mskanban.servicerewritten to the canonicalMSK-Scripts/mskanban. Therelease.ymlworkflow already pushed containers toghcr.io/${{ github.repository_owner }}(=MSK-Scripts) so the broken README badges were the only visible symptom. Also normalised the Code-of-Conduct contact e-mail to@msk-scripts.de. - README post-beta refresh (#53) — "five views per board" (Timeline added), "WebAuthn / Passkeys planned" → both shipped, Milestones / Burn-Down / Timeline / Presence / Automation added to the feature highlights, broken
docs/crypto/link replaced with ADR 0003 + threat-model, link to the new docs site. - 21 Dependabot bumps consolidated into the v0.1.0-beta → v0.2.0-beta window. First sweep (low-risk, single 2026-05-25 batch):
@types/node 22→25,prettier-plugin-tailwindcss 0.6→0.8,eslint-config-prettier 9→10,vitest 3→4,@hookform/resolvers 3→5,@dnd-kit/sortable 9→10,jsdom 25→29. Second sweep (afternoon): 14 PRs across 5 GitHub Actions majors (docker/setup-qemu-action 3→4,actions/cache 4→5,actions/download-artifact 4→8,github/codeql-action 3→4,actions/upload-artifact 4→7) and 9 npm bumps (bullmq 5.77.2→5.77.3,lint-staged 15→17,pino-pretty 11→13,typescript 5.9→6.0,tailwind-merge 2→3,pino 9→10,lucide-react 0.460→1.16,zod 3→4,y-websocket 2→3). All shipped CI-green incl. Playwright E2E. @vitest/coverage-v83 → 4 — peer-match for the vitest 4 bump.
Removed
- Phase-3 plaintext plumbing —
src/lib/encoding/plaintext-blob.ts,tests/unit/plaintext-blob.test.ts, andscripts/migrate-phase3-to-e2ee.tsare gone. ADR 0007 is "executed": every data-bearing API and every read path now goes through the Phase-4v1.<nonce>.<ct>envelope (28 call sites across workspace, board, card and card-drawer clients). TheencodeBlob/decodeBlobhelpers had zero consumers left. @otplib/coreremoved as a direct dependency — was unused insrc/. Stays in the tree as a transitive ofotplib, so no functional change.
Fixed
- Flaky
tests/unit/totp.test.tsclock-drift cases — original wall-clock arithmetic (Math.floor(Date.now() / 1000) - 31) jumped two 30-s steps back instead of one whenDate.now()/1000 mod 30was 0 – 1, flaking ~3 % of CI runs. Re-implemented withvi.useFakeTimers()locked to Unix second 1000 (mid-step, well above otplib v13's non-negative-epoch guardrail). 10× local stress loop now passes cleanly. Hit Dependabot PRs #32 and #33 before the fix. - Inline
# commenton env values in.env.example(#56) —ATTACHMENT_MAX_BYTES="26214400" # 25 MiBandLOG_LEVEL="info" # ...were copy-pasted into production.envfiles wheresystemd EnvironmentFile=does not strip trailing comments, breaking the app's zod env-schema at startup (Number("26214400 # 25 MB")= NaN). Both moved to comment-above-value. Discovered live during the first production auto-deploy.
Security
otplib12 → 13 — auth-critical API rewrite. Theauthenticatornamespace is gone; v13 ships top-level functional exports with the defaultverifynow async.src/lib/auth/totp.tsdeliberately usesverifySyncinstead of the asyncverify— in this path a missedawaitwould always evaluate the returned Promise as truthy and silently bypass 2FA.epochTolerance: 30 sreproduces the oldwindow: 1. ExistingUser.totpSecretrows remain valid; v13's new 16-byte minimum-secret guardrail does not reject the 20-byte secrets ourgenerateSecret()produces.@simplewebauthn/{browser,server}11 → 13 — kept in lock-step with the otplib bump even though @simplewebauthn is not yet used insrc/(reserved for the upcoming WebAuthn phase). Avoids future split-version surprises.
Tooling
.github/dependabot.yml— thecryptogroup (libsodium*,argon2-browser,@simplewebauthn/*,otplib) is now restricted toupdate-types: [minor, patch]. Major bumps in any auth-critical package now arrive as individual PRs so they can be reviewed againstsrc/lib/auth/*in isolation..gitattributes—*.sh+*.serviceforced totext eol=lf. Without this, Windows-side git's default LF→CRLF on checkout would silently break the shebang ofscripts/deploy.shon the Linux server with a cryptic^M: command not found.
Deferred (not landed in this sweep)
eslint9 → 10 — blocked on upstreameslint-plugin-react(pulled in viaeslint-config-next) which still uses the ESLint 9 context API removed in 10.@vitejs/plugin-react5 → 6 — needs Vite 8 as a direct dependency; we only have Vite 6 transitively today.- Automation triggers
card_label_added+comment_added— exist in the DSL but their emitters need card-drawer wiring; separate PR. - Automation
post_commentaction +card_due_reachedtrigger — both need the Redis SETNX idempotency claim (post_comment) and the per-minute BullMQ scheduler (card_due_reached) from ADR 0010 §"Conflict resolution". - Live cursors in the card description editor — needs swapping the plain
<textarea>for a CRDT-backed editor (TipTap / CodeMirror) that exposes cursor positions as observable state. Separate change, separate ADR. board-export.tsround-trippingstartAt— the field is now edita...
v0.1.0-beta
Full Changelog: https://github.com/MSK-Scripts/mskanban/commits/v0.1.0-beta