Plumspace Download
Under the hood

One server, one database, every client.

Plumspace is a single Rust process, a PostgreSQL database and a folder of files. The browser, the desktop app and both phone apps draw the same web client. This page explains how the pieces fit, measured on the code as it stands on 5 October 2026, version 0.0.7.

1server process; no queue, no cache, no second service
148HTTP routes, beside 20 socket commands and 32 events
30tables in PostgreSQL 18, from 44 migrations
837Rust tests against a real Postgres, and about 1,850 TypeScript tests
39.6 MBthe server image to pull, web client included
620 KBstart-up script budget, enforced at every commit

Overview

Three kinds of client, one process, one database.

Every client speaks to the server over HTTPS and one WebSocket. The server keeps the live sockets in memory, writes to PostgreSQL through a single crate, stores bytes through a storage port, and sends push notifications to Apple, Google and browsers itself. The update feed and automation tools such as n8n sit beside it, not inside it.

System overview: browser, desktop and phone apps connect through a reverse proxy to one server process, which uses PostgreSQL, a file store, push services, a call relay and n8n. Update feed signed lists and installers checked, then installed CLIENTS Browser the React web app Desktop app Electron: Mac, Windows Phone app WebView: Android, iPhone HTTPS /ws Reverse proxy TLS, and limits per address Call relay TURN, optional plum-server: one Rust process WebSocket /ws the hub keeps sockets per person REST /api/v1 each block owns its routes Files and previews through the plum-storage port Push senders APNs, FCM and Web Push, direct Hooks, bots, apps signed, and paced per account Clocks due work, send later, reminders Socket and REST call the same block functions. PostgreSQL 18 every statement in plum-store File store disk, volume or S3 Push services Apple, Google, browsers n8n, extensions hooks in and out a notification reaches the device with the message's text already inside
The whole system. Arrows with two heads carry traffic both ways; dashed lines are optional or out of band.

Four crates, one direction

The server is a Cargo workspace of four crates, and dependencies only point one way. plum-server holds no SQL at all, so the day the database changes shape, one crate answers for it. Lines of Rust under each src/, 5 October 2026:

CrateWhat it may know about
plum-coreNothing. Types and the protocol, shared with every client. 4,354 lines.
plum-storeplum-core and SQL. Every SQL statement in the product lives here, the development reset included. 13,087 lines.
plum-storageplum-core. Where bytes go, one backend per place. 2,228 lines.
plum-serverAll three: axum, the socket, permissions, push, and the command-line tools. 30,427 lines.

The stack

Few dependencies, each one chosen on purpose.

The web client's only runtime dependencies are React, React DOM and an emoji data set. Cryptography comes from crates the server already ships, so a feature such as two-step sign-in added no new crypto library and no new licence family.

PartBuilt with
ServerRust (2024 edition), tokio, axum 0.8 with WebSocket and multipart, sqlx 0.9 on rustls, Argon2id for passwords, aws-lc-rs for signing, the image crate for previews. Release builds are stripped, with thin LTO and one codegen unit.
DatabasePostgreSQL 18 with the unaccent and pg_trgm extensions. Keys are UUIDv7, every time is an integer of milliseconds.
Web clientReact 19 and TypeScript, built by Vite 7, with no UI framework. The client logic is a package of its own, @plumspace/protocol.
DesktopElectron 42 around the same web client, packaged with electron-builder; its own updater.
PhonesExpo and React Native 0.86 as a native shell around a WebView, with four native modules: push, audio routing, saving files, and updates.
TypeSource Sans 3 with its Vietnamese subset, and Noto Color Emoji, both under the SIL Open Font License and served from the same origin.

Two doors, one function

REST and the socket call the very same code.

Everything a person can do arrives through one of two doors: 146 paths under /api/v1, or 20 commands on the WebSocket. The socket handler parses a frame and calls the same block function the REST handler calls, so a behaviour cannot be right on one door and wrong on the other.

