Betav0.7.0-178-gf1ea8e9

About Lumen

Lumen is a small, self-hosted social app for staying in touch with friends - servers with voice and text channels, voice and video calls, direct messages, snaps, and a handful of games with ranked play. It's a personal project, currently in beta.

The web app is built for desktop.
On phones and tablets the web UI is still under development - it loads, but the layout and touch handling are rough. The native iOS and Android apps are fully working, so use those on mobile.

What Lumen is

Lumen brings a few things into one place: persistent voice channels you can jump in and out of, direct messages, ephemeral snaps, and a set of casual multiplayer games. It isn't trying to compete with Discord or Slack - it's an invite-only space for a small group of people.

The goal is a simple app that respects your privacy, loads quickly, and stays out of your way.

Requesting an account

Account creation is currently invite-approved. From the sign-in page you can request access by picking a username and submitting an email address. The operator reviews the request; once approved, a temporary password is emailed to you, which you're required to change on first sign-in.

This manual step is deliberate while the project is small, but it isn't the long-term plan. Automated sign-up with email verification is in the pipeline and will remove the approval step entirely - new users will be able to verify their email and start using Lumen without any administrator action.

Browser support

Lumen is developed and tested primarily on recent versions of Chromium-based browsers (Chrome, Edge). Firefox works. Safari is occasionally tested but is not a primary target - if you're on Safari, expect rough edges specifically around WebRTC (voice and calls) and IndexedDB (encryption key storage). You need a browser that supports the Web Crypto API, IndexedDB, and WebRTC, which in practice means any browser from the last few years.

The app is loaded as a single-page application and expects JavaScript to be enabled. Private browsing / incognito modes are workable for one-off sessions: each private window starts with a fresh device key pair, and your password unwraps the master key the same way as on a real install. History becomes readable for the duration of the private session, then both the device key and the in-memory master key are gone the moment the window closes.

Apps

Alongside the web app, Lumen ships native apps for iOS and Android, and a desktop app for macOS and Windows (built with Electron). They all talk to the same backend. The mobile apps are native ports - Swift on iOS, Kotlin on Android - that re-implement the UI on top of the same API and SignalR hub. The desktop app is an Electron wrapper around the web stack. All of them add the platform niceties you'd expect: background notifications, OS integration, and not having to keep a browser tab open.

Code signing - what to expect on install

No build carries a paid, OS-trusted identity certificate from Apple, Google, or a Windows code-signing CA. That doesn't mean the app is unsafe; it means your OS can't verify who built it, so it warns you on install. The plan for each platform:

  • iOS - sideload-only today. The eventual target is the App Store, at which point installation becomes a normal one-tap App Store install.
  • Android - sideloaded APK today. The eventual target is the Play Store.
  • macOS - the .dmg is self-signed with Lumen's own free certificate. It isn't notarised, so on first launch you'll still right-click the app → Open (or Open Anyway under System Settings → Privacy & Security) to bypass Gatekeeper - once. The self-signing is what keeps your Screen Recording, camera and microphone permissions across updates instead of macOS forgetting them every release. The eventual target is a notarised .dmg, after which Gatekeeper stops complaining entirely.
  • Windows - unsigned, and will stay unsigned permanently. Windows code-signing certificates are an ongoing annual cost that this project isn't going to absorb. Expect a SmartScreen warning on first run forever; you'll need to click More info → Run anyway. The build itself is the same as everywhere else, just without a paid certificate vouching for it.

Updating

The desktop app automatically checks its own version against the latest published build - a small per-platform version-<os>.json on the Downloads page - and flags when a newer one is out, both on the Downloads page and as a marker in the sidebar. It's a version check, not a silent background updater; you decide when to install.

  • Windows - one-click in-app update. When a newer build exists, an Update now button downloads and runs the new installer for you and the app restarts - no manual re-download.
  • macOS & Android - re-download the new build from the Downloads page and install over the top. macOS can't install a .dmg in place the way Windows runs an .exe, and won't accept un-notarised in-app updates, so it stays manual until notarisation.

Until store releases happen, the web app remains the lowest-friction way to use Lumen. The native apps are useful if you want notifications when the browser tab isn't open, or simply prefer a real app icon.

Mobile (web)

The web app's UI is currently designed for desktop browsers. Mobile layouts exist in a rudimentary form but are not a focus of the current beta - spacing, touch targets and keyboard handling are all rough. Using the web app on a phone or tablet is not recommended at the moment.

For phones and tablets, the native iOS and Android apps are the supported path. A responsive redesign of the web app is planned.

