Serverless peer-to-peer team collaboration — channels, chat, progressive file sharing, and video calls over WebRTC. Built with React, Vite, Tailwind CSS + DaisyUI, and Trystero. Peerly has no application backend that stores workspace messages or files; signaling services are used only to help browsers discover each other.
Highlights: the P2P room core ships as the npm package @peerly/core, reusable by other apps — alongside messenger attention, signed message actions, rich file/call workflows, channel management, an installable offline shell, URL routing, complete English/Polish UI, and accessibility hardening. The running app always shows its exact version and commit in the UI.
Live app: peerly.cc
Per-screen behavior, routes, and major functions: docs/views.md.
- Invite-only workspaces — a high-entropy workspace ID in the URL fragment doubles as the room encryption secret; share the full signed invite to grant access
- Multiple workspaces — joined workspaces are remembered per browser; sign in once, pick a workspace from the join screen, and switch later without the invite link
- Workspace appearance — rename a workspace and upload a custom icon from workspace settings; stored locally per browser and shown in the sidebar and picker
- Light and dark themes — follows the operating-system preference by default and stores an explicit choice per device
- Identity separate from workspace — leave a workspace without losing your identity; return to the picker while remaining signed in
- Verified identity — sign in with Google, Microsoft, Apple, or generic OIDC; peers verify JWTs client-side via JWKS
- Creator-signed allow-list — only invited email addresses can join; enforced cryptographically in the P2P handshake
- Creator-only invites — only the device that created a workspace can add members to the allow-list; anyone can copy the invite link
- Device-bound auth — ECDSA challenge-response prevents replayed identity tokens
- Session continuity — warns before the current ID token expires and offers same-account reauthentication so new peer handshakes keep working
- Managed channels & DMs — scoped messaging over one encrypted P2P room, with channel rename/delete/reorder and locally closable DM threads
- Reader-friendly history — incoming messages do not pull you away from older history; a new-message pill returns to the latest messages
- Messenger attention — unread totals update the tab title and favicon; users can explicitly opt into background DM notifications and local attention sounds, including an incoming-call ringtone
- Signed message actions — HTTPS links are safely linkified; authors can edit/delete with signed revisions, and reactions carry their own identity-bound signatures
- Fast file input — attach multiple files, paste clipboard images/files, or drag files onto the composer; selections process sequentially to cap peak memory
- Progressive file sync — text and thumbnails sync first; full-size file bodies download on demand by default, with a device-wide automatic mode available
- Storage visibility — approximate browser quota, available space, pressure warnings, per-workspace usage, and separate actions for freeing cached originals or clearing local history
- Workspace backups — exports signed workspace-channel history and access as JSON, then safely merges it back from the workspace picker
- Local sensitive-media screening — NSFWJS checks image/video attachments and samples remote video streams locally; flagged media stays hidden until revealed
- Video calls — incoming-call awareness, screen sharing, camera/microphone selection, and WebRTC media with TURN support for strict networks
- Installable offline shell — a service worker caches the production app shell and loaded release assets so local history remains reachable without signaling
- English and Polish UI — the device language preference covers navigation, settings, chat, files, calls, storage, invites, confirmations, placeholders, and accessibility labels
- Approved-device sync — the My Devices screen uses mutual, fingerprint-confirmed approval to link a second signed-in device; allowlisted local data then merges directly while both devices are online, and approved devices can sign edits/deletes of the account's earlier messages
- Visible P2P transfers — the Sync activity tab shows metadata-only sent/received events, transfer sizes, data categories, and the known friend, workspace member, peer, or approved device involved; message bodies and secrets are never logged
- Accessibility hardening — deterministic view focus, polite incoming-message announcements, clearer light-theme contrast, and motion-aware navigation
- Connectivity diagnostics — distinguishes local WebRTC availability, a verified peer connection, signaling failure, and paths that need TURN
- Build stamp — version and git commit shown in the UI so you can confirm what is deployed
Requires Node 24.x and npm 11.x — the majors that wrote the lockfile. Majors are what matter: different npm MAJOR releases can rewrite optional dependency records incompatibly, while patch releases do not. Exact-patch pins were tried and abandoned twice — they turn any tool-mirror gap into a failed install (Cloudflare's builder could not fetch one specific patch and every deploy failed).
nvm install 24
nvm use 24
npm --version # must print 11.x
git clone https://github.com/XeonFX/Peerly.git
cd Peerly
npm ci
cp .env.example .env # add at least one identity provider (see below)
npm run devOpen the printed URL in two browser tabs (or share an invite link). Peers connect in a few seconds over public Nostr signaling.
npm run dev:relay # local WebSocket relay instead of Nostr (offline / CI-like)
npm run stop # kill common dev ports- Sign in with a configured identity provider.
- Create a workspace (name + initial member emails) or join via an invite link (
#invite=…in the URL). - Share the invite link from the sidebar; only the creator's device can add more emails to the allow-list.
The workspace secret never appears in the UI — it lives only in the invite link fragment and local storage.
The default on-demand mode is designed for fast joins and constrained browser storage:
- Channel and message history syncs first.
- File metadata and small WebP thumbnails travel with history.
- Full-size file bodies download only when opened.
Workspace settings can enable Auto-download full files for every workspace on that device. Automatic downloads pause when browser storage reaches warning or critical pressure; text and metadata sync keep working. The storage card uses navigator.storage.estimate(), so quota and available-space values are approximate and browser-specific.
Storage actions are local:
- Free local space removes reclaimable cached originals while retaining messages, previews, and the metadata needed to request files again.
- Clear local history removes that workspace's messages, previews, read state, and cached bodies that no other local workspace references while retaining workspace access.
Neither action deletes content from other members. Re-sync requires at least one peer with the relevant history or file body to be online; Peerly has no global cloud archive.
Export backup in workspace settings saves the newest 500 messages from each workspace channel together with channel structure and signed workspace access. Protect the JSON like an invite link. Imports verify the creator-signed allow-list and message signatures, bound untrusted data, and merge without overwriting local messages. DMs and full-size file bodies are excluded.
NSFWJS and its MobileNetV2 model are loaded lazily only when visual media needs checking. Peerly serializes classification and backs off live-video sampling after clean frames; sampled frames are neither uploaded nor persisted. Shared images, video-file samples, and visible remote video streams can be blurred behind a reveal action.
This is a receiver-side privacy aid, not moderation or access control. Classification deliberately fails open when the model or browser graphics backend is unavailable, and a modified peer can bypass its own outbound checks.
Workspace access is decided by the verified email on an ID token, so a
provider only works here if it (a) issues a signed OIDC ID token in the browser,
(b) includes the user's email, (c) asserts that email is verified, and (d)
supports nonce (which binds the token to your device key). Set at least one
in .env, then restart the dev server.
| Provider | Variables | Notes |
|---|---|---|
VITE_GOOGLE_CLIENT_ID |
Simplest. Works out of the box. | |
| Microsoft | VITE_MICROSOFT_CLIENT_ID, VITE_MICROSOFT_TENANT_ID |
Tenant is required; needs email + xms_edov optional claims. |
| Apple | VITE_APPLE_CLIENT_ID, optional VITE_APPLE_REDIRECT_URI |
Needs a Services ID + verified domain. |
| Generic OIDC | VITE_OIDC_CLIENT_ID, VITE_OIDC_ISSUER, optional VITE_OIDC_LABEL |
IdP must allow implicit id_token for a SPA. |
- Google Cloud console → Create credentials → OAuth client ID → Web application.
- Authorized JavaScript origins: every origin the app is served from —
http://localhost:5173for dev, plus your production origin. No redirect URI is needed (Google Identity Services returns the token via callback). - Configure the OAuth consent screen (External is fine; while in Testing only listed test users can sign in).
- Copy the client ID →
VITE_GOOGLE_CLIENT_ID.
When a Google token approaches expiry, Peerly prepares a fresh same-account sign-in button in the workspace banner. Google Identity Services does not provide a truly silent sign-in flow; credentials are returned through privacy-preserving automatic or manual UI.
- Entra portal → App registrations → New registration.
- Supported account types: Accounts in this organizational directory only. Multi-tenant is refused at startup — see the security note below.
- Redirect URI: platform Single-page application, value = your exact origin (
http://localhost:5173, and your production origin). - Authentication → Implicit grant: tick ID tokens.
- Token configuration → Add optional claim → ID → add both
emailandxms_edov. - Copy Application (client) ID →
VITE_MICROSOFT_CLIENT_ID, and Directory (tenant) ID →VITE_MICROSOFT_TENANT_ID.
Step 5 is not optional. Azure never emits the standard email_verified; xms_edov
("email domain owner verified") is its equivalent, and without it no Microsoft
user can be admitted. Step 2 matters because Azure lets a tenant admin set an
account's email to any unverified value — with multi-tenant, anyone could
register a free tenant, assert one of your members' addresses, and walk in
(Microsoft's documented "nOAuth" abuse). Pinning one tenant reduces that to
"an admin of your own directory", who you already trust.
- Apple Developer → Identifiers → Services IDs → create one (e.g.
com.example.peerly). - Enable Sign in with Apple, then Configure: add your domain and a Return URL matching your origin. Apple requires a verified domain and does not accept
localhost— for local dev use a tunnel, or just use Google. - Services ID →
VITE_APPLE_CLIENT_ID; setVITE_APPLE_REDIRECT_URIif it differs fromwindow.location.origin.
- Create a SPA / public client in your IdP.
- Allow implicit
id_tokenand theopenid profile emailscopes; redirect URI = your origin. - Ensure the ID token carries
emailandemail_verified— the app rejects tokens without a verified email. VITE_OIDC_ISSUER= the issuer URL (the app reads<issuer>/.well-known/openid-configuration),VITE_OIDC_CLIENT_ID= the client ID.
GitHub does not implement OIDC for user sign-in. Its
discovery document
advertises claims_supported: [sub, aud, exp, nbf, iat, iss, act] — no email
and no nonce — and there is no userinfo_endpoint. Its plain OAuth returns an
opaque access token instead, which a peer cannot verify without a server (and
only by handing that server the token). Both are load-bearing here, so GitHub
cannot be supported without changing the trust model. It was removed rather than
left as a button that always fails.
Browsers need a signaling channel to discover each other (WebRTC handshake). Application data stays P2P afterward.
| Mode | When | Config |
|---|---|---|
| Nostr (default) | Dev & deploy — public signaling, no application server | None |
| ws-relay | Offline / local CI | npm run dev:relay or VITE_SIGNALING=ws-relay |
| Supabase | Relay you control | VITE_SIGNALING=supabase + Supabase URL/key |
npm run test:e2e uses a local relay (many connections from one IP would throttle public Nostr relays) and runs 4 workers in parallel — each worker gets its own workspace/room, so tests never meet each other. npm run test:e2e:nostr runs a small subset against public relays, deliberately serial.
Deployment owners can replace the curated Nostr set with the build-time VITE_NOSTR_RELAYS variable. Peerly does not currently expose relay editing to end users: members need at least one signaling relay in common, so a safe user-facing design must distribute a workspace relay profile rather than silently changing one device.
For strict NAT / corporate firewalls, configure your TURN URLs. The browser
obtains short-lived REST credentials from /api/network/credentials:
VITE_TURN_URLS=turn:your-turn.example:3478,turns:your-turn.example:5349 \
npm run buildCore expands conventional TURN endpoints into UDP 3478, TCP 3478, TLS
5349, and TLS 443 candidates. Expose TURN/TLS on 443 for networks that
block non-HTTPS ports; with one public IP, route TCP by TLS SNI so turn.*
reaches coturn and relay.* continues to reach HTTPS/WSS.
Peerly is a static SPA. Build once, serve dist/ from any static host.
npm run buildOutput goes to dist/. The build runs a bundle guard that fails if E2E test key material leaked into the production bundle.
The committed wrangler.jsonc deploys dist/ with an SPA fallback and runs worker/index.mjs first for /api/* requests. All other requests keep the static-assets-first path.
| Setting | Value |
|---|---|
| Build command | npm run build |
| Deploy command | npx wrangler deploy (default) |
| Non-production deploy | npx wrangler versions upload (default) |
| Root directory | (repo root) |
| Node version | 24 via .nvmrc (any 24.x the builder has) |
The connected Worker must be named peerly, matching wrangler.jsonc. The default deploy commands obtain Wrangler through npx; it is intentionally not installed as an application dependency.
Cloudflare may print its image-default npm@10.9.2 during initial tool detection. Installing the .nvmrc Node 24 override then exposes that runtime's bundled npm 11.x, which is what runs npm clean-install. devEngines still hard-fails any non-24/non-11 pair before it can touch the lockfile.
Environment variables (Production → Settings → Environment variables):
VITE_GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
Add TURN, signaling overrides, or other providers as needed (see .env.example).
Register the production origin (https://peerly.cc) in each OAuth provider's allowed JavaScript origins / redirect URIs.
Cloudflare injects WORKERS_CI_COMMIT_SHA at build time, which appears in the UI as v<version> · <commit>.
Cloudflare Pages also works: use npm run build, publish dist/, and set the same build-time environment variables.
Google OAuth requires exact JavaScript origins and does not accept a wildcard
for Cloudflare's per-branch URLs. Set VITE_GOOGLE_AUTH_BRIDGE_ORIGIN on
preview builds to render the Google button through your stable auth hostname.
The bridge returns the ID token directly to the requesting preview with an
origin-locked postMessage; the preview still verifies the token and its
device-key nonce locally. If the variable is absent, regular direct Google
sign-in is used.
One-time setup:
- Attach a stable custom domain to this Worker in Cloudflare. Domain routing is intentionally kept out of the repository.
- Add the same
VITE_GOOGLE_CLIENT_IDvalue as a Worker runtime variable. Cloudflare build variables are not exposed to Worker code at runtime. - Add your stable auth origin to the Google web client's Authorized JavaScript origins. No redirect URI is needed.
- Set
VITE_GOOGLE_AUTH_BRIDGE_ORIGINandVITE_GOOGLE_CLIENT_IDfor Cloudflare preview builds.
Vercel, Netlify, S3 + CloudFront, etc. work the same way: npm run build, publish dist/, set VITE_* env vars at build time, register OAuth origins.
| Layer | Choice |
|---|---|
| UI | React 19, Tailwind CSS 4, DaisyUI 5 (custom peerly light and peerly-dark themes) |
| Build | Vite 8, TypeScript 6 |
| P2P | Trystero (@trystero-p2p/*) — Nostr / ws-relay / Supabase signaling |
| Crypto | Web Crypto — ECDSA device keys, JWT verification via JWKS, creator-signed allow-lists |
| Storage | localStorage (session, workspace list, messages, indexes), IndexedDB (device keys, file bodies, avatars) |
| Media safety | Lazy NSFWJS MobileNetV2 inference in the browser |
| Tests | Vitest (unit), Playwright (E2E), oxlint |
The generic P2P room core — room-code generation, signaling strategy selection,
joinRoomByCode, the useRoom React hook, device identity, signing
primitives, browser-side OIDC verification, room media, attention helpers, and
shared avatar/IDB utilities — lives in packages/core and is
published to npm as @peerly/core (see that README
for the API surface; do not pin a version in prose here). The app consumes it
from source via a Vite/tsconfig alias; consumer apps use the published package.
Workspace semantics — creator-signed allow-lists, peer
handshakes, history sanitization — deliberately stay in the app, not the
package. Releases run through the release-core.yml workflow: manual trigger,
npm Trusted Publishing with provenance, automatic version bump via release PR.
.
├── e2e/ Playwright end-to-end tests
├── docs/ views.md (screens & functions), implementation notes
├── public/ Static assets (favicon, etc.)
├── scripts/
│ ├── guard-bundle.mjs Fail build if E2E keys reached dist/
│ ├── check-csp.mjs Serve dist with CSP, test a negative control + offline shell
│ └── check-relays.mjs Nostr relay health diagnostic
├── server/ Dev relay, test server, process helpers
│ ├── dev.mjs npm run dev (Nostr signaling)
│ ├── dev-relay.mjs npm run dev:relay
│ ├── relay.mjs WebSocket signaling relay
│ └── test-server.mjs E2E: relay + Vite with auth bypass
├── src/
│ ├── collab/ P2P protocol, crypto, identity, stores
│ ├── components/ React UI (join, settings, storage, chat, files, video)
│ ├── hooks/ Room, collab, auth wiring
│ ├── protocol/ Message types & mappers
│ ├── utils/ Storage, blobs, hashing
│ ├── index.css Tailwind + DaisyUI theme
│ ├── App.tsx Session bootstrap, workspace routing
│ ├── config.ts Room / relay / build label
│ └── session.ts Active workspace session persistence
├── build-info.mjs Version + commit injected at build time
├── .env.example Environment template (copy to .env)
├── .nvmrc Node 24.18.0 (npm 11.16.0 / CI alignment)
├── playwright.config.ts
├── vite.config.ts
├── wrangler.jsonc Cloudflare SPA assets and API routing
└── vitest.config.ts
| Command | Description |
|---|---|
npm run dev |
Vite + public Nostr signaling |
npm run dev:relay |
Vite + local WebSocket relay |
npm run dev:app |
Vite only, using the signaling strategy from the environment |
npm run stop |
Stop the common local Peerly development ports |
npm run build |
Typecheck + production build + bundle guard |
npm test |
Vitest unit/component tests (counts change with the suite — run npm test for the current total) |
npm run test:watch |
Vitest in watch mode |
npm run test:e2e |
Playwright E2E (local relay; parallel workers per Playwright config) |
npm run test:e2e:nostr |
E2E subset over public Nostr |
npm run test:e2e:ui |
Playwright interactive UI |
npm run preview |
Preview the production build locally |
npm run check:relays |
Health-check the default Nostr relays |
npm run check:csp |
Verify production CSP, its inline-script negative control, and the offline shell |
npm run guard:bundle |
Fail if test key material reached dist/ (runs in build) |
npm run lint |
oxlint |
The app shows its version and commit (v<version> · <commit>) on the join screen and
in the sidebar footer. Hosts that expose a commit SHA
(CF_PAGES_COMMIT_SHA, GITHUB_SHA, VERCEL_GIT_COMMIT_SHA, …) are picked up
automatically; otherwise it falls back to local git.
check:relays is a diagnostic, not part of npm test — a third-party relay
going down shouldn't fail your build. Run it after editing
DEFAULT_NOSTR_RELAYS: a relay that merely opens a socket can still silently
drop the ephemeral events Trystero signals with, which looks identical to
working until two peers fail to find each other.
- Invite link = credential — workspace ID lives in the URL hash (never sent to servers in HTTP requests)
- Identity handshake — three-round P2P verification: OIDC JWT + allow-list signature + live device-key proof
- No server-side enforcement — allow-list is creator-signed; peers verify signatures and JWTs locally
- Messages are author-signed — every message and file announcement is signed with the sender's device key at send time. Signed v2 revisions make edits/deletes tamper-evident, and each reaction is signed independently. Relayed history is verified on import: tampered entries are dropped, and identity claims are honoured only for keys bound to that user in a live handshake.
- Device approval is explicit — sharing an account login does not grant one device authority over another device's messages. Both devices must confirm a one-time pairing, exchange reciprocal signed grants, and retain those grants locally. Continuous sync is peer-to-peer and only runs while approved devices are simultaneously online; account sessions and identity tokens are never copied.
- Security headers — a strict Content-Security-Policy ships via
public/_headers; CI serves the production bundle with those headers, asserts zero startup violations, and proves its negative control is blocked. - Inviting is creator-only — the allow-list is only accepted if it verifies against the workspace's creator key, and that key never leaves the browser profile that created the workspace. A second device, even the creator's, cannot add members.
- Revocation is best-effort — the creator can remove a member, and every device judges peers against the newest creator-signed list it holds, so updated members stop admitting the removed member at their next handshake. The honest limit: the removed member and any member who never received the update can still pair, and open connections are not torn down. Nothing short of a server closes that gap.
- Live messages — attributed by transport peer id, not payload
senderId - Legacy history — unsigned entries from older versions retain readable text but lose durable identity claims; newly authored entries are signed and verified
- Local media classification — sensitive-media screening never uploads frames, but it is advisory and fails open rather than acting as a moderation authority
- Relay metadata — signaling relays do not receive message/file bodies, but relay and TURN operators can still observe connection metadata such as IP addresses, timing, and traffic volume
- Production bundle guard — E2E fake-issuer keys are isolated and scanned out of
dist/on every build
Consequences of having no server, stated as the trade-offs they are:
- Deletion is local, by design — with no authority that owns the data, storage cleanup affects one browser only. A safe workspace-wide reset would need a signed monotonic reset epoch so an offline peer cannot resurrect old state; until then, Peerly does not pretend to a global delete it cannot enforce.
- You are the archive — manual JSON backup covers workspace-channel messages and access; DMs and file bodies live in local copies and online peers. No cloud archive is the point, not a gap.
- Transfers are whole-file — file bodies are content-addressed and integrity-checked; resumable byte-range transfer is traded away for that simplicity, and join progress is channel-based rather than byte-accurate.
- Revocation is eventual — a removed member stops being admitted as devices learn the newer creator-signed list; a stale device honours the old list until it hears the new one. Nothing short of a server closes that gap, and Peerly chooses no server.
- Relays are deployment-time configuration — members need at least one signaling relay in common, so per-user relay editing could silently partition a workspace. Overrides exist at build time instead.
- Moderation stays on your device — local NSFW screening is advisory, and there is deliberately no workspace-wide block or ban authority: any such authority would be a server with power over content.
GitHub Actions on push/PR to main / master (see .github/workflows/ci.yml):
- Install Node
24.18.0with its bundled npm11.16.0, then verify both exact versions. - Run a clean
npm cifrom the committed lockfile. - Run lint and 210 unit/component tests.
- Run the TypeScript/Vite production build and bundle guard.
- Install Chromium, verify CSP plus the offline shell, and run all 50 Playwright tests against the local relay (2 parallel workers in CI, each with an isolated workspace).
package.json devEngines, .npmrc, .nvmrc, and CI all enforce the same toolchain. A mismatched Node or npm exits before it can rewrite package-lock.json.
A Privacy Policy and Terms of Service ship with the app (EN/PL), reachable at
/privacy and /terms and linked from the join screen and workspace sidebar. A
first-run consent banner records agreement (versioned in
src/consent.ts), and the sign-in card shows an agreement
notice.
- English reference copies: docs/legal/PRIVACY.md, docs/legal/TERMS.md.
- In-app localized source of truth:
src/legal/legalContent.ts. - Controller name, contact addresses, jurisdiction, and minimum age live in
src/legal/legalMeta.ts. Edit there.
The invite allow-list distributes invited email addresses to workspace peers — the Privacy Policy discloses this. These texts are a good-faith starting point, not a substitute for review by a lawyer before a public/commercial launch.
MIT — see LICENSE.