The server's code is organised by subject: one file, or one folder, per block (messages, channels, files, calls, tasks, apps, export and so on). A block declares its own routes, and three layers wrap every route, including ones written later: a tracing span, the owner's switches (a part switched off is refused by the server, not only hidden), and an app's scopes.

A request, end to end

  • Who is asking is answered in one place. It answers a person, nobody, or trouble; a database failure is kept apart from "nobody" on purpose, because a client signs out on a 401 and a database blip should not sign anybody out.
  • The block decides whether this person may do this, in its own words.
  • One statement runs in plum-store.
  • The result fans out to whoever needs telling, through two functions: to every member of a channel, sender included, or to every device of one person.

Two kinds of work leave the async runtime, because they burn a core rather than wait on one: password hashing (Argon2id, measured at 13.6 ms per verify) and picture decoding. A test runs eight sign-ins on a single-threaded runtime and requires an unrelated request to answer within 100 ms, so the hashing can never drift back onto the runtime unnoticed.

A message's path

Queued, carried, checked, stored once, then told.

The composer hands the text and any file ids to the client library, which keeps an outbox. The socket carries the command; the server checks it, writes it in one transaction, acknowledges it, and tells everybody who should know, each in the frame meant for them.

Sequence of sending a message: composer, client and outbox, server, PostgreSQL, members' devices and phones. Composer the web page Client, outbox protocol pkg Server send.rs PostgreSQL plum-store Members every open device Phones asleep or away Files first: POST /api/v1/files gives their ids send(text, file ids) message.send {cid} over the WebSocket rules, switches, post policy, pace one transaction the row, and total + 1 ack {cid, id} message.new message.new, the same frame rings: true where it interrupts read.changed, views.changed each person's own counts push: FCM, APNs, Web Push, the text inside then outgoing hooks; a link preview follows as message.changed
One send. The sender's own device receives the same message.new as everybody else, so the optimistic copy can be checked against it.

The protocol is a contract

Version 1 was frozen on 18 September 2026 and only ever gains: a new event, field or endpoint may be added, because a client that has never heard of it ignores it. Renaming or removing anything means version 2. Every frame has the same four fields:

{ "v": 1, "t": "message.send", "cid": "01J8...", "d": { "channel_id": "...", "text": "...", "file_ids": [] } }
  • A resend never duplicates. Every command that creates something carries a client-made cid; a cid already handled returns the earlier result.
  • One send is one message. Five pictures picked together are one message carrying five files, never five messages.
  • Unread is a subtraction, never a count. A channel keeps its total; a member keeps where they read to. Nothing runs COUNT(*) over the messages to find unread.
  • The text is plain on the wire. The server reads the marks (bold, italic, strikethrough, code, quotes, lists, links, mentions) into blocks, and every client draws the blocks it is handed, so no two clients read one message two ways. A golden file holds the Rust and TypeScript readers to one answer.

Real time

Sockets kept per person, not per channel.

The hub is a map of open sockets keyed by person. "Tell everyone in this channel" can be done from either shape; "tell every device this person is signed in on" cannot be done from a channel-keyed map, and that second one is what takes the unread badge off the phone the moment you read on the laptop.

  • Catching up is the client's job. The handshake sends the client's last message id in each channel; the server answers with what was missed, or a refetch flag when it is too much to push through one frame.
  • Two keep-alives, for two reasons. The client sends ping and expects pong, which proves both applications are alive; the server sends a WebSocket Ping frame every 25 seconds, under the 30 to 60 seconds an idle flow survives on a phone network.
  • Presence is one answer for all of a person's devices: online while any is in use, away when every one is idle, offline once the last closes.
  • The counts above the rooms follow the socket. Activity, Threads and Kept counts arrive as views.changed, so a message no longer costs every client a request.
  • Three clocks run in the process for what must happen with nobody asking: due work every minute, messages sent later every five seconds, reminders every fifteen. Each claims its rows with UPDATE ... RETURNING, so a restart mid-pass cannot send anything twice.

Storage

PostgreSQL for the record, a readable tree for the bytes.