Servers and channels

A server is a group space that contains text and voice channels. Anyone can create a server, and the user who creates it becomes its owner. Every new server gets a default general text channel automatically.

Who can do what

  • Creating a server - any signed-in user.
  • Creating, renaming, hiding and deleting channels - only the server owner.
  • Changing the server's join mode and creating invite codes - only the server owner.
  • Approving or declining applications - only the server owner (Apply-mode servers).
  • Inviting specific users to a hidden channel - only the server owner.
  • Sending messages in a text channel - any member of the server.
  • Joining a voice channel - any member of the server who can see the channel.

Lumen doesn't have a full role system yet - there's the owner and there are members. More granular roles (moderators, custom permissions) are planned.

Join modes

Each server has one of three join modes, picked by the owner:

  • Invite-only (default) - the server doesn't appear in the discovery directory. The only way in is an invite code.
  • Open - the server appears in the directory and any signed-in user can join with a single click.
  • Apply - the server appears in the directory, but joining requires submitting an application. The owner approves or declines each one; pending applications can also be cancelled by the applicant.

Discoverable servers (Open and Apply) are listed on the Discover page along with their member count and join mode. Invite-only servers are never listed there.

Invite codes

The server owner generates short invite codes from the server's settings. Each code becomes a shareable URL of the form /invite/<code>.

When creating a code, the owner picks two optional limits:

  • Expiry - 30 minutes, 1 hour, 6 hours, 12 hours, 1 day, 7 days, or never.
  • Max uses - 1, 5, 10, 25, 50, 100, or unlimited.

A code stops working once it expires, hits its use cap, or is manually revoked from the settings panel. Codes work regardless of the server's join mode - even an Apply-mode server can hand out a code that bypasses the application step.

Hidden channels inside a server

Individual channels - both text and voice - can be hidden from the server's general membership. A hidden channel is only visible to its owner (normally the server owner) and to users explicitly invited to that channel. Members of the server who haven't been invited to a hidden channel won't see it in their channel list and can't join it.

To invite someone to a hidden channel, open the channel's settings and pick the member to grant access. They'll see the channel appear in their list the next time it refreshes.

Voice and video calls

Lumen supports 1:1 voice and video calls between friends. You can start a call from a direct message conversation - calls are only available between users who are already friends.

Friend calls run on the same LiveKit SFU as server voice channels. When you place a call, the API mints a short-lived LiveKit token scoped to a freshly-created room, both clients join that room, and their media flows through LiveKit. The signalling (offer received, accept/reject, hang up) travels over the same SignalR connection used by the rest of the app - there is no direct SDP or ICE exchange between the clients themselves, which means calls work uniformly across NATs and mobile networks without needing a separate TURN relay.

During a call you can mute your microphone, end the call, and - for video calls - turn your camera on or off. Video calls show the remote participant full-screen with your own camera in a picture-in-picture corner.

Voice channels

Voice is built on LiveKit, a WebRTC SFU (Selective Forwarding Unit). Each server has voice channels you can join; inside a channel, your browser establishes a peer-to-SFU WebRTC connection, and the SFU forwards your audio to every other participant. This scales better than a pure peer-to-peer mesh and keeps the upload bandwidth cost per participant constant regardless of how many people are in the channel.

Signalling, presence ("who is in which channel"), and push-to-talk state are synchronised over the same SignalR connection used by the rest of the app.

Camera and screen-sharing

Voice channels carry video too. From the voice controls bar at the bottom of the sidebar, any participant can turn on their camera or share their screen - both publish into the same channel-scoped LiveKit room as your microphone, so only people currently joined to the voice channel can see them. A user publishing both camera and screen appears as two tiles in the channel view, side by side.

When nobody has video on, the channel pane shows centred avatar circles for each participant (with a green ring around whoever is speaking and a small mic-off badge for muted users). The moment anyone turns on a camera or starts sharing their screen, the layout switches to a tile grid where each video gets its own 16:9 tile. Self-camera tiles are mirrored selfie-style so they read like a real mirror.

Stream quality. When you start a screen share, a small dialog lets you pick the resolution (720p or 1080p), the framerate (30 or 60 fps), and whether to share the tab's audio along with the video. 1080p / 60 is suitable for streaming a game; 720p / 30 is gentler on bandwidth for static screens like an editor or document.

Streaming games to a voice channel

