KDX
Handoff · v0.2

KDX Client & Server

A from-scratch Rust rebuild of KDX — an early-2000s Hotline-style chat and file-sharing system. A custom binary protocol over TLS 1.3, a challenge-response login where the password never leaves the machine, chat rooms with presence and private messages, threaded public news, a virtual file tree with drop boxes, in-app account and role administration, and resumable chunked transfers both directions. The client is a Tauri desktop app in the classic black / blood-red / phosphor-green aesthetic.

Crates
6 + 1 app
Tests
162 green
Commands
33
Events
13
Windows
14

This document is the reference for what the interface does and how each feature works underneath. Read the interface sections to operate the app; read "Under the hood" to understand or extend the machinery each window drives.

v0.1 → v0.2, in one line The 4-window "concept" client (Connect / Chat / Files / Transfers) is now a 14-window desktop: server-wide presence and User List, private Messages, threaded Public News, an Administration window (custom roles + disconnect/ban), an in-app Accounts editor, an Address Book, and a Trackers directory. The original v0.1 doc is kept as-is for comparison.
Two halves, one protocol The client links the same kdx-protocol and kdx-crypto crates the server uses, so the wire format has a single implementation shared by both sides — no drift between what the server speaks and what the client expects.
Orientation

System map

Every user action follows one path: the webview calls a Tauri command, the command forwards to the async client library, which frames the request and writes it to the TLS socket. Server pushes travel back the same channels as events.

request path →
Webview UIdist/ · vanilla JS
→
Tauri shellcommands.rs
→
client-coreconnection actor
→
kdxdserver daemon
← event / push path
Crate / dirRole
kdx-protocolWire format: 20-byte framed packets, codec, fragmentation, typed messages. Shared by client and server.
kdx-cryptoArgon2id password hashing + challenge-response helpers.
kdx-server-coreConnection state machine, auth, chat rooms + presence, private messages, roles/privilege overrides, public news, file tree, transfers, tracker.
kdx-storagesqlx / SQLite persistence (accounts, roles, news, file tree, resume state).
kdx-client-coreAsync client library, zero UI deps. TOFU trust, transfer engines, event stream.
kdxdThe server daemon binary (+ useradd subcommand).
apps/kdx-clientTauri v2 desktop app: a thin Rust shell + the webview frontend.
The interface

The desktop

The client is a single OS window hosting a desktop metaphor: a thin top menubar (brand + connection chip) over an open desktop, plus a vertical Button Bar — itself a floating, draggable window sized to its content — that launches all 14 feature windows. Windows are lazily built on first open, are resizable and rememeber their geometry, can be minimized to a bottom-left dock, and support a per-window "Keep on Top" toggle. F1 opens a window switcher; Ctrl/Cmd+W closes the focused window.

Button Bar

  • Server header — shows host:port or "Not connected"; click opens Connect.
  • Feature buttons — News, Public Chat, Files, User List, Administration, Disconnect / Connect, Address Book, File Transfers, Messages (unread LED), Settings, About, Exit.
  • Footer counters — users online · rooms · active transfers.
  • Buttons auto-enable once connected; admin-only windows still open for everyone but the actions inside them are gated server-side.

The 14 windows

  • Connect · Chat · Files · Transfers — the original core loop.
  • User List · User Info · Messages — presence and private messaging.
  • Public News — newsgroups and threaded posts.
  • Administration · Accounts — roles, privilege overrides, disconnect/ban, in-app account creation.
  • Address Book · Trackers · Settings · About — client-only conveniences.
The interface

Connect window

The entry point. Enter server host, port, and account, then Authenticate. The window remembers your last host / port / username via local storage, and stays available after login so it doubles as the reconnect surface.

CONNECT
SERVER
127.0.0.1
PORT
10700
LOGIN
phraq
PASSWORD
••••••••
AUTHENTICATE

The authenticate flow

  • 1 · connect — TCP + TLS + KDX version handshake.
  • 2 · trust check — first contact triggers the TOFU prompt (below).
  • 3 · login — challenge-response; password never sent.
  • 4 · join lobby — you land in #lobby and the Chat window focuses.

Status appears inline below the button: logged in as phraq (power user) or a red error line.

Trust-on-first-use prompt

KDX pins server certificates SSH-style. The first time you reach a server, or any time its key changes, the connect step is refused and an inline prompt shows the certificate fingerprint for you to verify out-of-band before trusting it.

Unrecognized server First contact. The prompt shows the sha256:… fingerprint. Trust & connect pins it and reconnects. Expected against the self-signed dev server — its key regenerates each run, so you'll re-prompt on every restart.
⚠ Server key changed A different key than the one previously pinned. Could be a reinstall — or an impostor. The prompt shows both the new and previous fingerprints and warns in red before you can trust it.
The interface

Chat window

A topic bar, a scrolling message stream, a live user list, and an input line. The title-bar tag shows the current member count.

Message line types

21:58*** phraq joined
21:59<acidburn> anyone seed the 1620 build?
22:00*** zerocool waves
! flood protection: slow down
! not in that room
  • normal — <sender> text, phosphor green, sender highlighted.
  • action — /me messages, italic, *** sender text.
  • system — join/leave and server notices, dim italic (clients can't forge these).
  • warning — flood control and advisories, amber.
  • error — server error frames, red.

Input commands

Both / and \ are accepted as the command prefix. New in v0.2: /away, /back, /afk, /clear, /help — plus right-click on any member for Send Message or Get Info, which opens the Messages or User Info window.

You typeEffectCommand
hello thereSend a normal message to the room.send_chat(flags 0)
/me wavesAction message.send_chat(flags 1)
/topic digital freedomSet the room topic (needs CHAT_SET_TOPIC).set_topic
/away brb lunchAction: "is away (brb lunch)".send_chat(flags 1)
/backAction: "is back".send_chat(flags 1)
/afkAction: "is afk".send_chat(flags 1)
/clearClear the local transcript — client-side only, doesn't sync to other clients.—
/helpShow the slash-command list inline.—

/name and /desc are recognized but stubbed — see Gaps & next steps.

The interface

Files window

A path bar with Up / New Folder / Refresh / Upload controls over a filterable directory listing. Each row shows a kind tag and — for files — a size. Clicking a folder navigates in; clicking a file opens a native save dialog and downloads it. Uploading opens a native file picker and sends into the current directory. Right-click a row for Download / Delete / Copy name / Get Info (Delete needs FILE_DELETE).

New Folder dialog Creating a node lets you set its kind (folder, drop box, or upload folder) and its minimum read / write class right there — the ACL is set at creation time, not edited after the fact. Requires FILE_MANAGE_TREE.
FILES /pub
[DIR] docs
[DROP] incoming write-only
[UP] uploads
[FILE] kdx-1620.zip 409 KB

Node kinds

  • [DIR] — a folder; click to enter.
  • [FILE] — click to download to a chosen path.
  • [DROP] — a drop box, shown in amber. You may upload into it but never list or read it — enforced server-side for every class, admins included.
  • [UP] — an upload folder.
Live refresh When a transfer completes, the Files window refreshes automatically so a just-uploaded file appears without a manual reload.
The interface

Transfers window

One card per active transfer, each with a live chunk-bitmap grid: every cell is one chunk, lighting up phosphor-green as it is sent (upload) or received and hash-verified (download). The card shows direction (↑/↓), percent, and done/total chunks; on completion it flips to verified ✓ or a failure message.

↑ kdx-1620.zip72%
69/96 chunksupload

The bitmap is driven directly by the transfer_progress event's bitmap field, so what you see is the actual resume state — the same bytes persisted for resuming an interrupted transfer.

The interface

User List & User Info

User List is server-wide, not room-scoped — every logged-in session, anywhere on the server, with a class-colored bar per row, idle time, and a filter box. Click a row to open User Info (class, login time, idle duration, IP address); right-click for Send Message, Get Info, or Disconnect (needs USER_KICK).

Presence is pushed live: the presence event fires on every login/logout server-wide, so the list updates incrementally without a manual refresh.

The interface

Messages window

Classic two-pane private messaging: a conversation list on the left (unread ones highlighted red), the selected thread and an input line on the right. Sending echoes the message back into your own conversation so the sent line appears immediately. An incoming private message while the window is closed or on another conversation opens/updates it and lights the Button Bar's Messages LED.

The interface

Public News window

A three-pane newsreader: newsgroups on the left, the selected group's posts (threaded by reply depth) in the center, the selected post's full text on the right. New Message starts a thread, Reply nests under the open post; the author (or an admin) can delete a post, which removes it from the list for everyone.

Per-group access Each newsgroup carries its own minimum read class and minimum post class — a group can be readable by guests but postable only by power users, for instance. Creating a newsgroup needs USER_ADMIN.
The interface

Administration window

Three panels behind one Discord-shaped model: custom roles (name, sort rank, color swatch, a checkbox grid of privileges), a role-assignment panel (assign/unassign a role to a username, or look up a user's current roles), and a Disconnect panel (username, optional reason, and a ban duration — none, 5m, 1h, 1 day, 1 week, 1 year). Every action here needs USER_ADMIN (roles) or USER_KICK/USER_BAN (disconnect).

The interface

Accounts window

Closes the old "accounts only via CLI" gap. Lists every account with its base class and a summary of privilege overrides; Create takes a username, password, and base class; Edit changes the class and lets you flip each privilege to inherit / grant / revoke independent of role assignment. Password is only set at creation — there's no in-app reset yet. Needs USER_ADMIN.

The interface

Address Book, Trackers, Settings & About

Address Book

Client-only bookmarks: name, host:port, login, saved password, comments, connect count, and last-connected date. Click a row to pre-fill and auto-submit Connect; right-click for Connect / Edit / Remove.

Trackers

Queries the connected server's tracker for other live KDX servers it knows about (name, population, address, description) with a type-to-filter box; picking one pre-fills Connect with its address.

Settings

Theme picker (Blood Red, TRON, Amber, Matrix — live preview), a timestamps toggle, and identity fields (display name, description) that are saved locally but not yet sent to the server — see Gaps.

About

Static branding, version, and credits. Read-only.

The bridge

Command reference

The webview calls these via __TAURI__.core.invoke. Each is a thin async forward into kdx-client-core; none holds business logic. Errors return a tagged object — kind: "untrusted_certificate" (with host, port, fingerprint, previous) drives the TOFU prompt, everything else is kind: "message". 33 commands total, up from 11 in v0.1.

Session & chat

CommandArgumentsReturnsEffect
connecthost, port"connected"TCP + TLS + KDX handshake; spawns the event forwarder. May return an untrusted-cert error.
trusthost, port, fingerprint—Pin a fingerprint in known_hosts.toml so the next connect trusts it.
loginusername, passwordclass: u8Challenge-response login; returns the account's base class (0–3).
join_roomroom—Join (or create, with privilege) a room.
leave_roomroom—Leave a room.
send_chatroom, flags, text—Send a message; flags bit 0 = action (/me).
set_topicroom, topic—Set a room topic (needs CHAT_SET_TOPIC).
send_privateto, text—Send a direct message; echoed back to the sender's own sessions.
disconnect——Close the connection.

Presence & tracker

CommandArgumentsReturnsEffect
list_users—PresenceUser[]Server-wide roster: username, class, idle time, address.
get_user_infousernamePresenceUserOne user's presence snapshot.
list_serversfilterTrackerServer[]Query the tracker for other live servers matching a substring.

Files & transfers

CommandArgumentsReturnsEffect
list_filespath{path, entries[]}List a directory (recursive tree); entries carry name, kind, size.
create_folderpath, name, kind, min_read_class, min_write_classFileListDtoCreate a folder / drop box / upload folder with its ACL (needs FILE_MANAGE_TREE).
delete_pathpathFileListDtoDelete a file or folder (needs FILE_DELETE).
uploadlocal, remote_dir—Upload a local file; resolves when verified. Progress via events.
downloadremote_path, local—Download to a local path, resuming from a sidecar if present.

Roles & accounts USER_ADMIN

CommandArgumentsReturnsEffect
list_roles—RoleInfo[]Fetch all custom roles.
create_rolename, privileges, rank, colorRoleInfo[]Define a new role; returns the updated list.
update_roleid, name, privileges, rank, colorRoleInfo[]Modify an existing role.
delete_roleidRoleInfo[]Remove a role.
assign_role / unassign_roleusername, role_idRoleInfo[]Grant or revoke a role on an account.
account_rolesusernameString[]List role IDs assigned to an account.
disconnect_userusername, reason, ban_secs—Kick and optionally ban (needs USER_KICK / USER_BAN).
list_accounts—AccountSummary[]All accounts with class and privilege overrides.
create_accountusername, password, base_class, granted, revokedAccountSummary[]Create a new account in-app.
update_accountusername, base_class, granted, revokedAccountSummary[]Edit an account's class and privilege overrides.

Public news

CommandArgumentsReturnsEffect
list_newsgroups—NewsgroupInfo[]All newsgroups with read/post class thresholds.
create_newsgroupname, description, min_read_class, min_post_classNewsgroupInfo[]Create a newsgroup (needs USER_ADMIN).
list_threadnewsgroup_idNewsPost[]Fetch all posts in a group (flat; UI threads by parent_id).
create_postnewsgroup_id, parent_id, subject, bodyNewsPost[]Post a root (empty parent_id) or a reply.
delete_postpost_idNewsPost[]Delete a post (author, or USER_ADMIN for others).
The bridge

Event stream

The server's pushes and transfer progress arrive on one channel. kdx-client-core emits a typed Event; the Tauri shell forwards each as a "kdx" webview event whose payload is tagged by type (snake_case). The UI switches on that tag.

typePayloadWhat the UI does
connectedfingerprintInformational; connection is considered up after login succeeds.
authenticatedclassReserved — login() already returns the class directly.
chatroom, sender, timestamp, flags, textRender a chat line (normal / action / system by flags).
user_listroom, users[]Repaint the user list; update the member-count tag.
topicroom, topicUpdate the topic bar.
presenceuser, onlineServer-wide login/logout; patches the User List incrementally.
private_messagefrom, to, timestamp, textOpens/updates the Messages window; marks unread if it isn't the active conversation.
transfer_progressid, direction, done, total, bitmapUpdate / create the transfer card and its chunk-bitmap grid.
transfer_completeid, direction, status, messageMark the card done (status 0 = verified); refresh Files.
server_warningtextAmber chat line (e.g. flood control).
server_errortextRed chat line.
server_infotextSystem-styled chat line.
disconnectedreasonDrop to offline, tag chat "offline", focus Connect for reconnection.
Under the hood

Protocol & framing

Every message is a length-framed packet. The header is a fixed 20 bytes, all multi-byte fields big-endian:

// 20-byte header
magic       4  b"KDX\0"
version     1
packet_type 1  handshake / auth / file / chat / system ranges
flags       2  bitfield incl. fragmented, more-fragments
sequence    4  per-connection monotonic
length      4  payload bytes (u32)
reserved    2
checksum    2  CRC32C-folded — framing guard, not security
  • Fragmentation — payloads over a 16 KB frame cap (a file chunk, say) split across frames flagged FRAGMENTED / MORE_FRAGMENTS and reassemble on the far side, guarded by a size cap and timeout.
  • Integrity comes from TLS and per-chunk SHA-256 — the header checksum only catches framing corruption.

packet_type ranges (v0.2)

The original four ranges (handshake / auth / file / chat) are unchanged; presence, private messages, roles/accounts, and news each claimed a new range as they were wired in:

RangeGroupSince
0x00–0x0FConnection — handshake, ping/pong, disconnectv0.1
0x10–0x1FAuthentication — challenge/responsev0.1
0x20–0x2FFiles — list, transfer start/data/end, create/deletev0.1
0x30–0x34Chat — message, join/leave, user list, topicv0.1
0x35–0x39Presence — list/change, user infoP1
0x40–0x42Private messages & admin disconnectP1 / P3
0x50–0x5CRoles & accounts — CRUD, assign/unassign, listP3
0x60–0x66Public news — newsgroups, threads, postsP4
0xF0–0xF2System — error, warning, infov0.1
Under the hood

Auth & trust

Challenge-response login

The server stores only an Argon2id hash. To log in, the client receives a one-time random challenge plus the account's salt and cost parameters, recomputes the Argon2 verifier locally, and answers with SHA-256(challenge ‖ verifier). The server compares in constant time. The password never crosses the wire — not even inside TLS — and a captured answer is useless because each login gets a fresh challenge. Unknown usernames still get a plausible decoy challenge, so the wire can't distinguish "no such user" from "wrong password."

Trust-on-first-use

The client pins the SHA-256 of the server's certificate per host:port in known_hosts.toml. An unknown or changed key fails the TLS handshake; the library surfaces the observed fingerprint (and any previous one) so the UI can prompt. This is the desktop-app answer to self-signed and rotating server certs without a CA.

Under the hood

Chat & user classes

Each chat room is its own actor task owning its membership, topic, and per-member flood gate — broadcasting a message is N channel sends with no shared lock on the hot path. A token-bucket flood gate (burst 5, refill 1/s) throttles a spammer with a server_warning. Clients cannot forge system-flagged messages.

Classes & privileges

Four base classes with strictly-increasing privilege sets; custom roles and per-account overrides can grant beyond or revoke from the class (see Roles & overrides).

ClassGains (on top of the one below)
guestCHAT_SEND, FILE_LIST
userCHAT_PRIVATE, FILE_DOWNLOAD, FILE_UPLOAD
powerCHAT_CREATE_ROOM, CHAT_SET_TOPIC, FILE_DELETE, FILE_MANAGE_TREE
admineverything (USER_KICK, USER_BAN, USER_ADMIN, SERVER_ADMIN)

The underlying bitflags, from kdx-server-core/src/auth/classes.rs:

CHAT_SEND        = 1 << 0
CHAT_PRIVATE     = 1 << 1
CHAT_CREATE_ROOM = 1 << 2
CHAT_SET_TOPIC   = 1 << 3
FILE_LIST        = 1 << 4
FILE_DOWNLOAD    = 1 << 5
FILE_UPLOAD      = 1 << 6
FILE_DELETE      = 1 << 7
FILE_MANAGE_TREE = 1 << 8   // create/remove folders, dropboxes
USER_KICK        = 1 << 16
USER_BAN         = 1 << 17
USER_ADMIN       = 1 << 18  // create/edit accounts, manage roles
SERVER_ADMIN     = 1 << 19  // config, shutdown
Under the hood

Roles & overrides

On top of the four base classes, P3 added a Discord-shaped custom-role layer: a SysOp defines named roles (with a sort rank and a color) as arbitrary privilege bundles, and assigns any number of them to an account. Independently, each account can carry per-privilege grant/revoke bits that apply on top of everything else.

Resolution order effective_privileges(base, roles, granted, revoked) computes: base class privileges, unioned with every assigned role's privileges, unioned with per-account grants — then the per-account revokes are subtracted last. Revokes always win, so an admin can silence one specific privilege on one specific account regardless of class or role.

This is exactly what the Administration and Accounts windows edit: roles are the reusable bundle, account overrides are the one-off exception.

Under the hood

Files & ACLs

The file tree is loaded from SQLite into memory (read-heavy, write-rare) and written through on mutation. Every folder carries a minimum-class-to-read and minimum-class-to-write. File bytes live on the filesystem, never in the database.

  • Drop boxes reject listing and reading structurally — the check sits in the ACL layer and fires for every caller regardless of class, so write-only is a property of the node, not a convention.
  • Downloads resolve the file through the same read-ACL path, so a file inside a drop box can be uploaded but never downloaded.
Under the hood

Transfers & resume

Files move in 32 KB chunks, each carrying its own SHA-256; the whole file is SHA-256-verified before it's accepted. Both directions are throttled by a token bucket and support resume — the difference is who remembers what:

Upload ↑

The server holds the resume state: a received-chunk bitmap in the transfer_state table, updated after every chunk-ack. A reconnecting client is told which chunks the server already has and sends only the rest. A crash loses at most one chunk.

Download ↓

The client holds the resume state: a .kdxpart sidecar next to the partial file records the bitmap. On resume the client sends its bitmap and the server streams only the missing chunks; the whole-file hash is checked before .part is renamed to the final name.

The chunk grid in the Transfers window is a direct view of this bitmap — the visualization and the resume mechanism are the same bytes.

Under the hood

Private messaging

Closes the v0.1 gap where CHAT_PRIVATE existed but wasn't wired end to end. send_private(to, text) sends a PrivateSend (0x40) packet; the server delivers a PrivateMessage (0x41) to every one of the recipient's sessions and echoes it back to every one of the sender's own sessions, so a user logged in twice sees the same conversation on both.

There's no persistence yet — a message not received live (recipient offline) is simply not delivered; there's no mailbox or history replay.

Under the hood

News & threading

Newsgroups and posts persist in SQLite (kdx-storage/src/news.rs), with domain logic in kdx-server-core/src/news.rs. A post is a root (empty parent_id) or a reply nested under any existing post in the same group; the server returns the whole group as a flat list and the client threads it by walking parent_id and indenting by depth.

Access is per-group, not per-post: min_read_class gates list_thread, min_post_class gates create_post. Deleting a post is restricted to its author or an admin (USER_ADMIN) — there's no edit, only delete-and-repost.

Operating it

Running it

Server

# create an account: guest | user | power | admin
cargo run -p kdxd -- useradd phraq s3cret power

# start it (defaults to 0.0.0.0:10700, self-signed dev cert)
cargo run -p kdxd

Client

cargo run -p kdx-client

The window opens; enter host 127.0.0.1, port 10700, your account, and Authenticate. Accept the TOFU fingerprint on first contact. No Node toolchain is needed — the bundled frontend is served directly.

macOS packaging cargo install tauri-cli --version '^2', then in apps/kdx-client/src-tauri run cargo tauri icon icons/icon.png and cargo tauri build for a signed .app + .dmg. A placeholder icon ships so cargo run works out of the box.
Operating it

Verification

The whole stack runs green under cargo test --workspace — 162 tests (up from 107), no external services. The client library is tested headlessly against a real in-process kdxd (real TLS over 127.0.0.1), and the server's own tests are an independent second implementation of the wire flows.

  • Protocol — codec round-trips, checksum corruption, fragment reassembly.
  • Crypto — Argon2 verify, challenge round-trip, replay rejection.
  • End-to-end — login, chat both directions, uploads/downloads with kill-and-resume in both directions, DropBox refusal, throttle pacing.
  • Presence & messaging — server-wide login/logout fan-out, private message delivery + sender echo.
  • Roles & accounts — effective-privilege resolution (base ∪ roles ∪ grants − revokes), CRUD round-trips.
  • News — newsgroup creation, threaded post/reply, per-class read/post gating, delete authorization.
  • Cross-process smoke — cargo run -p kdx-client-core --example smoke drives a separately-running kdxd: TOFU-pin → login → join → chat → list, over a real socket.
Operating it

Gaps & next steps

Closed since v0.1 Tracker window (Trackers, wired to list_servers) · Account admin (Accounts window, in-app create/edit) · Private messaging (Messages window, fully wired) · Custom roles (Administration window — didn't exist as a concept in v0.1) · Public news (didn't exist in v0.1).
Identity fields don't sync Settings lets you set a display name and description, but they're saved to localStorage only — never sent to the server or reflected in presence. The /name and /desc slash commands are recognized but stubbed with a "not supported yet" message. Needs a protocol field + server-side storage.
Single room The client still fixes on #lobby; join_room / leave_room and the room-scoped protocol exist, but there's no room list or switcher UI. A room picker is a UI-only addition on top of already-wired plumbing.
  • Postgres — persistence is SQLite; a Postgres swap is confined to kdx-storage.
  • No PM history — private messages aren't persisted; a message to an offline user is simply not delivered, there's no mailbox or replay.
  • No password reset UI — Accounts sets a password only at creation; changing an existing account's password still needs direct DB access.
  • File manager — no drag-drop and no batch upload/download; the resumable-chunk plumbing exists but the UI never surfaces a partial-download resume prompt.
  • GUI manual smoke — the desktop window itself is verified by hand (cargo run -p kdx-client); every layer it wraps is covered by automated tests.