Thirty tables hold everything that is not a file. Primary keys are UUIDv7, which already sort by time, and paging is keyset paging on them, never OFFSET. Nothing is removed with DELETE: a message gets a deletion time, so the trail keeps its record and sync learns what disappeared instead of finding a silent gap. The server holds a pool of eight connections.

Files go through a port of seven operations in plum-storage, and three places sit behind it: a folder on the server, a mounted volume, or any S3-compatible bucket (AWS, Cloudflare R2, Backblaze B2, Wasabi, MinIO and others). Moving between them is a copy and one setting, because the database stores a key, never a path.

channels/<channel id>/2026/09/<file id>_drawing-a3.pdf
channels/<channel id>/2026/09/thumb/<file id>_<size>
direct/<conversation id>/2026/09/<file id>_IMG_1234.jpg

Folders are ids, never names, because renaming a folder on object storage means copying every byte under it. Year and month let one month be restored on its own, or a lifecycle rule move a year to colder storage without reading a database.

The mount that did not mount. The store keeps a marker file at its root. A root that has files but no marker stops the server, and an install with a mounted volume can require the marker outright, so a volume that failed to mount is a loud failure at start, not a weekend of files written to the wrong disk.

Files and previews

The original is never touched.

An upload names the channel it is for, and only a member may upload into it. The server takes up to 100 MB a file by default; the owner can set a lower limit and refuse file types from the console. The original is stored as it arrived and is never an input to anything that writes.

  • Two previews per picture, 128 and 640 pixels on the longest edge by default, never larger than the original. JPEG at quality 82, or PNG when the picture has transparency, so a logo does not turn into a white box.
  • Upright. A phone stores a photo as the sensor reads it; the server applies the EXIF orientation before it scales, so portraits are not drawn on their side.
  • Bounded. Only a file whose first bytes say it is an image is read into memory, decoding runs on the blocking pool, and a decoder may not allocate more than 512 MB, the guard against a tiny file that unpacks into gigabytes.
  • The server does not decode HEIC, because HEVC is patent-encumbered. An iPhone converts its photos before they upload; from Android a HEIC photo arrives as a file to download.
  • Link previews are made by the server, after the message is sent: only the page's head, at most 512 KB and six seconds, no images from strangers, and every address checked to be public before it is fetched, redirects included.

Calls

WebRTC between the two ends, signalling over the socket.

Voice and video calls between two people go browser to browser over WebRTC. The server carries the signalling (offer, answer, candidates) over the socket each client already holds, and never a second of sound or picture. No outside service is involved: no hosted relay, and no public STUN server handed out.

  • A relay of your own, when the ends cannot meet. The server hands out short-lived TURN credentials in coturn's shared-secret scheme: the username says when it expires, the password is an HMAC of it, good for twelve hours. With no relay configured, calls still connect wherever the two ends can reach each other.
  • Every device rings; the first to answer takes the call and the others stop. A call that rings 40 seconds becomes a missed call, which leaves a line in the conversation and raises the callee's unread count in the same transaction.
  • Video is the same call with pictures. The caller always offers a video line, so turning the camera on or off, or to the other camera, swaps a track without a second negotiation.
  • On a locked phone, a call is a phone call. An iPhone is woken by a PushKit VoIP push and the call is reported to CallKit; an Android phone gets a call screen over the lock screen. The phone's native code picks the earpiece, the loudspeaker, a headset or Bluetooth, which a web page cannot.

Push notifications

The server talks to Apple and Google itself, with the text inside.

Most self-hosted chats route phone notifications through the vendor's gateway, and that is where they meter them. Plumspace's server holds the keys and sends directly. The notification carries the message's text, so the phone shows it without first waking the radio to call back and ask, which is the step that loses notifications on a factory floor.