Because screen-share is a normal video publication on the same SFU as voice, you can use it to stream a game to the rest of the channel: start the game, hit Share screen from the voice controls, choose 1080p / 60 fps with Share audio on, and pick the game's window or display. Everyone in the voice channel sees your tile appear; they can click it to focus. Audio (game sound + your microphone) and video share the same LiveKit room, so latency is comparable to a normal voice call.

How a join is authorised

Joining a voice channel needs a signed LiveKit token, and Lumen issues that token in a single authenticated round-trip:

  1. The browser asks the BFF to join channel X.
  2. The BFF forwards the request to Lumen's API with your access token attached. The API verifies your token (TrustGate-signed), checks that you're a member of the server and (for hidden channels) have an accepted channel invite, then mints a LiveKit JWT scoped to that one channel's room.
  3. The BFF returns the LiveKit token plus the LiveKit server URL to the browser. The browser opens a WebRTC connection straight to LiveKit using that token.

The LiveKit signing secret lives only in the API - neither the BFF nor the browser ever sees it, so a compromised browser can't forge tokens for other rooms. Tokens are short-lived and scoped to the specific channel room, so they can't be reused to enter a different channel either.

Voice settings

Voice settings are reachable from the main app sidebar, so you can adjust them whether or not you're currently in a voice channel. Settings are stored per-device (localStorage in the browser, UserDefaults on iOS, SharedPreferences on Android), so they don't sync between devices but they apply across every server you're in:

  • Output volume - how loud other participants sound on your end.
  • Microphone volume - your input gain before it reaches the SFU.
  • Input mode - how your microphone decides when to transmit. Two options:
    • Voice activity (default) - the mic only transmits when your input crosses a threshold. With Auto adjust on (also default) the threshold tracks the ambient noise floor automatically; turn it off to set a fixed threshold yourself using the draggable marker on the input-level meter.
    • Push to talk - the mic only transmits while a bound key is held. Inside the browser this only works while the Lumen tab is focused; the desktop app registers the binding globally so it works while you're in another window or game.
  • Push-to-talk key - the keyboard binding captured for PTT, including modifiers.
  • Microphone and Sound output - pick a specific input or output device when more than one is connected. Browsers that don't support output-device selection (Safari, Firefox) fall back to the system default and the output picker is hidden.
  • Noise suppression - filters background noise from your microphone.
  • Echo cancellation - prevents your speakers from being picked up by your microphone.
  • Automatic gain control - normalises your microphone level over time.

Noise suppression, echo cancellation and AGC are all browser-provided (WebRTC). On some hardware and browsers the quality varies; if you have a good headset, turning AGC off is often an improvement.

Per-user volume and mute

Right-click (or long-press on mobile) any participant in a voice channel to open a small per-user menu. From there you can adjust how loud that one person sounds on your end, or mute them locally without affecting anyone else. The volume slider goes above 100% to boost a quiet talker; the UI flags values that are likely to clip on loud sources. Reset to default clears your override.

These preferences are local to your listening side - they don't change what the speaker transmits and aren't visible to other participants. They apply across every server and channel where you meet that person, but are stored per device, so a user you've muted in one browser will not be muted automatically on a different device.

Snaps

Snaps are single-view photos or short videos. They're encrypted on-device, uploaded as ciphertext, and deleted server-side once every recipient has opened them. Unopened snaps have a retention window after which they expire on their own. There is no re-watch, no saved history, and no "story" archive.

Recording a video snap is currently a native-app feature - the iOS and Android apps can capture both photos and video, while the web app captures stills only. The web app can still receive and play back video snaps sent from a phone; it just can't record one itself yet.

Snaps can only be sent to users you're already friends with - the same constraint that applies to direct messages and calls.

One thing worth being upfront about: Lumen cannot prevent the recipient from taking a screenshot or screen recording. On the web, there is no reliable way to detect or block either, and no capture notification is sent. Don't send a snap to someone you wouldn't trust with a copy of it.

Where the ciphertext is stored

The encrypted snap bytes don't live in MongoDB - only the metadata does (sender, recipients, per-device wrapped AES keys, expiry). The ciphertext blobs themselves are written to SeaweedFS, a small self-hosted object store running alongside Lumen on the same Synology NAS. SeaweedFS is split into three roles: a master that tracks where data lives, one or more volume servers that hold the actual bytes on disk, and a filer that maps human-readable paths (/snaps/{userId}/{snapId}.enc) to those bytes. Uploads go from the client directly to the BFF, which streams them to the filer over the private Docker network - the API is not in the upload path. Downloads flow through the API, which enforces access control before fetching from the filer.

