Skip to content

Web client + protocol expansion (moderation, attachments policy, user directory)#10

Merged
RagingRedRiot merged 8 commits into
mainfrom
feat/web-client-and-protocol-expansion
Jun 7, 2026
Merged

Web client + protocol expansion (moderation, attachments policy, user directory)#10
RagingRedRiot merged 8 commits into
mainfrom
feat/web-client-and-protocol-expansion

Conversation

@RagingRedRiot

Copy link
Copy Markdown
Owner

Web client + protocol expansion (moderation, attachments policy, user directory)

Summary

Brings relay from a backend-only service to a usable, self-contained product: a bundled Svelte + TypeScript web client embedded in the binary, plus the backend protocol features it needs — message moderation, an attachment content-type policy, room moderation and browsing, member ownership, and a searchable user directory. Also includes the rust-embed asset-embedding switch the embedded client relies on.

7 commits, grouped so each is reviewable on its own and (for the backend ones) compiles independently.

What's included

4dd3f47 — rust-embed asset embedding
Replaces hand-wired include_str! handlers with rust-embed: the release binary bakes in frontend/dist/, debug reads from disk for fast frontend iteration, and FRONTEND_DIR overrides with a filesystem directory. Also switches sqlx/reqwest to rustls (drops the libssl-dev dependency). (Pre-dated this branch; carried along because the embedded client depends on it.)

0e88ec9 — wire protocol: room-name addressing, admin flag, signup status

  • SendMessage is keyed by room_name instead of room_id — clients address rooms by handle, no UUID discovery step.
  • AuthOk carries is_admin, so the client gates admin UI from the login reply.
  • New GetSignupStatusSignupStatus { open_signups }, answerable pre-auth so a login screen knows whether to offer registration. The prelude becomes a bounded loop (capped at 16 frames) to support it.

75f8bb1 — message deletion, admin history read, attachment content-type policy

  • DeleteMessage (sender-or-admin unsend) with a new MessageRemoved fan-out event.
  • Admins can read any room's history for moderation (resolve_readable_room).
  • Completed uploads are magic-byte sniffed (infer): detected binary types overwrite the declared type; magicless text types are allowed only when declared and NUL-free; anything else is rejected via AttachmentRejected, rolling back the file (and an orphaned caption-only message).

0180416 — room moderation, member ownership, user directory

  • RemoveRoomMember (owner/admin kick, with live cross-session unsubscribe), CancelJoinRequest (self-withdraw).
  • ListDiscoverableRooms / admin-only ListAllRooms, returning DiscoverableRoom { room_name, is_public, member_count }.
  • RoomMembers now returns RoomMember (adds is_owner).
  • GetUsers — keyset-paged, prefix-filterable directory; per-entry is_admin revealed only to admin callers.

85c2882 — dev tooling and docs
Seed binary (cargo run --bin seed), build.rs (marks frontend/dist as a Cargo input), docker-compose.yaml.example (Postgres 18), and docs for the new protocol surface.

3eb7b93 — default web client (Svelte + TypeScript)
A reconnecting, fully-typed WebSocket client mirroring the Rust wire protocol, Svelte stores for session state, and a spatial "canvas" of draggable panes (rooms, directory, profiles, room info, admin). Dark/light theming, per-user colors, inline image attachments, admin read-only room panes for moderation. Plus a Makefile for dev/LAN/production workflows.

af3836d — docs: build-first note
Documents that frontend/dist/ must be built before the backend compiles.

Breaking wire-protocol changes

These change the JSON contract; any existing client must update:

  • SendMessage: room_id: Uuidroom_name: String
  • AuthOk: now AuthOk { is_admin: bool }
  • RoomMembers: members are RoomMember (with is_owner) instead of PublicUser

Build / fresh-clone note

frontend/dist/ is gitignored and embedded at compile time, so a fresh clone won't cargo build until the client is built (make build, or cd frontend && npm install && npm run build). Documented in the README. If we'd rather a clone build out-of-the-box, an alternative is committing a placeholder frontend/dist/index.html — happy to do that instead.

