Use your regular smartphone as a secure document scanner -- even for sensitive documents.
Try it now: websend.olicorne.org
- Disclaimer
- How It Works
- Security Features
- End-to-End Encryption
- Zero Server Trust
- Supply Chain Attack Resistance
- Man-in-the-Middle Protection
- Room Security
- Rate Limiting and Origin Validation
- Receiver Payload Bounding (Anti-DoS)
- Transform-Replay Hardening (Anti-DoS)
- Receiver UI DoS Hardening (Anti-DoS)
- Metadata Protection
- Transfer Verification
- No Phone Storage
- Cross-Session Data Isolation
- Docker Hardening
- Subresource Integrity (SRI)
- TURN Relay Security
- Non-Security Features
- Keycloak SSO (Experimental)
- Future Ideas
- Requirements
- Quick Start
- Configuration
- Firewall (UFW)
- Troubleshooting
- Tech Stack
- Development
- Third-Party Libraries
- License
WebSend transfers photos directly between devices using WebRTC and end-to-end encryption. Photos are encrypted on the sender's device and decrypted only on the receiver's device. They never pass through any server unencrypted, and they never touch the phone's storage.
This project was developed with AI assistance (Claude Code) with careful attention to security, but by someone without a formal background in computer science or security research.
- Receiver (typically a computer) opens the app and clicks "Receive" -- generates encryption keys and displays a QR code
- Sender (typically a smartphone) scans the QR code -- either by clicking "Send" and using the in-browser camera, or by scanning directly with any barcode scanner app (the URL opens the browser directly in sender mode)
- A direct peer-to-peer connection is established via WebRTC
- Both parties verify key fingerprints by reading short codes aloud to each other
- Sender takes or selects photos, which are encrypted and sent directly
- Receiver decrypts, previews, optionally crops/rotates/binarizes, and downloads the photos — individually, as a ZIP, or as a single PDF (plain or searchable via OCR). Photos are auto-grouped into "collections" (one per sender batch). The sender side also exposes a Genius-Scan-like gallery (rotate, flip, B&W, perspective crop, drag-and-drop reorder) before the photos are sent
- ECDH key exchange (P-256 curve) with AES-256-GCM encryption via the Web Crypto API
- Forward secrecy: fresh ephemeral key pairs are generated for each session, so compromising a key later does not expose past sessions
- HKDF key derivation with domain separation to derive AES keys from the ECDH shared secret
- The server acts as a signaling relay only (exchanges SDP connection metadata between peers)
- The server never sees encryption keys, plaintext photos, or file metadata
- All photo data travels peer-to-peer via WebRTC data channels (or encrypted through TURN/TURNS if relaying is needed)
- Rooms and signaling data are ephemeral (10-minute TTL, stored in memory only)
- No frameworks, no bundlers, no build tools: the entire frontend is vanilla HTML, CSS, and JavaScript -- there is no
node_modulesin the browser, no transpilation step, and no dependency tree that could be poisoned - All third-party client-side libraries are vendored directly into the repository (not pulled from npm or a CDN at runtime) — see Third-Party Libraries below
- Subresource Integrity (SRI) hashes on all local
<script>and<link>tags ensure that even a compromised server cannot silently swap in tampered files - The server-side dependency footprint is intentionally minimal (Express.js only)
- Key fingerprint verification: after connection, both parties see a 16-hex-char (64-bit) SHA-256 fingerprint of each other's public keys, grouped as
XXXX-XXXX-XXXX-XXXX, that they compare aloud to confirm no MITM key substitution occurred. The length is fixed at the recognised floor for verbal-comparison fingerprints (Signal uses 60 decimal digits, OTR 40 hex / 160 bits). It is deliberately NOT adapted to server load: a signaling-MITM grinds ECDH keys against any single session, so shortening the code under low load would not reduce attacker effort, just make the attack feasible in seconds on a laptop. - Both parties must explicitly confirm the fingerprints match before photo transfer begins
- Either party can abort if fingerprints don't match
- Room IDs are short (6 characters) for usability, but each room also has a 128-bit cryptographic secret (generated with
crypto.randomBytes) - The secret is embedded in the QR code URL's hash fragment (never sent to the server in HTTP requests)
- All room API calls require the secret via the
X-Room-Secretheader - Secret comparison uses constant-time comparison (
crypto.timingSafeEqual) to prevent timing attacks - This prevents room enumeration and unauthorized room access even if an attacker guesses or brute-forces the short room ID
- Per-IP rate limiting on room creation (5/min), room lookups (30/min), and general API calls (100/min) to prevent DoS and enumeration. The general 100/min cap also covers
GET /api/rooms/:id/answer?wait=trueso a peer holding a valid secret cannot pipeline long-polls to exhaust memory. - Origin header validation blocks cross-origin API requests from unauthorized websites (CSRF-like protection)
- Express trusts proxy headers only from loopback, so
X-Forwarded-Forcannot be spoofed by external clients (designed to run behind Caddy) - Long-poll waiter caps: layered defense for
?wait=true. A per-room cap (4 concurrent waiters) refuses extras with 429, and a process-wide ceiling (10000 in-flight waiters) refuses extras with 503, before any socket / closure / timer is allocated.
- The data-channel binary branch refuses chunks that arrive before a valid
file-start, refuses any chunk that would push the in-flight file past its declared size, and refuses any chunk that would push the cumulative session bytes past 4 GiB. On any of those, the data channel and peer connection are torn down immediately. - The
file-startsize validator enforces a 16 KiB floor (the smallest legitimate padded ciphertext) so a hostile peer cannot smuggle a tiny declared size to keep the receive buffer growing under the radar. - These caps fire at the WebRTC layer, before fingerprint verification, so a not-yet-verified peer cannot OOM the receiver tab while the verification modal is up.
- The
transform-imagevalidator capstransforms[]length (32 ops max) and, forop:'crop', requires four{tl, tr, br, bl}corners with normalized{x, y}in[0, 1]. Peer-supplied corners outside that range are rejected before any pixel work happens. cropPerspectivedefensively clamps its output dimensions tomin(srcDim * 2, 8192)so even a validator bypass cannot drive a multi-GiBcreateImageDataallocation or freeze the main thread on the inverse-mapping loop.- Peer-mutating handlers (
encrypted-file,transform-image,replace-image,delete-image,batch-*) are gated behind both-sides fingerprint confirmation, so an unverified peer cannot push files, replay transforms, or rearrange the gallery while the verification modal is still up.
- The send-page file picker (
#file-input/#dir-input) refuses selections larger than 50 files in one go. EXIF stripping re-encodes every image to PNG, which inflates a typical phone photo from ~5 MB to ~30 MB; a "select all" against a full gallery would otherwise pile up hundreds of MB of stripped blobs before the first byte hit the wire and kill the renderer on Android Chrome. - Selected files are processed and queued one-at-a-time (strip → push → drain in a loop), so peak resident memory is bounded by ~1 stripped blob in the queue plus 1 in flight regardless of selection size.
Collections.createNew()refuses to allocate past 64 collections per session, so a verified-but-hostile peer floodingbatch-startcannot grow receiver-side DOM/state without bound. The cap is reset on cross-session shred.- The logs panel does not append DOM nodes while it is hidden, and when visible trims its children to
logger.maxLogs(500). On next open it rebuilds from the bounded in-memory log buffer. A pre-verification flood of invalid wire messages (each producing alogger.warn/error) can no longer grow the panel forever and OOM the tab.
- File metadata (name, MIME type, original size) is encrypted inside the payload, not sent in plaintext over the data channel
- Encrypted payloads are padded to fixed bucket sizes (16 KB to 32 MB, power-of-2) to hide the exact file size from network observers
- Padding uses random bytes (not zeros) to prevent compression-based attacks
- After decryption, the receiver computes a SHA-256 checksum of the plaintext data and sends it back to the sender via a
file-ackmessage - The sender compares this against its own pre-encryption hash to verify end-to-end integrity (encryption, transfer, and decryption all succeeded)
- If verification fails or times out, the sender is notified and can retry without losing the photo
- Photos are captured directly in the browser (no camera app) and stay in browser memory only
- Photos are never written to the phone's gallery, filesystem, or local storage
- Photos are kept in memory until the receiver confirms successful receipt — only then are they cleared
- A new pairing on either device shreds all in-memory user data (decrypted images, OCR text, preBW pixel buffers, blob URLs, scribe WASM state, crypto keys) before establishing the new session
- Sender: scanning a QR with a different
roomIdtriggers a confirm prompt (when the gallery is non-empty) and then a local shred. The same-room reconnect path keeps the gallery intact, so a phone can re-pair after a network blip without losing unsent photos - Receiver: a sender disconnect keeps the same room and QR alive (so the same phone can re-scan and reconnect with data preserved). A deliberate "Start new pairing" button in the disconnect banner rotates to a fresh room and shreds everything
- The signaling relay stores only ephemeral SDP + ICE in an in-memory
Mapwith a 10-minute TTL and complete deletion on expiry — no database, no filesystem writes for room data, no cross-room caching
- Runs as a non-root user (UID 1001)
- Read-only root filesystem in the container
- All Linux capabilities dropped (
cap_drop: ALL) - No privilege escalation (
no-new-privileges:true) - Resource limits (128 MB memory, 0.5 CPU) to prevent DoS
- Health check for monitoring
- All local JavaScript and CSS files include SRI integrity hashes in their
<script>and<link>tags, ensuring files have not been tampered with
- TURN credentials are time-based (HMAC-SHA1, standard coturn ephemeral credentials) and expire after a configurable TTL (default: 1 hour, see
TURN_CREDENTIAL_TTL) - Even when relayed through TURN, photos are still end-to-end encrypted -- the TURN server only sees encrypted blobs
- TURNS (TURN-over-TLS) is enabled by:
- Setting
TURNS_PORT=443in.env(this is the public port the reverse proxy listens on, not coturn's port) - Configuring your reverse proxy to terminate TLS for
turn.<DOMAIN>on 443 and proxy the plaintext TURN stream to coturn's3478/tcplistener
- coturn itself runs with
--no-tlsand does not need any certificate files (the reverse proxy owns the TLS material)
- Setting
- Why front coturn behind the reverse proxy instead of letting coturn terminate TLS itself: coturn's TLS stack has a different JA3S fingerprint and ALPN behaviour from a regular HTTPS server, so middleboxes that allow your normal HTTPS traffic may still selectively drop a direct TURNS connection. Fronting coturn behind the same TLS stack as your HTTPS site makes TURNS traffic indistinguishable from regular HTTPS on the wire
- Caddy example (requires the caddy-l4 plugin, built with
xcaddy build --with github.com/mholt/caddy-l4):{ servers { listener_wrappers { layer4 { @turns tls sni turn.<DOMAIN> route @turns { tls { connection_policy { alpn h2 http/1.1 } } proxy localhost:3478 } } tls } } }
- On networks that block UDP and strip TURNS at the proxy, WebSend now falls back to a pure-HTTPS path that runs through the same
:443reverse-proxy listener as the rest of the app. No separate container or port; the same Caddy reverse proxy upgrades the WebSocket and handles the long-poll endpoints to the Node process. - The client races three transports in parallel: WebRTC (preferred), WebSocket to
/api/rooms/:id/relay, and an on-demand long-poll over/api/rooms/:id/relay/{handshake,up,down,close}(auto-spawned when the WS path is refused). A 10 s grace window lets WebRTC win when it can; afterwards the relay path wins. - The relay forwards opaque ciphertext between two paired peers. The existing ECDH + AES-GCM + fingerprint stack is transport-agnostic, so the server never sees plaintext on this path either.
- Anti-DoS caps are mirrored server-side: 4 GiB
MAX_TOTAL_SESSION_BYTES, 16 KiBMAX_CONTROL_MSG_BYTES, plus a 32-frame bounded queue and 60 s idle timeout on the long-poll slots. Long-poll slot tokens (128-bit random) are validated in constant time alongside the room secret. - The sidebar shows the active path: Direct, Relay (TURN), Relay (TURNS), Relay (HTTP), or Relay (HTTPS).
- Disable by setting
RELAY_ENABLE=falseon the server (default istrue).
- PWA (Progressive Web App): installable on mobile home screens, with service worker for fast UI shell loading and an auto-reload on each deploy (the cache name is timestamped during SRI regeneration)
- Internationalization (i18n): supports English and French, auto-detected from browser locale
- Live document edge detection on the sender camera: pure-JS pipeline (downscale → Sobel → Otsu → contour trace → multi-candidate quad fitting scored by perimeter edge alignment) overlays a green outline of the detected page in real time, and pre-fills corner positions when entering the crop tool
- Sender-side gallery (Genius-Scan-like): thumbnail grid with per-photo rotate / flip / B&W / perspective crop, drag-and-drop reorder, and batch finalization before sending
- Transform replay: when the sender edits an already-sent photo, only a small
transform-imagecommand is re-encrypted and re-sent rather than the full image; the receiver replays the transforms against its stored original (with atransform-nackfallback that triggers a full resend) - Receiver collections: photos are auto-grouped per sender batch and shown as "Document N" sections, supporting drag-and-drop reorder and per-collection PDF/ZIP export
- Document cropping: perspective-corrected 4-corner crop tool, shared between sender and receiver via a single
crop-modal.jsmodule so the logic isn't duplicated - Export modal: download received images as PDF or ZIP, with optional B&W (Otsu thresholding) and OCR producing a searchable PDF (scribe.js + Tesseract WASM). OCR runs in a background queue as photos arrive (status badge per card), then assembles cached results at export time. OCR uses LSTM-only mode and downscales large images to 2000px for recognition to keep it usable in a browser, while preserving original image quality in the final PDF
- Per-PDF actions: when an incoming file is a PDF, dedicated buttons let you re-export it as a ZIP of page images or as a re-OCR'd searchable PDF (rendered with bundled MuPDF)
- PDF export: hand-crafted minimal PDF 1.4 generator, no dependencies (one page per JPEG, page sized to image)
- ZIP export: client-zip (preloaded in background)
- B&W document mode: Otsu's automatic binarization for crisp scanned documents
- QR code scanning: in-browser QR code scanning (jsQR) and generation (qrcode.js)
- Connection type detection: shows whether the connection is direct (local network or via STUN) or relayed (TURN/TURNS)
- Debug logging: "Logs" button on both sender and receiver pages for troubleshooting, with optional verbose DEV mode. A vendored eruda mobile devtools console can be opened on demand by appending
?debug=1to any page URL or by 5-tapping the DEV badge in the sidebar. Once opened it stays on across reloads (stickyeruda-persistflag in localStorage); append?debug=0once to turn the auto-load back off - Configurable file types:
ALLOWED_FILE_TYPESenv var restricts uploads to images (ONLY_IMAGES), images + PDFs (IMAGE_OR_PDF), or anything (ANY, default) - Large button UI: designed for usability by non-technical users
- No heavy frameworks: vanilla HTML5 + CSS + JavaScript only
WebSend can be placed behind Keycloak authentication using oauth2-proxy. This provides a simple "authenticated or not" gate — only users who log in via Keycloak can access the app. No user, group, or permission mapping is performed.
A commented-out oauth2-proxy service is included in docker-compose.yml along with corresponding environment variables in env.example. This feature was added with assistance from Claude Code.
Status: Experimental. WebSocket signaling should work through oauth2-proxy, but long-lived connections may break when OAuth tokens expire. Token lifetime tuning in Keycloak may be required. coturn (TURN/TURNS/STUN) traffic is not protected by oauth2-proxy (it uses UDP/TCP, not HTTP), but is indirectly secured because unauthenticated users cannot obtain TURN/TURNS credentials.
Ideally, the WebRTC signaling server would be replaced by iroh in the future, which would eliminate the need for a signaling server entirely. However, iroh is not yet easy to embed in phone browsers.
- Docker and Docker Compose
- HTTPS (required for camera access in browsers) -- I recommend Caddy as a reverse proxy for automatic Let's Encrypt certificates
- The devices must be able to reach each other (same network, or TURN/TURNS relay)
-
Go to
./docker -
Copy the environment file and configure your domain/IP:
cp env.example .env # Edit .env and set DOMAIN to your server's IP or hostname -
Start the services:
docker compose up -d
-
Set up Caddy (or another reverse proxy) to terminate HTTPS and proxy to port 7395
-
Access the app at
https://your-domain
All configuration is done via environment variables in docker/.env (see docker/env.example for documentation). Docker Compose automatically loads .env and substitutes variables into docker-compose.yml.
Important: after changing .env, you must run docker compose up -d (not docker compose restart) for changes to take effect, because restart reuses the existing container with old environment values.
| Variable | Description | Default |
|---|---|---|
DOMAIN |
Server IP or hostname | localhost |
ALLOWED_ORIGINS |
Comma-separated allowed origins for API requests | https://{DOMAIN}, http://{DOMAIN} |
DEV |
Enable verbose debug logging (1 or 0) |
0 |
STUN_SERVER |
Self-hosted STUN server (host:port) |
(empty -- uses Google STUN) |
STUN_GOOGLE_FALLBACK |
Use Google's public STUN as fallback | true |
TURN_SERVER |
TURN relay server (host:port) |
(empty -- no relay) |
TURN_SECRET |
Shared secret for TURN time-based credentials | (empty) |
TURN_CREDENTIAL_TTL |
TURN credential validity in seconds | 3600 (1h) |
TURNS_PORT |
Public TURN-over-TLS (TURNS) port advertised to clients; this is the port the reverse proxy listens on (typically 443), not coturn's internal port. Enables turns: ICE candidates |
(empty -- TURNS disabled) |
UMAMI_URL |
Base URL of your Umami analytics instance | (empty -- analytics disabled) |
UMAMI_WEBSITE_ID |
Website ID from your Umami dashboard (UUID) | (empty) |
UMAMI_DNT |
Respect browser Do Not Track setting (true or false) |
true |
RUN_NPM_AUDIT |
Run npm audit --audit-level=high during docker build (build arg) |
false |
ALLOWED_FILE_TYPES |
Restrict accepted uploads: ONLY_IMAGES, IMAGE_OR_PDF, or ANY |
ANY |
OCR_LANGS |
Tesseract languages used by the receiver's OCR (comma-separated) | eng,fra |
OCR_PSM |
Tesseract page-segmentation mode | 12 |
TURN_TIMEOUT |
Seconds the client waits for TURN ICE candidates before giving up | 15 |
DEV_FORCE_CONNECTION |
Force DIRECT or RELAY ICE policy for testing (otherwise DEFAULT) |
DEFAULT |
PORT |
HTTP port the Node server listens on inside the container | 8080 |
TEST_DISABLE_RATE_LIMIT |
Disable per-IP rate limiting (test escape hatch only) | (unset) |
If you use UFW, you need to open the ports used by coturn. Note that Docker bypasses UFW's iptables rules by default, so standard ufw allow commands won't work for containers.
It is recommended to use ufw-docker which manages UFW rules that actually apply to Docker containers.
# TURN listening port (UDP + TCP)
# Note: when TURNS is enabled, it is fronted by the reverse proxy on port 443
# (see the "TURN Relay Security" section) and the proxy forwards plaintext to
# coturn:3478/tcp. There is no separate TURNS port to open on coturn.
sudo ufw-docker allow coturn 3478/udp
sudo ufw-docker allow coturn 3478/tcp
# TURN relay ports -- ufw-docker does not support port ranges,
# so each port in the relay range must be allowed individually.
# Adjust to match --min-port / --max-port in your coturn config.
sudo ufw-docker allow coturn 49152/udp
sudo ufw-docker allow coturn 49153/udp
sudo ufw-docker allow coturn 49154/udp
sudo ufw-docker allow coturn 49155/udp
sudo ufw-docker allow coturn 49156/udp
sudo ufw-docker allow coturn 49157/udp
sudo ufw-docker allow coturn 49158/udp
sudo ufw-docker allow coturn 49159/udp
sudo ufw-docker allow coturn 49160/udp
sudo ufw-docker allow coturn 49161/udpNote: Replace
coturnwith your actual container name (e.g.,docker-coturn-1) if it differs. Check withdocker ps.
- Camera not working: make sure you're using HTTPS. Browsers require a secure context for camera access. Set up Caddy or another reverse proxy for automatic HTTPS.
- Connection failing: check that both devices can reach the server. If behind symmetric NAT, enable the TURN relay (see
env.example). Check firewall rules for UDP traffic. A good way to test your network's STUN/TURN/TURNS capabilities is Twilio's Network Test. - TURN/TURNS not reachable: use
misc/check_turn.pyto verify that your TURN or TURNS server is up and responding. It sends an unauthenticated Allocate request and reports whether the server answers correctly (a 401 response means the server is alive and asking for credentials, which is the expected behaviour):uv run misc/check_turn.py --turns-server myrelay.example.com 5349 uv run misc/check_turn.py --turn-server myrelay.example.com 3478
- Diagnosing failed sessions from the logs: the in-page logs panel (Logs button on sender and receiver) now distinguishes STUN / TURN / TURNS individually instead of lumping them. Useful lines to look for:
- Startup of the server:
ICE URLs offered to clients: STUN=N, TURN=N, TURNS=Nfollowed by every URL. IfTURNS=0, noturns:will be offered to clients (setTURNS_PORT). - Client init:
ICE breakdown: STUN=N, TURN=N, TURNS=N. Tells you what the server handed this session. - Per-candidate gather:
ICE candidate: relay via TURNS(TLS) turns:host:5349confirms TURNS actually produced a candidate. - Per-server failure:
ICE error from turns:host:5349: code=401 "Unauthorized" :: TURNS: credentials rejected by server. Check that TURN_SECRET on WebSend matches coturn's static-auth-secret. The code maps to a tailored cause (401 = coturn auth, 403 = ACL, 701 = DNS, >=700 = network unreachable / TLS handshake / port blocked). - On disconnect: a
CONNECTION FAILURE DIAGNOSTICSblock lists configured URLs, gathered local candidates (withrelay/udp,relay/tcp,relay/tlsbroken out), remote candidates, and every candidate pair withstate,nominated,requestsSent,responsesReceived, and RTT. A pair withreqSent>0 respRcvd=0means the peer dropped our STUN probes (firewall on their side). - Whenever a session fails, a per-server probe report (
[DIAG] turns:host:5349 -- reachable / UNREACHABLE) is appended even outsideDEV=1.
- Startup of the server:
- QR code not scanning: ensure good lighting and that the QR code is fully visible. The QR code contains a URL with a security token.
- Click "Logs" button: both sender and receiver pages have a logs panel for detailed connection debugging. Set
DEV=1in.envfor verbose output.
- Express.js (5.x) -- static file server + signaling API
- Web Crypto API -- ECDH key exchange + AES-256-GCM encryption
- WebRTC -- peer-to-peer data channels
- jsQR / qrcode.js -- QR code scanning and generation
- scribe.js-ocr / Tesseract WASM / MuPDF -- OCR and PDF rendering, all vendored
- client-zip -- streaming ZIP generation in the browser
- eruda -- on-demand mobile devtools console (loaded only via
?debug=1or DEV badge) - coturn -- optional TURN relay server (can reuse an existing instance)
- Docker -- containerized deployment
Built with assistance from Claude Code (AI-assisted development).
The project uses a three-tier test suite:
| Tier | Command | What it covers | Speed |
|---|---|---|---|
| Unit | npm run test:unit |
Pure JS modules: crypto, image transforms, server helpers, transfer stats, SRI updater, hand-rolled PDF builder, and a real-photo regression suite for the document-edge detector (doc-detect-samples.test.mjs, gated on the optional canvas devDep) |
~0.5s |
| HTTP integration | npm run test:http |
Real server.js spawned per file via child_process — config, origin validation, rate limiting, room/ICE/SDP signaling, long-poll edge cases, static asset mounts, env-var propagation |
~2s |
| End-to-end | npm run test:e2e |
Two real browsers via Playwright (sender + receiver round-trip) | ~30s |
Run npm test (= unit + HTTP) for the fast inner loop, or npm run test:all for everything. A pre-push git hook (in .githooks/pre-push, auto-wired by npm install via the prepare script) runs npm test before every push.
Known testing gaps (frontend modules like webrtc.js/logger.js/i18n.js, the export modal, crop tool, transform-replay protocol, fingerprint-mismatch / integrity-retry paths, the service worker, healthcheck, and SSO) are listed in ARCHITECTURE.md.
A minimal Node script src/cli/receive.js pairs as a receiver from a terminal — useful for remote-instance smoke testing and headless captures. It reuses the production crypto.js + protocol.js verbatim by driving them inside a Playwright-launched headless Chromium (already a devDependency for the e2e tests), so no native node-webrtc dependency is added and the wire protocol cannot drift. See src/cli/README.md for usage. Not intended for end users.
All client-side libraries are vendored directly in the repository (no CDN at runtime). All licenses are compatible with AGPL-3.0.
| Library | Version | License | Source |
|---|---|---|---|
| qrcode.js | 1.5.1 | MIT | QR code generation |
| jsQR | 1.4.0 | Apache-2.0 | QR code scanning |
| client-zip | — | MIT | ZIP export |
| scribe.js-ocr | 0.10.1 | AGPL-3.0 | OCR engine (preloaded in background; bundles Tesseract WASM and MuPDF) |
| Tesseract trained data | — | Apache-2.0 | eng + fra language models, served locally |
| eruda | — | MIT | Mobile devtools console (on-demand) |
| Express.js | ^5.0.0 | MIT | Server-side HTTP framework |