Push path: a stored message goes through one rule deciding who is woken, then to four senders in the server, to the browser, Google and Apple push services, and to browsers, Android phones and iPhones. Message stored after the commit Who is woken? • never your own message • muted room: only if named • level: all, mentions, none • a pause, quiet hours • your words count as a name • @here: who has it open one rule, every device SENDERS, INSIDE THE SERVER Web Push VAPID, RFC 8291 sealed FCM HTTP v1 data message, RS256 token APNs alert, ES256 token, HTTP/2 APNs VoIP calls only, through PushKit a task of its own, 64 in flight, 10 s each: the sender's message never waits on it Push service of the browser Google FCM Apple APNs Browser, desk service worker Android the app draws it iPhone an alert, stacked by room; a call through CallKit
From a stored message to a lit screen. Highlighted arrows leave the server.
  • Web Push is composed from primitives rather than taken from a push crate: RFC 8291 for the key agreement, RFC 8188 for the aes128gcm layout, RFC 8292 (VAPID) for the signature. A test reproduces the RFC's published example byte for byte.
  • FCM is HTTP v1 with a service account: a JWT signed RS256 is traded for an access token, kept for most of its hour, so a busy channel costs one trade an hour rather than one per phone. It sends a data message, so the app draws the notice itself and stays quiet when that room is already open.
  • APNs uses token authentication: a JWT signed ES256 with a .p8 key, renewed every fifty minutes, over HTTP/2. Notices are grouped by room. A VoIP push goes only to a token registered for calls, and only for a call, because iOS ends an app woken by VoIP that reports none.
  • Dead tokens are retired as soon as Apple or Google says so, and tokens are registered again at every app start.
  • Read anywhere clears the phone. When a room is read on another device, a silent push takes its notice off the phone, at normal priority so Android does not count it against the app.
  • The rules live in one place. A browser notice, a phone and the in-app sound obey the same rule, and unread counts move whether or not anybody is woken.

The phone apps

A native shell that draws the server's own web client.

The Android and iPhone apps ask for the workspace's address once, check it, then draw the page that server serves in a WebView. Every screen the web has, the phone has the same day, at the version of the server it talks to. What stays native is only what a page cannot do on a phone: being woken by push, ringing a call, choosing where sound plays, saving a file into the phone, the back gesture, links that open in the phone's browser, and updating itself.

The bridge between the web page and the phone shell: the page sends named messages to the shell, the shell injects events into the page, and native modules handle push, audio, saving and updates. THE WEB PAGE tellShell() ui/shell.ts Listeners shell.ts, calls/route.ts THE PHONE SHELL heard() Shell.tsx injectJavaScript a CustomEvent in the page the page tells the shell session, signed-out, place, language, edges, download, haptic, badge, update, exit, change-server, call-audio, call-route, call-answer, call-over the shell tells the page PLUM_SHELL facts before the page runs, plum-open-channel, plum-back, plum-call-answer, plum-call-rung, plum-call-hangup, plum-call-route Native modules plum-push, plum-audio, plum-save, plum-update; CallKit on an iPhone plum-server the same web client HTTPS and /ws push tokens
The bridge. Each message is named in the phone app's README; the page knows it is inside the app and changes only what the app does better.
  • One app for every workspace. There is no build per company: every install asks for its address, which is also why the shell registers push tokens under the page's own session.
  • It feels like the phone's own app. The page follows the system text size as a whole (0.85 to 1.3, so Vietnamese marks never overlap the next line), paints the system bars in its own colours, shrinks for the keyboard, and lays out two panes from 640 pixels wide on an opened folding phone.
  • Android updates itself from the publisher's signed list: the APK is hashed in native code, a block at a time, before Android's installer sees it. The Google Play build leaves updates to Play, and an iPhone updates through TestFlight; both say when a newer build is waiting.
  • Battery savers: the downloadable Android app asks once to be left out of battery optimisation, the most common reason notifications go missing on some Android brands.

The desktop app

Electron, one window, the address you type.