Testing

  • Each backend commit was compile-checked in isolation (cargo build --all-targets against that commit's tree).
  • The behavioral integration suite (cargo test, sqlx::test) needs a Postgres instance and was not run in this pass — reviewers should run it locally (docker compose up -d from the example, then cargo test).
  • Frontend type-check (npm run check) / build not run here.

RagingRedRiot and others added 7 commits June 3, 2026 23:25
…d`, which bakes the contents of `frontend/dist/` into the binary at compile time so a deployed relay needs no external static files.

Serving:
- In release builds the embedded tree is served directly from memory.
- In debug builds rust-embed reads from the filesystem at runtime, so frontend iteration (edit → rebuild JS → refresh) doesn't require a Rust recompile.
- Both paths fall back to `index.html` for any unmatched route, which is required for SPA client-side routing to work.

Override (FRONTEND_DIR):
- When `FRONTEND_DIR` is set, `tower_http::services::ServeDir` serves that directory instead of the embedded tree. Community or custom frontends drop in by pointing at their own `dist/` output — no recompile needed.
- The same `index.html` fallback applies on the filesystem path.

Route changes (src/app.rs):
- Drop the explicit `/` and `/script.js` routes; static serving now lives entirely in the router fallback so any path the frontend emits is handled without a per-file route registration.
- `/ws`, `/health`, and `/favicon.ico` are registered first and take priority over the fallback.

Also switches sqlx and reqwest from native-tls to rustls, removing the OpenSSL system-library dependency (`libssl-dev`) so the binary builds anywhere without external headers.

Placeholder `frontend/dist/index.html` committed so the embed compiles before the Svelte frontend is scaffolded.
…at login

Three client-facing protocol refinements that let a frontend bootstrap from a room/login screen without an extra discovery round-trip. They ship together because each one rewrites the same wire types and the prelude, and the test scaffolding they all touch (the `authenticate` helper, the message-posting helpers) has to move in lockstep or nothing compiles.

Protocol (src/model.rs):
- SendMessage now carries `room_name: String` instead of `room_id: Uuid`. A client addresses a room by its human handle everywhere; it no longer has to learn and cache the server-assigned UUID before it can post.
- AuthOk gains `is_admin: bool`. A successful login reports admin status in the same reply, so the client can gate admin-only UI immediately rather than issuing a follow-up IsAdmin query.
- New GetSignupStatus command and SignupStatus { open_signups } reply: a client can ask whether unauthenticated account creation is enabled. Answerable in the prelude (pre-auth) so a login screen knows whether to offer "create account", and after auth as well.

Session / prelude (src/server.rs):
- The SendMessage handler resolves the supplied name to a room_id (LOWER(room_name) = LOWER(trim_ws($1))) before handing off to the message actor, which still keys everything by id. An unknown name collapses to the same generic Failed as a forbidden room, so room existence isn't leaked.
- The prelude becomes a bounded loop: GetSignupStatus is answered in place and the prelude keeps waiting for Auth/NewUser, while Auth, NewUser, and any other frame stay terminal exactly as before -- a first Auth/NewUser behaves unchanged. MAX_PRELUDE_FRAMES (16) caps signup-status queries on an unauthenticated socket, where the per-session rate limiter isn't active yet.
- On a successful Auth the prelude resolves is_admin (UserHandle::is_admin) and returns AuthOk { is_admin }, threaded through the receiver task to the client.

Tests:
- common: authenticate() now matches AuthOk { .. }.
- New signup_status test (open vs. closed signups, pre-auth answerability).
- send_message, fan_out, get_messages, read_state, reactions, max_chunk_size, and download_attachment now address rooms by name; their room_id() lookups are dropped.
- Compiles clean: cargo build --all-targets. (Behavioral suite needs Postgres and was not run in this pass.)
…-type policy

Adds the moderation and safety pieces around individual messages and their files: an author/admin can unsend a message, an admin can read any room's history for moderation, and a completed upload is verified against its own bytes before it is ever published. These land together because they share one new fan-out event (MessageRemoved) and one rollback path -- a rejected file that was a message's only attachment deletes the message exactly the way an unsend does.

Protocol (src/model.rs):
- New DeleteMessage { message_id } command: an "unsend" allowed only for the message's own sender or a server admin. On success the message -- with its attachments, their chunks, and reactions -- is removed and a MessageRemoved is fanned out. A forbidden or unknown message yields the same generic failure, so neither the message's existence nor who may delete it leaks.
- New MessageRemoved { room_name, message_id } event so every subscribed client drops a message live, whether it was unsent, admin-deleted, or orphaned by a rejected upload.
- New AttachmentRejected { attachment_id, reason } event: a fully-uploaded file failed the content-type policy and was not published. Attachment-scoped so the client can attribute it to the specific file.

Message actor (src/message.rs):
- delete_message: JIT auth resolves the room (id + name) in the same gated statement that checks sender-or-admin, so a non-author non-admin and an unknown message both produce no row and the same Failed. The delete relies on ON DELETE CASCADE for attachments/chunks/reactions, then publishes MessageRemoved.
- GetMessages history now resolves via resolve_readable_room: a member OR an admin may read, so an admin can moderate a private room they don't belong to. A non-member non-admin and an unknown room still collapse to the same Failed.

Attachments (src/attachment.rs):
- Content-type policy: once a completed upload's bytes match the declared hash/size, its header (chunk 0 only -- no reassembly) is magic-sniffed with `infer`. Detected binary types (png/jpeg/gif/webp/pdf/zip) are authoritative and overwrite the stored content_type in the same CAS that flips is_complete, correcting any mislabeling. Magicless text-like types (text/plain, csv, markdown, json, svg) are accepted only when declared AND the header has no NUL byte, so a binary can't be smuggled in as "text". Anything else is rejected.
- cancel_rejected_upload rolls a rejected file back: delete the attachment (chunks cascade), fan AttachmentRejected to the room, and if that left the parent message with no attachments -- the common case, since a file post's caption defaults to the filename -- delete the message too and fan MessageRemoved, so it doesn't linger as a bare filename. Best-effort: a DB hiccup here at worst leaves an incomplete row for the reaper, never a served file.

Session (src/server.rs):
- DeleteMessage dispatch arm.
- process_binary threads content_type and the hub into the upload actor (so completion can sniff and, on rejection, fan out), with the meta query now also selecting content_type.

Deps: infer 0.16 (magic-byte content sniffing).

Tests:
- New delete_message and reject_attachment suites.
- get_messages gains an admin-reads-non-member-room case.
- download_attachment now uploads bytes that open with a real PNG signature, and max_chunk_size declares text/plain, so both satisfy the new policy.
- Compiles clean: cargo build --all-targets. (Behavioral suite needs Postgres and was not run in this pass.)
Rounds out the room/user surface a client needs to manage membership and let people find each other: owners and admins can remove members, a user can withdraw a pending join request, rooms can be browsed (publicly, or exhaustively by an admin), member lists now expose ownership, and there's a paged, searchable user directory. Grouped together because they all extend the same two actors (room, user) and the RoomResponse/UserResponse + wire enums in one pass.

Protocol (src/model.rs):
- RemoveRoomMember { room_name, member_username } -- owner/admin removes another user's membership.
- CancelJoinRequest { room_name } -- the caller withdraws their own pending request.
- ListDiscoverableRooms -> DiscoverableRooms, and (admin-only) ListAllRooms -> AllRooms, each a list of DiscoverableRoom { room_name, is_public, member_count }. Discoverable lists only public/discoverable rooms; the admin list includes private non-discoverable ones for moderation.
- GetUsers { starts_with?, after?, limit? } -> Users { users, has_more }, a keyset-paged directory of UserDirectoryEntry.
- RoomMembers now returns RoomMember (the public profile fields plus is_owner) instead of PublicUser, so a client can render ownership and gate owner-only actions.

Room actor (src/room.rs):
- remove_room_member: authorize in a row-locked statement (caller owns the room, or is an admin), delete the target's membership capturing their user_id, and on commit call hub.unsubscribe_user_from_room so their open sessions stop receiving the room live. "No such room" and "not authorized" report identically so a non-owner can't probe a private room.
- cancel_join_request: delete the caller's own pending request, resolving the room by name inline; a missing room or no request both NoChange. No authorization needed -- a user owns their own request.
- list_discoverable_rooms / list_all_rooms: member counts via LEFT JOIN, ordered by count then name; list_all_rooms is gated on a fresh admin check.
- get_room_membership query now also selects memberships.is_owner.

User actor (src/user.rs):
- get_users: username-ordered keyset pagination (case-insensitive) with an optional prefix filter; LIKE metacharacters in the prefix are escaped so a literal %/_ isn't a wildcard. Fetches one extra row to report has_more without a second count. The per-entry is_admin flag is populated only when the caller is themselves an admin, and is omitted from the wire otherwise, so admin status isn't leaked to non-admins. Page size is clamped (default 50, hard cap 100, floor 1).

Hub (src/hub.rs):
- unsubscribe_user_from_room: the mirror of subscribe_user_to_room -- pushes Subscription::Remove to every open session of a user, so a kick takes effect on their live feed immediately instead of at reconnect.

Session (src/server.rs):
- Dispatch arms for GetUsers, RemoveRoomMember, CancelJoinRequest, ListDiscoverableRooms, and ListAllRooms.

Tests:
- New get_users, remove_room_member, cancel_join_request, list_all_rooms, and list_discoverable_rooms suites.
- get_room_membership asserts the per-member is_owner flag.
- fan_out gains a removed-member-stops-receiving-live case (the cross-session unsubscribe).
- Compiles clean: cargo build --all-targets. (Behavioral suite needs Postgres and was not run in this pass.)
Non-runtime supporting changes for the three feature commits that precede it: a way to stand up and seed a local database, a build hook so the embedded frontend stays fresh, and documentation for the commands/events those commits added. No server behavior changes here.

Seed binary (src/bin/seed.rs, Cargo.toml):
- A `seed` binary that runs migrations and inserts a known set of test users, rooms, and messages, idempotently (safe to re-run). Gives a one-command local setup and a stable fixture for manual/frontend testing (admin/admin123, alice/bob/carol with password).
- Cargo.toml gains `default-run = "relay"` so `cargo run` still launches the server now that the crate has two binaries; the seed is `cargo run --bin seed`.

Build script (build.rs):
- Recursively marks frontend/dist as a Cargo input (cargo:rerun-if-changed) so a new JS bundle triggers a rebuild of the binary that embeds it (pairs with the rust-embed switch). Absent the directory it no-ops, so a backend-only checkout still builds.

Local infra (docker-compose.yaml.example):
- A copy-to-docker-compose.yml example bringing up Postgres 18 (required for the uuidv7() the schema uses) with credentials matching the default DATABASE_URL, plus a healthcheck. Committed as .example so a developer's actual compose file stays local (gitignored). #2

Misc:
- .gitignore: ignore .claude/skills/ (local Claude Code skills used for front-end, not for commit).
- src/app.rs: rustfmt-only reformat of the frontend fallback service; no behavior change.

Docs (docs/, README.md):
- wire-protocol, client-contract, architecture, and the actors-and-channels / data-model references updated for the new commands and events: room-name addressing, AuthOk admin flag, signup status, message deletion, attachment content-type policy, room moderation (remove member, cancel request, room listings), member ownership, and the user directory.

Compiles clean: cargo build --all-targets (the seed binary included).
The reference frontend for relay: a single-page client that speaks the WebSocket protocol directly and is embedded into the release binary (via the rust-embed switch and build.rs hook already in place), so a deployed server ships a usable UI with no extra hosting. A community or custom client can still replace it through FRONTEND_DIR.

Stack:
- Svelte 5 + TypeScript, built with Vite; styling via Tailwind (postcss/autoprefixer). `npm run check` runs svelte-check + tsc. node_modules and the dist/ build output are gitignored; dist is produced by `npm run build` and is what the backend embeds.

WebSocket layer (src/lib/ws/):
- RelayConnection: a single socket with automatic reconnect (capped exponential backoff), arraybuffer binary mode, and a typed pub/sub over server events. Distinguishes an expected server-initiated Close from a dropped connection so it doesn't fight a deliberate shutdown.
- types.ts mirrors the Rust wire protocol one-to-one (ClientCommand, ServerEventMap, and the shared shapes: MessageHistoryItem, RoomMember, UserDirectoryEntry, DiscoverableRoom, attachments, reactions, join requests), so the client and server speak exactly the same language.
- attachments.ts handles chunked binary upload/download framing; users.ts wraps directory paging.

State (Svelte stores):
- Connection state, authed/username, isAdmin (from the AuthOk payload), per-room unread counts, and signupsOpen (queried automatically pre-auth so the login screen knows whether to offer registration). Unread refreshes are debounced.

UI:
- A spatial "canvas" workspace instead of a fixed sidebar: rooms, the user directory, profiles, room info, and the admin panel are each draggable, stackable panes (canvas/Pane, Room, Directory, ProfilePanel, RoomInfoPanel, AdminPanel). An admin can open a room pane read-only for moderation (no composer). Per-user colors, a dark/light theme synced to system preference, and inline image attachments round it out.
- Login screen gates the canvas and adapts to whether signups are open.

Tooling:
- Makefile with dev (two-terminal backend + Vite hot-reload), LAN-testing, and production (build + embed) targets.

Note from the engineer: the product direction, interaction model, and the canvas-based design of this client are mine. The implementation leaned heavily on AI pair-programming, which let me go from design intent to a working, protocol-complete client quickly. I've reviewed it and stand behind the result; credit to the assistants below for the heavy lifting in code.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Codex <codex@openai.com>
The binary embeds frontend/dist/ at compile time via rust-embed, but that
directory is gitignored, so a fresh clone won't compile until it's produced.
Document the build-first step (make build, or npm run build) in the
Running-locally section, add Node.js + npm to the requirements, and clarify
that FRONTEND_DIR is a runtime override that doesn't remove the compile-time
need for the embed folder.
@github-actions

github-actions Bot commented Jun 7, 2026

Copy link
Copy Markdown

⚠️ Existing tests were modified

The following test files have removed or changed lines compared to main:

  • tests/common/mod.rs (1 line(s) removed or changed)
  • tests/download_attachment.rs (8 line(s) removed or changed)
  • tests/fan_out.rs (15 line(s) removed or changed)
  • tests/get_messages.rs (16 line(s) removed or changed)
  • tests/get_room_membership.rs (2 line(s) removed or changed)
  • tests/max_chunk_size.rs (10 line(s) removed or changed)
  • tests/reactions.rs (2 line(s) removed or changed)
  • tests/read_state.rs (20 line(s) removed or changed)
  • tests/send_message.rs (14 line(s) removed or changed)

Adding new tests is fine and does not trigger this notice. This appears when existing test code (or helpers under tests/common/) is edited or removed. That can be legitimate — but every change should be deliberate and visible to reviewers.

The binary embeds frontend/dist/ at compile time via rust-embed, but that
directory is gitignored and so absent on a fresh checkout, which broke the
clippy and test jobs with "#[derive(RustEmbed)] folder 'frontend/dist/' does
not exist". Add a Node setup + `npm ci && npm run build` step to both jobs so
the real client is built (and embedded) before cargo runs. The fmt job is
left untouched -- rustfmt doesn't expand macros or compile, so it needs no
frontend build.
@RagingRedRiot RagingRedRiot merged commit 924392e into main Jun 7, 2026
4 checks passed
@RagingRedRiot RagingRedRiot deleted the feat/web-client-and-protocol-expansion branch June 7, 2026 01:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant