Skip to content

Latest commit

 

History

230 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Peerly

Encrypted hybrid team collaboration. Production currently uses relay coordination and P2P chat; preview.peerly.cc runs the Durable Objects backend for encrypted channel state and recent chat history. File bodies and video calls use WebRTC in both modes. Built with React, Vite, Tailwind CSS + DaisyUI, Cloudflare Workers, and Trystero. See the production cutover runbook for rollout and rollback requirements.

Highlights: the reusable P2P and Durable Objects primitives ship as the npm package @peerly/core, while Peerly owns its product-specific authorization and event policies. The app also includes 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.

Features

  • 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; the Worker verifies enrollment in Durable Objects mode and peers verify identity in P2P mode
  • Creator-signed allow-list — only invited email addresses can join; the exact signed revision is enforced by the Durable Object authorization boundary and by peer handshakes in P2P mode
  • 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 — client-encrypted, persisted message/reaction streams with channel rename/delete/reorder and locally closable DM threads; P2P remains a selectable backend
  • 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

Quick start

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/codefusion-cc/peerly.git
cd Peerly
npm ci
cp .env.example .env   # add at least one identity provider (see below)
npm run dev

Open 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

First-time flow

  1. Sign in with a configured identity provider.
  2. Create a workspace (name + initial member emails) or join via an invite link (#invite=… in the URL).
  3. 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.

Sync and browser storage

The default on-demand mode is designed for fast joins and constrained browser storage:

  1. Channel and message history syncs first.
  2. File metadata and small WebP thumbnails travel with history.
  3. 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. In production P2P mode, re-sync requires a peer holding the relevant history or file body. DO preview can replay recent encrypted history without an online peer: up to 1,000 chat/reaction events per workspace or DM object for 30 days. Channel definitions and deletion records have separate durable storage. File originals still require a peer. Pending outgoing messages are kept in a local IndexedDB outbox until delivery and local persistence succeed.

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.

Sensitive-media screen

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.

Identity providers (required for production)

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
Google 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

  1. Google Cloud console → Create credentials → OAuth client ID → Web application.
  2. Authorized JavaScript origins: every origin the app is served from — http://localhost:5173 for dev, plus your production origin. No redirect URI is needed (Google Identity Services returns the token via callback).
  3. Configure the OAuth consent screen (External is fine; while in Testing only listed test users can sign in).
  4. 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.

Microsoft

  1. Entra portal → App registrations → New registration.
  2. Supported account types: Accounts in this organizational directory only. Multi-tenant is refused at startup — see the security note below.
  3. Redirect URI: platform Single-page application, value = your exact origin (http://localhost:5173, and your production origin).
  4. Authentication → Implicit grant: tick ID tokens.
  5. Token configuration → Add optional claim → ID → add both email and xms_edov.
  6. 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

  1. Apple Developer → Identifiers → Services IDs → create one (e.g. com.example.peerly).
  2. 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.
  3. Services ID → VITE_APPLE_CLIENT_ID; set VITE_APPLE_REDIRECT_URI if it differs from window.location.origin.

Generic OIDC (Okta, Auth0, Keycloak, Entra ID as OIDC, …)

  1. Create a SPA / public client in your IdP.
  2. Allow implicit id_token and the openid profile email scopes; redirect URI = your origin.
  3. Ensure the ID token carries email and email_verified — the app rejects tokens without a verified email.
  4. VITE_OIDC_ISSUER = the issuer URL (the app reads <issuer>/.well-known/openid-configuration), VITE_OIDC_CLIENT_ID = the client ID.

Why GitHub is not supported

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.

Signaling

Browsers need a signaling channel to discover each other for P2P file and media flows. Message content uses a separately selected backend:

Content mode Behavior Config
Durable Objects (preview) Encrypted messages, reactions, and channel definitions are persisted before fan-out; late joiners receive bounded history VITE_CONTENT_BACKEND=durable-objects and CONTENT_BACKEND=durable-objects
P2P (production default) Messages and history move directly between currently connected browsers VITE_CONTENT_BACKEND=p2p and CONTENT_BACKEND=p2p

Files and call media remain P2P in both modes.

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
Durable Objects (preview only) Cloudflare-hosted control plane instead of a relay VITE_SIGNALING=durable-objects against a Worker deployed with matching backend config — see below

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.

Durable Objects control plane (preview)

A fourth signaling mode moves coordination — device enrollment, session cookies, presence, workspace/DM notifications, WebRTC signaling, and short-lived TURN credentials — off the self-hosted relay and onto a Cloudflare Worker backed by Durable Objects, served from /api/network/* and /api/realtime/* (see worker/index.mjs and packages/core/worker/realtime). The preview deployment also enables the content Durable Object: the browser encrypts messages, reactions, and channel definitions with the workspace/DM secret; the server persists only ciphertext and bounded routing metadata before broadcasting it. File bytes and calls remain WebRTC P2P.

VITE_SIGNALING=durable-objects only works against a Worker that was itself deployed with COORDINATION_BACKEND=durable-objects, the Durable Object bindings/migrations, and its own secrets (NETWORK_SESSION_SECRET, OPAQUE_USER_ID_SECRET, TURN_AUTH_SECRET, …). A client pointed at an unconfigured Worker fails closed with 503, and a stale/invalid capability fails with 400/401 rather than degrading silently. Production (peerly.cc) still runs COORDINATION_BACKEND=legacy-relay; only the stable preview deployment (preview.peerly.cc, via wrangler.preview.jsonc) runs the Durable Objects path today, ahead of a full production cutover.

npm run deploy:preview deploys peerly-preview through wrangler.preview.jsonc, whose build enables both DO signaling and DO content and writes dist-preview/, so a preview deploy never touches production's dist/. It runs from the Mac through the CodeFusion Console CLI rather than wrangler login, whose token reaches the whole account:

cd /Users/xeon/Projects/Peerly && codefusion-console cloudflare workers:edit,domains:edit -- npm run deploy:preview

codefusion-console cloudflare <scope>[,<scope>…] [--ttl 30m] -- <command> mints a token with exactly those scopes for that one command (two hours by default), hands it over as CLOUDFLARE_API_TOKEN with CLOUDFLARE_ACCOUNT_ID, and deletes it when the command exits; a token with an :edit scope waits for the owner's passkey tap on Dispatch. The preview deploy needs workers:edit and domains:edit (its preview.peerly.cc custom domain); its secrets (codefusion-console cloudflare workers:edit -- npx wrangler secret put <NAME> -c wrangler.preview.jsonc) need workers:edit. Once per Mac: npm install -g @codefusion-cc/console && codefusion-console relay install.

This control plane is shared code in packages/core/worker/realtime and packages/core/src/realtime: Peerly and HeyHubs each deploy their own Worker and Durable Object namespaces from it, with independent secrets and data. See docs/DURABLE_OBJECTS_ARCHITECTURE.md for the full design, or docs/RELAY_VS_DURABLE_OBJECTS.md for a comparison against the relay stack production still runs.

npm run test:e2e:do drives it the way a user would: it builds the app with the DO backend, serves that build from the real Worker on localhost with real Durable Objects, and runs two browser contexts against it. Test-only sign-in there is configuration — a generic oidc provider whose JWKS is served from the build, set only in wrangler.e2e.jsonc. A deployment that sets none of those variables resolves the provider to null and the route 503s, so it cannot be switched on by accident.

What that suite cannot prove is TURN: two browser contexts on one host connect over host candidates and never reach a relay. npm run turn:smoke covers that separately by asking coturn for a real allocation, with no browser involved.

docs/REWRITE_ARCHITECTURE.md records how this was built and what remains; docs/DURABLE_OBJECTS_CUTOVER.md is the step-by-step for putting it in front of users.

TURN (optional)

For strict NAT / corporate firewalls, configure your TURN URLs. The browser obtains short-lived REST credentials from /api/network/credentials — or, on the durable-objects signaling mode above, from /api/network/session instead, using the same TURN_AUTH_SECRET:

VITE_TURN_URLS=turn:your-turn.example:3478,turns:your-turn.example:5349 \
npm run build

Core 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.

Deploy

Peerly's UI is a static SPA. A P2P-only build can serve dist/ from any static host; Durable Objects mode must be served by the configured Worker so its authenticated /api/realtime/* routes and bindings are available.

npm run build

Output goes to dist/. The build runs a bundle guard that fails if E2E test key material leaked into the production bundle.

Cloudflare Workers Static Assets (recommended)

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.

GitHub Actions deploys it: every push to main that passes the test job runs the deploy job in .github/workflows/ci.yml (environment production, one deploy at a time, never cancelled). It runs npm run build with production's build-time variables, which the workflow lists (the Google client ID, VITE_SIGNALING=ws-relay with relay.peerly.cc:443, and the TURN hosts), then npx wrangler deploy, then waits until peerly.cc serves the new entry script. Branches are not deployed; test them on preview.peerly.cc (below). The build shows GITHUB_SHA in the UI as v<version> · <commit>.

The deploy needs a CLOUDFLARE_API_TOKEN secret in the GitHub environment production (Settings → Environments) with Workers Scripts Edit on the account that holds the peerly Worker. Cloudflare Workers Builds, still configured on the Worker, lost its GitHub connection when the repository moved to codefusion-cc and deploys nothing.

Register the production origin (https://peerly.cc) in each OAuth provider's allowed JavaScript origins / redirect URIs.

Cloudflare Pages also works: use npm run build, publish dist/, and set the same build-time environment variables.

Testing branches with Google sign-in

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:

  1. Attach a stable custom domain to this Worker in Cloudflare. Domain routing is intentionally kept out of the repository.
  2. Add the same VITE_GOOGLE_CLIENT_ID value as a Worker runtime variable. Cloudflare build variables are not exposed to Worker code at runtime.
  3. Add your stable auth origin to the Google web client's Authorized JavaScript origins. No redirect URI is needed.
  4. Set VITE_GOOGLE_AUTH_BRIDGE_ORIGIN and VITE_GOOGLE_CLIENT_ID for Cloudflare preview builds.

Other hosts

Vercel, Netlify, S3 + CloudFront, etc. work the same way: npm run build, publish dist/, set VITE_* env vars at build time, register OAuth origins.

Tech stack

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

@peerly/core

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.

Project structure

.
├── 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/
│   ├── emit-e2e-jwks.mjs   Publish the E2E issuer's JWKS into dist/ (E2E only)
│   ├── check-csp.mjs       Serve dist with CSP, test a negative control + offline shell
│   └── check-relays.mjs    Nostr relay health diagnostic
├── server/                 Dev relay, test servers, 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
│   └── test-server-do.mjs  E2E: built app behind the real Worker + Durable Objects
├── 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
├── playwright.do.config.ts Durable Objects browser suite (own target, serial)
├── vite.config.ts
├── wrangler.jsonc          Production: SPA assets and API routing
├── wrangler.preview.jsonc  Staging with Durable Objects (preview.peerly.cc)
├── wrangler.e2e.jsonc      Local-only DO target for the browser suite
└── vitest.config.ts

Scripts

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 typecheck tsc -b across every project (the root config is solution-style, so tsc -p alone checks nothing)
npm run test:workers Durable Object behaviour against real workerd
npm run test:e2e Playwright E2E (local relay; parallel workers per Playwright config)
npm run test:e2e:do Two browsers against the real Worker and real Durable Objects
npm run test:e2e:nostr E2E subset over public Nostr
npm run test:e2e:ui Playwright interactive UI
npm run turn:smoke -- <urls> Ask coturn for a real TURN allocation per transport. Needs TURN_AUTH_SECRET
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.

Security model

  • Invite link = credential — workspace ID lives in the URL hash (never sent to servers in HTTP requests)
  • Identity and membership — enrollment verifies OIDC and a live device-key challenge; workspace content authorization verifies the creator's exact signed allow-list and derives opaque deployment-scoped member IDs
  • Server-side enforcement without plaintext identity storage — Durable Objects receive opaque member IDs and a monotonic signed-list fingerprint, close revoked sockets, and never receive raw invited emails
  • 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 monotonic in Durable Objects mode — a newer creator-signed allow-list replaces the previous authority and removed members' live sockets are closed; the P2P rollback retains eventual peer-learned revocation
  • 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

Design limits

Hybrid storage and transport trade-offs:

  • Durable history is bounded — encrypted event envelopes retain for at most 30 days and the latest 1,000 events per workspace or DM; this is reliable delivery and recent history, not a permanent archive
  • File availability remains peer-owned — metadata can outlive an online sender, but file bodies live in local copies and transfer only while a member holding the content is reachable
  • 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.
  • P2P rollback revocation is eventual — a removed member stops being admitted as devices learn the newer creator-signed list; Durable Objects mode closes existing revoked sockets immediately after accepting a newer signed authority revision
  • 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; the server holds ciphertext without the workspace/DM key and therefore cannot inspect content

CI

GitHub Actions on push/PR to main / master (see .github/workflows/ci.yml):

  1. Install Node 24.18.0 with its bundled npm 11.16.0, then verify both exact versions.
  2. Run a clean npm ci from the committed lockfile.
  3. Run lint and 210 unit/component tests.
  4. Run the TypeScript/Vite production build and bundle guard.
  5. 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.

Legal

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.

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.

License

MIT — see LICENSE.

About

Serverless P2P team collaboration — invite-only workspaces, verified OIDC identity, channels, chat, files, and video over WebRTC. No app backend; signaling only helps peers find each other.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Used by

Contributors

Languages