The Mac and Windows app is the web client in one Electron window, with its own menu, a frame that follows light and dark, a drawn title bar on Windows and none on a Mac, and an offline page that retries. The Mac build is signed with a Developer ID and notarised by Apple. The Windows installer is not code-signed, so Windows shows its SmartScreen notice once, on the first install; updates after it are protected by the publisher's own signature.

  • It checks for updates fifteen seconds after start and every four hours, from the publisher's feed and then from the workspace server's own mirror, comparing build numbers.
  • It downloads in the background, checks the size and SHA-512 the signed list gives, then hands the file to the system: on a Mac to Squirrel, which also refuses an app not signed by the same Developer ID; on Windows to the installer, run silently.
  • Only then is the person asked: Restart to update, or Later, which installs when the app quits.

Offline and start-up

It opens with no network, and starts small.

A service worker of about 120 hand-written lines keeps the build, never the server's answers: the page, the start-up scripts, the stylesheet and the fonts, in one cache per build. Opening the page is network first; the kept copy is drawn only when the network fails, answers with an error, or has not answered in four seconds. A new deploy replaces the cache whole.

  • The page keeps the newest messages in IndexedDB, one database per signed-in person: the 20 most recently opened rooms, 50 messages each, at most 256 KB a room, written only from what the server said in this session.
  • Drawn first, replaced by the server. With the server stopped, the first saved message was drawn 34 to 35 ms after navigation in a measured run. It is read only and says so; nothing written against a possibly stale copy is sent later behind the person's back.
  • Signing out wipes it before anything else, and the worker's cache holds only the build, the same bytes for everybody.
  • Where it runs: browsers, the desktop app and Android. An iPhone's WebView gives a service worker only to a fixed list of domains, which a one-app-for-every-workspace build cannot write, so the iPhone app does not open offline.

A budget the build cannot pass

Anything not on the first screen (the console, a person's pages, the picture viewer, a call's video screen, most sheets) is a file of its own, fetched when first drawn. The path that answers a ringing call stays in the first file on purpose. A gate follows index.html to every script loaded before the first paint and fails the commit above 620,000 raw bytes; on 5 October 2026 it was 615,078 raw, 162,453 as brotli. Every text asset is precompressed as brotli and gzip, and hashed assets are cached for a year as immutable.

Security

Every limit counted on the key it can trust.

AreaHow it works
PasswordsArgon2id (19 MiB, two passes), at least 12 characters, hashed off the async runtime. An unknown address costs the same as a wrong password, so timing gives nothing away.
Sessions32 random bytes from the operating system; the server stores only their SHA-256. A session lasts 90 days from its last use, and every device can be listed and signed out one at a time.
Two-step sign-inStandard TOTP (RFC 6238, six digits, thirty seconds) with any authenticator app; ten recovery codes, stored hashed and shown once; a code is good only once. A right password answers with a five-minute ticket, not a session. The owner can require it of admins or of everybody.
Rate limitsThree keys for three doors. The proxy counts addresses. The socket counts accounts, with separate allowances for writes (60 a minute), chatter such as typing (120) and machines (120), so a keystroke can never cost somebody their message. Sign-in counts consecutive failures per email address: ten, then one try every three minutes, forgiven by a right password.
PermissionsThe server decides and the client draws: the handshake carries what this person may do, and a part the owner switched off is refused by the server, not only hidden.
The owner's lookOnly an owner, never an admin, may read a conversation they are not in: with a written reason of 3 to 500 characters, read only, for one hour, and every look written to the trail that every owner and admin reads. The trail line is itself the permission.
MachinesEach incoming hook and bot is an account of its own, counted like one. Outgoing hooks and apps sign every delivery with HMAC-SHA256 over a timestamp and the body. An app reaches only what its manifest's scopes allow.
UpdatesAn Ed25519 signature over the exact bytes of every update list, checked against a public key built into the apps, then the SHA-512 of every file; see Releases below.

Running it

One image, one database, a small machine.

The server ships as a container image with the web client of the same commit inside it, so one pull moves both halves together. It is built on distroless (glibc, the CA roots, no shell, no package manager), runs as an unprivileged user, and weighs 39.6 MB to pull and 91 MB unpacked. A compose file runs it beside PostgreSQL; a deploy is a database dump, a pull, a migration and an up, and a rollback is the previous version number. Each release also publishes the binaries as a tarball for a host without Docker.

  • Sized for two cores. The design was sized on a machine with 2 cores and 7.9 GB of memory. In production on 20 September 2026 the process used 30 MB of memory and no measurable CPU for a 21-person workspace.
  • A health check that asserts a value. /healthz returns the PostgreSQL version and the number of tables it counted, and answers 503 without its database, because a probe that only says "ok" lets an empty database pass for healthy.
  • Nothing happens at start but starting: no cache to warm, no index to build, no migration run as a side effect. Migrations are a separate command and safe to repeat.
  • Settings are environment variables, one table of them, and a misspelled storage setting stops the server with the variable named rather than quietly writing to the wrong place.
  • Push keys belong to whoever runs the server: a VAPID key for browsers (one command prints it), and the app publisher's APNs key and Firebase service account for phones.

Releases and updates

One version for everything, every list signed.

The server, the web client and every app carry one version number, moved together at each release. A tag on main starts two workflows side by side: one builds the server image and the release, the other builds the apps. Installers are published to a beta channel first and promoted to stable by hand, by copying the beta list's exact bytes and signature.

Release and update flow: check, bump, tag, then an image workflow and an app build workflow, a signed beta list, promotion to stable, and on each device a signature check, a newer-build check, a SHA-512 check and installation. THE PUBLISHER Check verify.sh --all One version bumped, CHANGELOG Tag vX.Y.Z on main image.yml server image, web inside release.yml Mac: signed, notarised Windows, Android APK iPhone to TestFlight Registry, Release image and binaries deploy: pull, migrate, up Beta channel builds/<ver>-<build>/ written once, never again list + Ed25519 .sig Promote, by hand stable = beta's bytes, .sig The update key Ed25519. The private half is kept by the publisher and in the release environment's secrets, never in the repository. The public half is built into every desktop and Android app; a release build that still carries a placeholder key refuses to build. A list can name files only inside the feed's own builds folder. apps follow stable; a tester's app follows beta ON EVERY DESK AND PHONE Read the list manifest.json and its .sig, as bytes Check signature with the key built in, parse those same bytes Newer build? for this platform and architecture Download, check size and SHA-512 from the signed list Install when the person says, or on quit refused: one line in the log, the next source is asked, nothing is offered
From a tag to an installed update. The phone and desktop apps never install a file the publisher's key did not vouch for.
  • Why a raw Ed25519 signature rather than minisign's format: the apps check one key that never changes, in four lines with Node's own crypto on the desktop and a small audited library on the phone, and the same bytes can be checked by hand with the publishing script.
  • Builds are never overwritten. Every installer lives under its version and build number for good; a platform with no new build keeps its entry, and rolling back is promoting the older version, after which no further app moves to the bad one.
  • An app too old for its server says "Update needed". The server names the oldest app build it works with; the page compares at start and at every reconnection and offers the update instead of half working.

What it does not do

The limits, listed so nobody has to guess.

  • Calls are between two people. No group calls and no screen sharing yet; a group call would need a forwarding server on the same signalling.
  • No end-to-end encryption. The text travels inside the push notification, by design; encryption end to end would rule that out. Traffic is encrypted in transit, and the server is yours.
  • One organisation per install. Every rule is workspace-wide; a company runs its own instance.
  • Nothing is deleted on a clock. There is no retention policy; deleting is soft, and only an owner removes something for good from the Trash, by hand.
  • No shared push gateway yet. Phone push uses the app publisher's keys on the server that sends it; a gateway for installs without those keys is not built.
  • Two-step secrets are stored unencrypted in the database, because there is no key for secrets at rest; a stolen database alone still cannot sign in, since passwords are Argon2id.
  • The iPhone app does not open offline, and HEIC photos from Android arrive as files rather than pictures.

The feature tour lists what it does do, and what's new lists every release.