engine.io (under socket.io) had a denial of service through a protocol
revision mismatch, reachable by anyone who can open a connection to the
server. brace-expansion (pulled in by archiver's glob matching) had
denial-of-service bugs on crafted patterns. Both updates stay inside the
version ranges Haven already allows; nothing else in the lockfile changed.
* Group DM E2E: crypto core, key distribution, and tests
Implements the design in docs/group-dm-e2e-plan.md far enough to prove the
properties hold. No UI yet — this is the layer everything else sits on.
public/js/e2e-group.js holds the primitives, deliberately free of storage,
sockets and DOM so the same file runs in the browser and under node --test.
Confidentiality comes from an AES-256 epoch key wrapped per member over the
pairwise ECDH secret that already exists; authenticity from an ECDSA P-256
signature over a digest binding channel, epoch, sender and the previous
message. Verification happens before decryption and a rejected message
returns null plaintext, so a forgery cannot be rendered by mistake.
The signature is what makes one shared group key safe: every member can
decrypt, only a key holder can author.
src/socketHandlers/groupE2E.js distributes wrapped blobs the server cannot
open, while enforcing what no client can check for itself — only a member
may publish an epoch, epochs are strictly sequential, a member reads only
their own blob, and an epoch must cover exactly the current membership.
That last rule stops a member publishing an epoch that silently omits
someone, locking them out of a conversation the UI still shows them in.
Two suites, 28 tests, runnable with npm test. The crypto suite is written
as attacks rather than happy paths: forging as another member, re-attributing
a genuine message, tampering, stripping the signature, replaying into another
channel or epoch, reading an epoch you were not given, and unwrapping a key
not wrapped for you. It also asserts scrollback still decrypts across several
rotations, which is the regression a per-message ratchet would have caused,
and that concurrent sends sharing a prev do not false-alarm.
Confirmed the suite is load-bearing by disabling the signature check: exactly
the five attack tests fail and nothing else.
* Group DMs invite instead of auto-joining, and rewraps require a request.
Addresses review on #5499: drop leftover setup.js, consent plus roster
dedup on start-group-dm, and pin rewraps to a prior request plus the
recipient's TOFU public key.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
npm audit was reporting 12, and two of the high ones sit directly under the
chat transport rather than off in tooling: ws had an uninitialised memory
disclosure and a memory-exhaustion denial of service, and socket.io-parser
could be made to exhaust memory through binary attachments. Every Haven message
travels through both.
ws 8.18.3 to 8.21.3, socket.io-parser 4.2.5 to 4.2.7, axios 1.13.5 to 1.20.0,
qs 6.14.2 to 6.15.3, body-parser 1.20.4 to 1.20.6. Audit reports none left.
package.json is untouched. Every fix landed inside the semver ranges already
declared, so this is a lockfile change and nothing was upgraded across a major.
Ran with --ignore-scripts so no native rebuild was attempted; better-sqlite3's
compiled binary is the same one as before and still loads.
Verified after: express and socket.io both load in a fresh process, no unmet
dependencies, the suite passes 91 of 91, and a server boots and answers a
socket.io handshake.
package-lock.json still carried 3.50.0 in both of its version fields. The
release steps bump package.json and the lockfile was left behind, so npm
rewrites those two lines on the next install and leaves the tree looking dirty
for no reason.
Each Haven server brings its own Discord bot, since Discord caps unverified
applications at 100 guilds. Reading uses a gateway WebSocket, because Discord
has no outgoing webhook for new messages. Writing uses a per-pairing Discord
channel webhook rather than the bot user, which is the only way a relayed
message can carry the Haven author's own name and avatar.
Pairings live in ferry_links and act as the allowlist, not just a routing
table: a member can only reach Discord channels an admin paired with the Haven
channel they are standing in. Each pairing picks its own direction (two way, or
either one way) and its own outgoing mode (mirror everything, or only messages
addressed with the "=>" prefix). Sending needs the new use_ferry permission.
The trigger is "=>" and not ">>" because a leading ">" is Haven's blockquote
marker, so an unresolved target would render as a quote instead of visibly
failing. Resolved targets are stripped from the stored message and recorded in
messages.ferry_target so history shows a destination badge instead of raw
routing syntax.
Loop prevention drops the gateway echo of our own webhook ids and, by default,
every other bot. Mentions are blocked by default so one member cannot ping a
whole Discord server through the bridge. DMs are off by default, outbound only,
and stamped with the author's name, since a bot cannot impersonate in a DM.
ws becomes a direct dependency; it was only reaching us through socket.io.
Reported by birdycrazy: the layout "seems to completely remove the ability
to join or create channels", and the Encryption menu entry does nothing.
Both are real. Join and Create were not merely collapsed — they could not
be reopened at all.
_collapseJoinCreate() added its own `braid-collapsed` class to the section
alongside the stock `.collapsed` class on the body, and the stylesheet hid
`.braid-collapsed .collapsible-section-body` with !important. Nothing ever
removed that class, so the stock toggle went on flipping `.collapsed`
underneath while the body stayed hidden. Traced live: clicking the header
moves the stock class exactly as it should, computed display stays `none`,
and dropping `braid-collapsed` by hand returns it to `block`. That left an
admin unable to create a channel and everyone unable to join one for as
long as the layout was on.
Visibility now comes only from the stock class, which the collapse helper
still sets, so the sections keep opening collapsed and the header reopens
them.
The Encryption entry proxied a click to #e2e-menu-btn, whose handler only
toggles #e2e-dropdown. That dropdown lives inside #e2e-menu-wrapper, which
the app sets to display:none outside a DM — so in a normal channel the
entry appeared and did nothing whatsoever. Inside a DM it fared no better:
this layout hides the header chrome the dropdown anchors to, and the click
that opened it kept bubbling to the document handler that closes it again.
Proxy the three actions instead — Verify encryption, Recover keys, Reset
keys — each of which opens its own modal and does not depend on the
dropdown. The menu is built once, so they are marked and resolved when it
opens: shown where encryption applies, hidden everywhere else.
#5471 correctly widened the range from <24 to <25, but the source machine and
the Haven servers here run Node 26.4, so the launcher would still print a
"very new Node" warning and package.json would still declare the running
runtime unsupported.
The functional better-sqlite3 load check that #5471 added is the real gate, so
the version number only needs to stop being wrong. Ceiling is now <27 and the
launcher's guidance points at Node 26.
* Fail loudly when the server port cannot be bound, and stop refusing Node 24
A failed bind (EADDRINUSE/EACCES/EADDRNOTAVAIL) surfaces as an async
'error' event on the HTTP server. With no listener there, it fell
through to the global uncaughtException keep-alive, which logged it to
crash.log and kept a process alive that never got its socket: no
banner, no exit code, and Start Haven.bat's netstat readiness probe
was satisfied by the stale port-holder — so launches died silently.
Reproduced on 3.39.0 by double-launching; %APPDATA%\Haven\crash.log
shows the same swallowed EADDRINUSE in the wild.
- server.js: bind failures on the main listener are now fatal and
explain themselves (which port, why, what to do) before exit 1.
Other server errors keep the existing keep-alive behaviour.
- server.js: the HTTP->HTTPS redirect listener (port+1) warns and
continues instead of feeding the same silent uncaught path.
- Start Haven.bat: the hard Node >=24 refusal is gone — Node 24 is
current LTS and better-sqlite3 12.10.0 ships prebuilds for it
(verified working). The honest gate is a functional
require('better-sqlite3') check after npm install; >=25 warns.
- package.json/lock: engines widened to >=18 <25 (lock version also
synced from stale 3.31.2).
* Add Braid themes (dark/light) and the Braid Layout plugin
Ports the Braid look from the Amni-Haven mobile app to the web and
desktop clients, as pure opt-ins riding the existing rails — no core
file changes:
- themes/braid.theme.css / braid-light.theme.css: the mobile BRAID /
BRAID_LIGHT palettes (mint on slate / deep green on white) with the
1.6x-rounder shape language. Unpublished by default; admins opt in
via Settings -> Custom Themes, exactly like any file theme.
- plugins/BraidLayout.plugin.js: the 'vastly simplified' two-edge
layout — folds the server rail (including manage/sync) into a strip
atop the sidebar, tucks header extras into a kebab menu, and uses
the full width for messages. Every stock feature stays reachable:
the kebab proxies search/pins/gallery/channel code/encryption/
settings and adds People, Soundboard, Status bar, and app-download
entries for the chrome it hides. A separate variable-driven shape
layer applies the Braid format (radii, blur, focus rings) on top of
ANY active theme, so all built-in themes reskin the Braid layout
with their own palette. start()/stop() fully applies and fully
restores (DOM, styles, listeners, localStorage); the
MutationObserver is childList-only and only mutates
styles/attributes, so it cannot observe its own writes.
All sizes are rem so the interface Zoom slider scales Braid chrome
together with the rest of the UI. prefers-reduced-motion disables the
motion layer outright.
* Braid Layout: mobile-parity chat cards and condensed chrome
Feedback round: the flat message list didn't carry the mobile Braid
shape language, and leftover strips still read as four-edge chrome.
- Merged run cards (desktop twin of Haven-Mobile's braidForm): a run of
same-author posts becomes one bordered card on .message-body, dashed
seams between posts, avatar and hover-timestamp in the gutter, own
messages tinted with the accent. Run position comes from O(n)
data-braid-run marking in the existing observer batch — NOT
:has(+ .message-compact), which makes Chromium re-run invalidation
per sibling insert and sent a 600-message channel load quadratic.
Channel list runs are carded the same way.
- Condensed chrome: the topic bar folds into the header band as a
quiet subtitle (no second border strip), header drops to 3rem,
sidebar footer and composer tighten, media buttons shrink to 2rem.
- All gutter math is rem (.625 pad + 2.25 avatar + .75 gap = 3.625rem)
so cards and compacts stay aligned under interface zoom.
* Braid Layout v1.2: Mod Mode interop, themed icons, modern settings, subtler seams
- Mod Mode (the layout editor) is now fully functional with Braid on:
the layer suspends itself while body.mod-mode-on is set — gates come
off and the server rail unfolds so every draggable section and panel
handle is real — and re-applies when editing ends. A saved custom
section order wins over Braid's default join/create-at-bottom
ordering (the order rules load only when no haven-layout is stored).
'Edit layout' joins the kebab menu.
- The bottom-left emoji buttons (theme/activities/members/settings/
donate/mute/deafen) become currentColor line icons so they follow the
palette like the rest of the chrome; originals restore on disable.
- Modern settings: nav rail becomes grouped pills with an accent-tinted
active state, each settings section becomes a rounded card (the
inline border-top hairlines between sections retire), header gets the
blur band + pill tabs treatment.
- The seam between same-author posts shrinks to a centered dotted
hairline (min(7rem,45%)) instead of a full-width dashed rule.
* Braid Layout v1.3: true author-run merging, hamburger menu, text sliders, hex loader, logo glow
- Message merging now chains on AUTHOR: consecutive posts by the same
user merge into one card even when the app rendered them as separate
full .message elements (persona changes, break_chain, and system
notices still split). Continuing messages hide their avatar/header
and surface their own timestamp in the gutter on hover
(data-time-short). The visible full-width divider is gone — bodies
inside a run no longer draw internal horizontal borders, so the only
separator is the small centered dotted seam.
- One animated hamburger (morphs to an X) replaces both the header
kebab and the bottom-left icon bar: grouped Channel / View / App menu
with staggered item animation. The menu mounts on <body> — the
blurred header is a containing block with overflow:hidden that clips
even position:fixed descendants. Every absorbed feature keeps its
door (themes, activities, members, settings, donate, apps, Mod Mode).
- Braid text size: two continuous sliders in Settings (chat text
80-160%, interface text 85-140%) layered over rem zoom, live-applied
via CSS vars and persisted per user.
- The brand hexagon gets an accent glow that breathes (and a slow spin
on hover); a hexagonal loading indicator appears on channel switches
and reskins the setup wizard's circular spinner. All animations
respect prefers-reduced-motion.
- Braid / Braid Light themes: type & color enforcement pass — the font
stack and palette land over style.css's per-element declarations
(fonts on all controls, selection/placeholder/link/timestamp colors),
and the own-message tint calms from 12% to 8% accent.
* Braid Layout v1.4: hamburger moves home to the bottom-left, settings nav gets real pills and themed icons
- The hamburger relocates to the bottom-left corner where the icon bar
it absorbed used to live — the popups it opens (themes, activities)
anchor down there, so the menu and its children now share a corner
instead of spawning across the screen from the button. It is styled
as a Haven tile (bordered, bg-tertiary, accent hover) with a Menu
label, and the grouped menu rises from the bottom-left with the same
morph-to-X and stagger.
- Settings nav highlights now encompass their rows: stock uses a
hanging-indent hack (padding-left 1.625rem + text-indent -16px) so
wrapped labels clear the leading emoji, which pulled the first line
left out of the highlight pill. Items are now full-width flex pills
with text-indent reset — and every leading emoji (nav rows and the
User/Admin tabs) becomes a currentColor line glyph keyed by
data-target, same treatment as the old bottom-left buttons, with a
Haven hexagon fallback for unmapped rows. Originals restore on
disable.
Restoring a full backup that included files failed at ~30GB, and even a
paired-down ~15GB backup died after about 30 seconds with "failed to fetch".
Three separate limits were in the way:
- server.requestTimeout was 30s, so any upload that took longer than that was
aborted mid-transfer (this is the ~30s "failed to fetch"). Raised to 1h; the
15s headersTimeout still guards against slowloris, and the restore handler
now also clears its own socket inactivity timeout for the long upload +
staging phase.
- The restore upload was capped at 4GB by multer, so a 15GB+ body was rejected
part-way and reset the connection. Raised well past realistic backup sizes.
- Restore read the whole uploaded zip into memory via adm-zip (getData() per
entry), which OOM'd on a large archive. It now streams every entry to disk
with yauzl, so memory stays flat regardless of backup size.
Verified with an archiver -> yauzl round-trip: a full backup restores
byte-for-byte, a partial backup is rejected (400), and a crafted ../ entry
cannot write outside the staging directory. adm-zip stays as a dep for the
Discord importer.
Backups that include files failed and crashed the server once the uploads
directory got large (reporter hit it around 30GB). buildBackupBuffer read
every file into an adm-zip object and then called zip.toBuffer(), holding
the entire archive in RAM at once. That both exceeded Node's Buffer size
limit (RangeError) and exhausted the heap (OOM), so the process died.
Structure-only backups (~300KB) were unaffected, matching the report.
Replace the in-memory build with a streaming archiver(zip) pipeline that
writes each entry straight to a file on disk, keeping memory bounded no
matter how big the uploads are:
- buildBackupBuffer -> buildBackupFile(include, outPath): streams to disk,
returns a Promise. The temp VACUUM'd DB clone is unlinked only after the
archive stream closes.
- Download endpoint builds to a temp file then res.download()s it (no more
res.send of a multi-GB buffer), cleaning the temp file afterward.
- Auto-backup streams to a .partial file then renames it into place, so a
crash mid-write can't leave a truncated .zip. Prune/list now skip dotfiles.
Output is a standard zip; the existing restore path reads it back
byte-for-byte (verified via SHA-256 round-trip through the adm-zip reader).
- Replaced custom MIT-NC license with GNU Affero General Public License v3
- Haven is now truly open source under an OSI-approved license
- AGPL copyleft ensures forks deployed as network services must release source
- Updated all license references across README, package.json, docs, website, and TOS
- Fixed bot/webhook pinned messages showing raw username instead of [BOT] prefix