Because the bytes are already ciphertext by the time they leave your browser, SeaweedFS sees only opaque blobs - losing access to the filer would not give anyone a way to read snap contents without also compromising every recipient's device-private key.

Games

Lumen currently includes five games: Chess, Battleship, Pong, Minesweeper, and Snake. All games are implemented end-to-end inside Lumen - there are no third-party game services. Game state is stored and validated on the server, so cheating by editing the client isn't possible: the server re-checks every move.

Multiplayer is built on a real-time connection (SignalR) between the browser and the API. When you make a move, the server validates it, updates the authoritative state, and pushes the update to the other player. Disconnects and reconnects are tolerated - games persist and resume where you left off.

Ranked play and ELO

Most human vs. human games are ranked using a standard ELO system, tracked per game. Your rating moves based on the rating difference between you and your opponent and whether you won, lost, or drew. Games against the AI never affect your rating, and casual / "friendly" challenges between friends can also be set to not count.

Each ranked game has its own leaderboard and per-user statistics.

Chess

Multiplayer

Move validation is delegated to Stockfish, running as a process pool inside the API. When you submit a move, the server replays the full move history through Stockfish, asks it whether the candidate move is legal in that position, and only then commits it to MongoDB and pushes the update to your opponent over SignalR. Because legality is checked by an actual chess engine rather than a hand-rolled validator, edge cases like en passant and pinned pieces are correct by construction.

Each move appends to a stored move list (UCI notation). Reconnecting clients fetch the game and replay it locally - there is no per-move snapshot, just the move sequence. Draw offers, resignations, and position evaluation (a small "win probability" gauge above the board) all flow through the same handler pipeline and emit SignalR events to the opponent.

AI play

The AI is also Stockfish, taken from the same pool. The pool size is configurable - typically equal to the number of physical CPU cores on the host minus one - so a request waits briefly for a free instance rather than spawning a new process per move. Difficulty maps to Stockfish's Skill Level rather than search depth, so lower settings play more like a beginner than a weaker deep-search opponent. AI games never affect your ELO.

Battleship

Classic Battleship on a 10×10 grid. Both players place a fleet, then take turns firing one shot at a time; first to sink the opponent's entire fleet wins. Ship layouts and the choice of fleet type (different total ship counts) are configurable per game.

Multiplayer

The authoritative game state lives in MongoDB. Each fire-shot is a CQRS command - the server checks it's your turn, that you haven't already shot that cell, and atomically records the result (miss, hit, sunk, or game-over) before pushing the update to both players over SignalR. The client never sees the opponent's ship positions until a ship is sunk; until then, the server only reveals "hit" or "miss" per cell.

Fleet placement is also server-validated: ships must stay inside the grid, must match the chosen fleet's ship sizes, and may not overlap. A separate "ready" handshake flips both players to placing → playing; reconnects after that point resume from the stored shot history.

AI play

The AI places its fleet randomly (with up to 500 retries per ship to avoid overlaps) and shoots using a hunt/target heuristic: when there are no unsunk hits on the board it picks a random cell on a checkerboard pattern (which guarantees coverage of any ship of length ≥ 2 with half the shots), and when a ship is wounded it switches to target mode and walks the row or column of contiguous hits in both directions until the ship is sunk. It's intentionally simple - a fixed-strength opponent for casual play. AI games don't affect ELO.

Pong

Real-time 1v1 paddle game played to a configurable number of points (default 11). Inputs travel over SignalR rather than REST so paddle changes hit the server with minimum overhead.

Multiplayer

Pong is the only game that runs a continuous physics loop on the server: a single BackgroundService ticks every active match at 60 Hz, advancing paddles and the ball, resolving wall and paddle collisions, scoring goals, and broadcasting the resulting snapshot to both players. Because the simulation is server-side, neither client can manipulate ball trajectory or paddle speed - they only send a directional input (-1 / 0 / +1) and receive snapshots.

To hide network latency, the client predicts its own paddle locally each frame using the same paddle speed and clamps as the server, then lerps gently toward each incoming server snapshot to absorb small drifts. Two refinements keep that lerp from showing up as visible jitter on noisier links: the correction is skipped entirely when local and server already agree within a small threshold, and the paddle is snapped directly to the wall whenever the server reports it clamped at a boundary - without this, local prediction would briefly drift past the wall and the lerp would yank it back. The ball is extrapolated from the most recent snapshot using its server-supplied velocity. The opponent paddle is interpolated rather than snapped so 60 Hz updates render smoothly at higher frame rates. Disconnects pause the game with a 45-second forfeit timer; reconnects resume the simulation from where it stopped.

AI play

The AI runs in the same physics loop and produces the same directional inputs a human would. On each "decision tick" (a difficulty-dependent interval, longer for easier opponents) it picks a target Y for its paddle: easy difficulty just tracks the ball's current Y, while medium and hard simulate the ball forward (including wall bounces) to predict where it will arrive at the right edge. A difficulty-scaled random error is added so even the hard AI occasionally misses cleanly. Between decision ticks the paddle just moves toward whatever target was last chosen, which is what gives the different difficulties their distinctive feel. AI games don't affect ELO.

Minesweeper

Two distinct modes share the Minesweeper UI: a classic singleplayer game and a turn-based two-player variant called Minesweeper Flags. The singleplayer game is human-only (there is no opponent to model), but Flags supports both human-vs-human and human-vs-AI play.

Singleplayer

Classic Minesweeper at three difficulties (easy / medium / hard) - clear every non-mine cell without triggering a mine. The board is generated and played entirely in the browser, including the first-click guarantee (your first click is always safe, with mines re-shuffled if needed) and the flood-fill that opens up zero-neighbour regions. The server only sees the final result: when a game ends, the client posts the difficulty, time taken, and win/loss outcome, and the server records it for personal best-time history. Singleplayer results don't appear on any leaderboard - leaderboards are reserved for ELO-ranked human-vs-human games.

Multiplayer (Minesweeper Flags)

Two players (red vs. blue) take turns clicking cells on a shared board. Clicking a safe cell ends your turn; clicking a mine flags it for your colour, scores a point, and gives you another turn. First to half the mines plus one wins (e.g. 6 of 10 on the small board). Three board sizes are available - 8×8 / 10 mines, 12×12 / 25 mines, and 16×16 / 51 mines.

Unlike singleplayer, the board lives on the server. Move validation, mine flagging, score updates, flood-fill on safe cells, and turn switching all happen server-side, with the new state pushed to both players over SignalR. An optional 15-second per-turn timer is enforced by a background service that atomically sweeps for expired turns and switches the turn over automatically. The first move of a game guarantees a safe cell with at least one zero-neighbour reveal, matching the singleplayer first-click rule.

AI play

Flags AI lives entirely server-side: the player picks difficulty (Easy / Medium / Hard), board size, and colour, and the AI is plugged in as a synthetic second player on the same code path as a human opponent - same persistence, same SignalR messages, same scoring.

AI games are persisted in MongoDB but do not count toward ELO or appear on the leaderboard, and the 15-second turn timer is forced off for AI matches so the runner is the only thing pacing the AI's side. Optimistic concurrency on the game's version field guards against races between the player's move and the AI runner overlapping in unusual sequences.

Snake

A real-time 1v1 take on the Achtung, die Kurve formula: each player drives a continuously moving curve that leaves a permanent trail behind it. You can't stop, you can only steer. First curve to crash into a wall, an opponent's trail, or its own trail loses the round; the match is played as a best-of (configurable from 1 to 9). An optional powerup toggle adds short speed boosts and periodic trail gaps you can slip through.

Multiplayer

The simulation runs server-side at 60 Hz inside a single background service that ticks every active match, advancing curves on a normalised arena and resolving collisions against a fine-grained collision grid. Snapshots are broadcast to both clients at 30 Hz over SignalR. Inputs are just steering deltas (-1 / 0 / +1), so neither client can influence trajectory beyond what the server simulates. Disconnects pause the match with a 45-second forfeit timer and resume cleanly on reconnect.

AI play

Three difficulty tiers, all running inside the same physics loop. Easy uses a 5-ray fan heuristic with heavy steering noise; medium uses a 9-ray fan with much less noise; hard forward-simulates the next ~150 ticks for each candidate steering angle and scores them with a flood-fill of remaining open space, which lets it avoid dead-ends and exploit gaps in the opponent's trail. AI matches don't affect ELO.

Privacy and security

Privacy is taken seriously where it matters most: direct messages and snaps. These are end-to-end encrypted in the browser before they reach the server, so the server only ever stores ciphertext it can't read.

What is end-to-end encrypted

  • Direct messages - one-to-one text chats between friends.
  • Snaps - the photo or video itself is encrypted on-device and uploaded as ciphertext.

Text channels use server-side encryption rather than end-to-end encryption - by design. A server's text channels are group spaces, often with many members and open join, where moderation, search, and a server owner's ability to manage their own community all depend on the server being able to read message content. This is the same trade-off every group-chat platform makes - Discord and Slack don't end-to-end encrypt channel messages either - so end-to-end encryption is reserved for the places it genuinely fits: one-to-one direct messages and snaps.

That doesn't mean channel messages sit in the clear. They're encrypted at rest with AES-256-GCM under a server-held key, so a dump of the raw database yields ciphertext rather than readable content.

How the encryption works, in plain terms

Each device you sign in on - each browser on each machine - generates its own RSA-OAEP-2048 key pair locally. The public half is registered with the server; the private half never leaves the device. On the web it lives in the browser's IndexedDB; on iOS in the Keychain (hardware-backed where the device supports it); on Android in the AndroidKeyStore.

Alongside the per-device keys, your account has a single master keypair. The master public key is treated as if it were just another device in your wrapping list. The master private key is encrypted on-device with a key derived from your account password via Argon2id (memory-hard, deliberately slow), and the resulting ciphertext is stored on the server. The wrapping key itself never leaves your device - the server only ever sees the wrapped blob.

When you send a direct message or snap, a fresh AES-256-GCM key is generated for that one message, used to encrypt the content, and then re-encrypted ("wrapped") for every device of the sender and recipient and for the sender's and recipient's master public keys. Every one of your devices, every one of your recipient's devices, plus whichever device you next sign in on can therefore decrypt the message - but only that specific set.

Signing in on a new device

Adding a new device works automatically as soon as you sign in:

  1. The new device generates its own per-device key pair and registers the public half with the server.
  2. It fetches your wrapped master key, derives the unwrapping key from the password you just entered (Argon2id, on-device only), and unwraps the master private key in memory.
  3. It uses the master private key to read the master-wrapped entry on each of your historical messages, then re-wraps those AES keys for its own device public key. From that point on the new device decrypts directly with its own private key and the master key isn't needed.

Your password is the recovery factor. No other device needs to be online for a new install to recover history - only the password. If you reset your password via the "Forgot password" link and are still signed in somewhere, the next sign-in on that signed-in device automatically rewraps the master key under the new password and keeps you recoverable on future devices. If you reset your password with zero signed-in devices anywhere, history wrapped with the old password is unrecoverable. Messages sent while a device wasn't yet known to the system simply won't include that device in their wrapped-key map and can't be decrypted by it later. This matches how standard E2E chat apps behave.

What is stored in the database

A rough inventory of what's on the server:

  • Direct messages - ciphertext only, plus the per-device wrapped AES keys needed for delivery. The server cannot read the content.
  • Snaps - ciphertext only, same key-wrapping model as direct messages. Snaps are short-lived: they are deleted once every recipient has opened them, or automatically after a retention window if left unopened. The server cannot read the content.
  • Text channel messages - encrypted at rest (AES-256-GCM, server-held key). Decryptable by the running Lumen server, not by anyone with a raw database dump.
  • Voice and video - no recordings. LiveKit forwards audio, camera, and screen-share tracks peer-to-SFU and the server does not record or transcribe any of it. The only persisted voice data is channel metadata and who was in which channel at a given time.
  • Account data - username, email, password hash (managed by TrustGate, not Lumen), profile details you set, friends list, device public keys, and the Argon2id-wrapped master private key (the wrapping key never reaches the server).
  • Game data - move history, results, ratings.

The database itself (MongoDB) is hosted on a personal Synology NAS behind the home network, with external access limited to the application over TLS.

TrustGate (single sign-on)

Lumen does not store your password. Sign-in is delegated to TrustGate, a single-sign-on provider that handles authentication for Lumen and a handful of related personal projects developed and run by the same developer (JSI-IT). TrustGate stores a salted PBKDF2/HMAC-SHA256 hash of your password (the ASP.NET Identity default - not the plaintext), handles password resets, and issues short-lived signed tokens that Lumen's API verifies on every request.

What this means in practice:

  • Your password only ever lives in TrustGate's database, not in Lumen's.
  • The same account can be used across any project that integrates with TrustGate - you don't need a separate account per app.
  • Password changes and resets happen in one place and are effective everywhere immediately.

Tokens are held only in a server-side, encrypted, HTTP-only session cookie issued by Lumen's own backend-for-frontend. They are never exposed to JavaScript in the browser, which limits the impact of any potential cross-site scripting bug.

A note on trust

TrustGate is self-built and self-hosted on the same personal infrastructure as Lumen - it isn't a third party like Google or Auth0. TrustGate's job is narrow (verify passwords, issue tokens), the design follows the same standards used by mainstream SSO providers, and Lumen's server is built so it can verify tokens but cannot mint them.

How sign-in works

  1. You enter your username and password on Lumen. The credentials are sent to Lumen's backend-for-frontend (BFF) over TLS, then forwarded to TrustGate.
  2. TrustGate looks up your account, verifies the password against the stored PBKDF2 hash, and (if it matches) issues a short-lived access token plus a longer-lived refresh token.
  3. The BFF stores both tokens in an encrypted, HTTP-only session cookie. Your browser never sees the tokens directly - JavaScript on the page cannot read them.
  4. On every request to Lumen's API, the BFF attaches the access token. Lumen's API verifies the signature locally and reads your user ID from the token. No round-trip to TrustGate is needed per request.
  5. Access tokens last about an hour. When they expire, the BFF transparently swaps in a refreshed one using the refresh token; you won't notice unless you've been signed out for a long time.

How tokens are signed

Each application that integrates with TrustGate gets its own dedicated signing keypair. The algorithm is ECDSA on the P-256 curve (commonly written ES256), one of the standard signing algorithms used by mainstream OpenID Connect providers.

A keypair has two halves:

  • The private half is used to create tokens. It never leaves TrustGate. It is stored in TrustGate's database encrypted at rest with AES-256-GCM, using a master key that lives only in TrustGate's runtime environment - so a raw database dump on its own does not yield a usable signing key.
  • The public half is used to verify tokens, and is published openly at a standard URL on TrustGate (a JSON Web Key Set, or JWKS). Lumen's API fetches Lumen's public key from that URL on startup and caches it.

Because the two halves are mathematically asymmetric, possessing the public key lets you verify a signature but not produce one. This means even full compromise of Lumen's application server does not give an attacker the ability to forge tokens - neither for Lumen itself nor for any other app on TrustGate. The forgery capability lives only inside TrustGate. It also means a leak in one app cannot impersonate users in another app, because each app has its own independent keypair.

Token rotation and revocation

Tokens are deliberately short-lived (about an hour for access tokens, longer for refresh tokens) so that even an unintended copy of a token has a limited useful lifetime. Signing keys themselves can be rotated in TrustGate without any downtime: a new key is generated, takes over signing, and the old key keeps verifying existing tokens until they expire - Lumen picks up the new key automatically the next time it refreshes its public-key cache. If a key ever needs to be revoked immediately, it's removed from the published set and any token still signed with it stops being accepted.

What TrustGate stores about you

  • Username and email.
  • A salted PBKDF2/HMAC-SHA256 hash of your password (ASP.NET Identity's default, currently 100,000 iterations) - never the plaintext. The iteration count is intentionally high so that even the hash is hard to reverse offline.
  • The list of applications you're allowed to sign in to (so an account on TrustGate doesn't automatically grant access to every app integrated with it - Lumen access has to be explicitly granted).
  • Refresh tokens, stored as salted SHA-256 hashes - never the original token.

Hosting

TrustGate runs in a container on the same Synology NAS that hosts Lumen, backed by its own MongoDB instance. External traffic is terminated at the Synology's reverse proxy with TLS; internal service-to-service communication between Lumen and TrustGate happens over the host's private Docker network and never traverses the public internet.

Who can see what

  • Direct messages - only you and the other participant.
  • Snaps - only the recipients you selected.
  • Text channels - every member of the server who can see the channel.
  • Voice channels - anyone currently joined to the channel can hear you. Who's currently in a channel is visible to other members of the server who can see the channel.
  • Presence - your online status is visible to your friends.
  • Games - opponents see the game; the leaderboard and your rating are visible to anyone who opens the leaderboard.

Profile and device management

Your profile panel is where you manage devices, change your password, and sign out. To open it, click your avatar in the tab bar on the left of the main Lumen interface (the circular badge with your initials at the bottom of the sidebar).

Trusted devices

The profile panel lists every browser or device you've ever signed in on, along with when it was added and when it was last seen. Revoke removes a device from the list - revoked devices can no longer decrypt new messages sent to you, and they lose access to your account when their session expires.

Change password

The Change password button in the profile panel takes you through the password change flow. Because authentication is handled by TrustGate, changing your password here changes it for every project that shares the same sign-in. After the change succeeds, Lumen also rewraps your master key with the new password locally so future devices remain recoverable.

Account recovery

Password recovery is handled by TrustGate. If you forget your password, the "Forgot password" link on the sign-in page will email you a reset link.

Encryption keys ride along with your password through the Argon2id-wrapped master key described under Privacy and security. As long as you successfully sign in with a password that matches the one your master key was wrapped with, history is recoverable on any new install - no other device needs to be online. If you've reset your password since the master key was last wrapped, sign in on any device that's still authenticated to rotate the wrapping; from that point onward future devices recover with the new password.

The single failure mode is: you reset your password and have no device left signed in to re-wrap. History encrypted to the old password becomes unreadable. New messages from that point on are wrapped to the fresh master key and behave normally. This is a deliberate trade-off of end-to-end encryption and applies to any E2E messenger that doesn't escrow plaintext keys.

Deleting your account

There are two levels of deletion, because your account straddles Lumen and TrustGate:

  • Leave Lumen only. Your Lumen profile, direct messages, snaps, game history and friend connections are removed. Any servers you own are deleted as well (cascade) - this takes their channels, channel messages, invites and membership data with them. Members of those servers will see them vanish from their list. Your TrustGate account is left intact, so you can continue using any other JSI-IT project that shares the same sign-in, and you can re-join Lumen later if you change your mind.
  • Delete your SSO account entirely. This removes your account in TrustGate and, as a consequence, removes access to Lumen and every other project that authenticates through TrustGate. Data tied to your account across those projects is deleted.

Both options are available from account settings. Deletion is irreversible: once processed there is no way to recover messages, snaps, rating history, or the servers and channels you owned, and any remaining ciphertext belonging to you in other users' mailboxes becomes permanently unreadable.

Tech stack

  • Frontend - Vue 3 + Nuxt 3 (TypeScript), running on Node.
  • Backend-for-frontend - Nuxt's Nitro server, which proxies API calls and holds the session cookie so tokens never reach the browser.
  • API - .NET 10.
  • Real-time - SignalR for presence, voice signalling, game state, and message fan-out.
  • Voice - LiveKit (WebRTC SFU).
  • Database - MongoDB.
  • Blob storage - SeaweedFS, used to hold encrypted snap blobs outside MongoDB. Master, volume and filer run as three containers on the same host.
  • Cache - Redis, used as the distributed cache and SignalR backplane.
  • Rate limiting - applied in the BFF (Nitro) per client IP, using an in-memory fixed-window limiter. Anonymous flows (request access, forgot password, validate reset token) are the primary targets.
  • Chess AI - Stockfish, process-pooled inside the API.
  • Auth - TrustGate (PBKDF2 password hashing + ES256-signed JWT), session cookie in the BFF.
  • Encryption - Web Crypto API in the browser: RSA-OAEP-2048 for key wrapping, AES-256-GCM for message content, private keys stored in IndexedDB. AES-256-GCM is also used server-side for at-rest encryption of text channel messages.
  • Observability - OpenTelemetry traces shipped to a self-hosted Aspire dashboard.
  • Logging - Seq (self-hosted), used for application logging.
  • Hosting - Synology NAS on a home network, deployed via a self-hosted GitHub Actions runner, fronted by Synology Reverse Proxy with Let's Encrypt TLS; nginx handles reverse-proxy routing between containers internally.
  • Anti-abuse - Cloudflare Turnstile on sign-up and password reset flows.

Logging

Lumen logs the minimum it needs to operate. In normal use:

  • Application logs - structured logs of requests and errors. These do not tie a request to a user's IP address in ordinary operation.
  • IP addresses - used transiently for rate limiting (sign-in attempts, password reset requests, account requests, etc.). They are not written to an application-level audit log unless the system detects abuse - repeated failed sign-ins, credential stuffing patterns, or other clearly malicious behaviour - in which case they may be recorded for investigation and blocking.
  • Infrastructure logs - the reverse proxy, LiveKit server, and operating system write their own access logs at the infrastructure layer. These are outside the application and may contain IP addresses as part of standard server logging. They are not combined with application data.

Log retention is short. None of this is sold, shared, or used for analytics.

Data, backups and uptime

Lumen runs on a Synology NAS on a domestic internet connection. That is a real constraint and it shapes the expectations you should have:

  • There is no multi-region failover, no hot standby, and no SLA. Power cuts, ISP outages, and routine maintenance all cause brief downtime.
  • Backups of the database are taken, but restore-from-backup is a manual operation. During beta, a catastrophic failure could in principle lose a small amount of recent data.
  • Media (snaps, avatars) are stored on the same NAS. They are covered by the same backup policy.

Beta status

Lumen is in beta. Expect bugs, the occasional feature that behaves strangely, and cosmetic rough edges. Data loss is unlikely but not impossible. Features may change shape between releases.