WeJure is a peer-to-peer social network written in ClojureScript. Data lives in GunDB, a decentralized graph database that syncs between browsers through relay servers; images live on IPFS. Accounts, signatures and end-to-end encryption use SEA, GunDB's cryptography layer.
- Accounts with a profile picture and a bio
- Posts with text and/or an image, kept in the composer until the relay has stored them; a feed of the people you follow; deleting your own posts, which disappear from other users' screens right away
- Following and unfollowing users
- Finding users by their exact name, and searching the posts of the people you follow by text
- End-to-end encrypted direct messages, and blocking users
- Group chat with layered encryption (backend only, see Status)
WeJure is built with shadow-cljs, which needs:
- Node.js
- Yarn 1.x
- Java SDK (Adoptium) 21 or newer
Running it also needs an IPFS node (Kubo).
The setup scripts install the requirements, initialize IPFS and run yarn install.
macOS
./WejureStartup.shLinux
./setup_wejure_linux.shWindows
Set-ExecutionPolicy RemoteSigned -Scope Process # Allow script execution
.\WeJureWinStart.ps1macOS and Linux
./WejureRun.shWindows
.\WeJureWinRun.ps1Then visit http://localhost:8020.
The scripts start three processes, which can also be started by hand:
- the IPFS daemon:
ipfs daemon - the GunDB relay:
cd src/wejure/js && node relay - the shadow-cljs dev server:
yarn dev
For an optimized build, run yarn release; it writes public/js/main.js. Serve public/ with unknown paths falling back to index.html, since the app routes with real URLs such as /home.
- IPFS: the app uploads through the API on port 5001 and loads images from the gateway on port 8080 (both set in
src/wejure/common.cljs). If the API listens elsewhere, runipfs config Addresses.API /ip4/127.0.0.1/tcp/5001. The API must allow requests from the page, which the setup scripts configure:ipfs config --json API.HTTPHeaders.Access-Control-Allow-Origin '["http://localhost:8020", "http://localhost:5001", "https://webui.ipfs.io"]' ipfs config --json API.HTTPHeaders.Access-Control-Allow-Methods '["PUT", "POST"]'
- GunDB relay:
src/wejure/js/relay.jslistens on 127.0.0.1:8001 (override with theHOSTandPORTenvironment variables). Users only see each other's data when their browsers reach a common relay (browsers that share one also connect to each other directly over WebRTC, found through the relay), so for more than one machine, runrelay.json a reachable host (for exampleHOST=0.0.0.0 node relay) and add its URL topeersinsrc/wejure/js/db.js. The relay loads SEA, so it checks signatures itself, and it refuses writes to the node names of the old shared data model (user,post/*,chat,groups,groupKeys,groupChat).
- Accounts and identity: GunDB SEA creates a key pair per user. A user's public key (
pub) is their identity; the username is an alias registered in SEA's account system (~@<alias>) and repeated in the signed profile. Passwords are SHA-256-hashed before they reach SEA. - Data ownership: everything a user owns (profile, posts, follows, blocks, chats, groups) lives in their SEA user space
~<pub>. Every write there is signed, and writes not signed by the owner are rejected by the writer's own client, by the relay and by every reader, so nobody can change or delete another user's data.test/e2e/security.spec.jschecks this. - Usernames: aliases are first come, first served, but an open network cannot rule out two accounts racing for the same name. If more than one account claims an alias,
/user/<alias>lists the claimants instead of picking one, so a later registration cannot take over an existing user's page./user/~<pub>always names exactly one account. - Direct messages: the sender and the recipient derive the same shared secret with ECDH (
SEA.secret(recipientEpub, senderPair)), and each message is encrypted with AES-GCM (SEA.encrypt(message, secret)). Only the two participants can read it. Each participant writes their messages into their own user space, so who sent a message is determined by where it is stored, not by a field in it. - Reaching someone new: nobody can write into another user's space, so a first message (or a group invitation) also leaves a pointer in the public node
inbox/<pub>. The pointer is signed by the sender; the recipient's client verifies it and then records the conversation in its own space. Unsigned or forged pointers are ignored. The inbox is the one place others can write to, so it can be spammed, but a vandalized entry loses at most a notification, never a message. - Blocking: the blacklist is encrypted with the owner's own key, so nobody else can see who blocks whom. Blocking works on the blocker's side: their client ignores the blocked user, who is not told.
- Group chats: the group's definition lives in its creator's user space, so only the creator can change membership. Every membership change starts a new layer with a fresh random key, which the creator encrypts separately for each member of that layer. Members can decrypt exactly the layers they belonged to: new members cannot read messages from before they joined, and removed members cannot read messages sent after they left.
- Public data: profiles, posts, follows and which conversations a user has (not their contents) are signed but stored unencrypted.
GunDB paths (a/b means gun.get("a").get("b"); ~<pub> is a user's SEA user space, written through gun.user()):
| Path | Content |
|---|---|
~<pub>/profile |
{alias, icon_cid, bio}; icon_cid is an IPFS CID |
~<pub>/follows/<pub> |
true, or null once unfollowed (users follow themselves, so their own posts appear in their feed) |
~<pub>/blocks/<pub> |
SEA.encrypt(true, ownPair), or null once unblocked |
~<pub>/posts/<post key> |
{text, image, unixtime}; the key is <unixtime>-<random>, so posts made in the same millisecond still get distinct keys |
~<pub>/chats/<roomID> |
{peer, last_message_time}: the owner's record of a conversation; roomID is the SHA-256 of the two pubs, sorted and joined with - |
~<pub>/chats/<roomID>/msgs/<time ms> |
the owner's sent messages, encrypted {timestamp, sender, content} |
~<pub>/chats/g:<groupID> |
{creator, last_message_time} for each group |
~<pub>/groups/<groupID> |
{group_name, created, totallayer}, for groups this user created |
~<pub>/groups/<groupID>/<layer>/members/<pub> |
true |
~<pub>/groups/<groupID>/<layer>/keys/<pub> |
the layer key, encrypted for that member |
~<pub>/groupChats/<groupID>/<layer>/<time ms> |
the owner's group messages, encrypted with the layer key |
inbox/<pub>/<roomID or groupID> |
{from, sig}: a pointer to a conversation or group, signed by from |
The record fields above are built and read only in src/wejure/js/records.js. Release builds compile src/wejure/js/ with Closure's advanced optimizations, which rename unquoted property names; records.js writes every field with a quoted key, so they keep these names. Add new fields there, not as object literals or dot access in the other modules.
The browser keeps the session in sessionStorage: username, pub, icon_cid and the SEA key pair pair.
Data written before the ownership redesign (October 2026) used shared nodes keyed by username, which anyone could overwrite. It is not migrated: stop the relay, delete its radata directory (./remove_local_gun.sh for a relay run from src/wejure/js/), update, and restart. Users re-register; the new relay refuses writes to the old node names.
src/wejure/core.cljs: routes, and redirects between logged-in and logged-out pagessrc/wejure/common.cljs: session and IPFS helperssrc/wejure/components/: one namespace per page, plus the navigation bar andpostCard.cljs, the post list shared by the feed, profile and post-search pagessrc/wejure/components/textField.cljs: the MUI text field every page uses; it keeps the caret in place while typingsrc/wejure/js/: the GunDB layer, called from ClojureScript with callbacks or promises:db.js(the shared GunDB instance, plus helpers for writes the relay confirms and for subscriptions a page stops when it closes),records.js(the stored record formats),account.js,profile.js,post.js,chat.js; andrelay.js, the relay servertest/e2e/: the end-to-end tests:wejure.spec.js(every page, two users),security.spec.js(data ownership) andreliability.spec.js(posts the relay does not confirm, deletions reaching other users, chat subscriptions ending with the conversation)
yarn test:e2e builds the release version and runs a browser test through every page with two users, plus tests that anonymous clients and other logged-in users cannot change anyone else's data, and tests of posting while the relay is unreachable, of deletions reaching other users and of chat subscriptions ending with the conversation. Install its browser once with npx playwright install chromium. The test starts its own relay (with empty data), an IPFS stand-in and a web server, on the ports the app uses (8001, 5001 and 8080, 8020), so stop any IPFS daemon, relay or dev server first.
- Clear the site data (local storage) in every browser that opened WeJure.
- Stop the relay and run
./remove_local_gun.sh, which deletessrc/wejure/js/radata.
The project is still under development.
- Group chat is implemented in
src/wejure/js/chat.js(createGroup,addMemberToGroup,removeMemberFromGroup,storeGroupMessage,displayGroupMessages), but the chat page has no UI for groups yet. - Unblocking (
removeFromBlacklistinchat.js) has no UI yet.