DEV Community

Cover image for End-to-end encrypted collaborative notes in ~300 lines of JavaScript — no app server
Jim
Jim

Posted on

End-to-end encrypted collaborative notes in ~300 lines of JavaScript — no app server

I wanted to create a small demo of libVES capabilities — E2EE per-record sharing, user roles, live events, history log. The result is VESpost, end-to-end encrypted collaborative sticky notes. About 300 lines of plain JS, no dependencies other than libVES, plus a CSS file and a basic HTML page. Runs entirely on VES APIs, no app server, just static hosting.

You can try it live on GitHub Pages. It's Apache-2.0, and it's deliberately small enough to read in one sitting, because it's really a template for building this kind of app on libVES.js.

This post walks through the load-bearing lines.

Why E2EE apps are usually painful

If you've tried to build one, you know the four walls you hit:

  1. Sharing. Encrypting your own data is easy. Getting ciphertext readable by another person means key exchange, and most designs push that pain onto users or compromise the E2E nature.
  2. Access control. Once data is encrypted client-side, "who can read, who can write, who can re-share" becomes a key-management problem in addition to a database column.
  3. Key loss. The classic E2EE trade-off: if the user loses their key, the data is gone. This single issue kills most consumer E2EE ideas.
  4. Audit. "Who changed this record, when, from where?" — it gets more complicated when the server can't read the data.

VESpost gets all four from the library. The notes UI is a thin wrapper around a more general primitive: a shared, versioned, end-to-end encrypted record store hosted by the VES repository at ves.host. All plaintext exists only inside the browser tab.

  Browser (index.html + vespost.js)                     ves.host
  +-------------------------------+                  +--------------+
  | libVES.subtle('demo')         |  e2e-encrypted   | VES Repo     |
  |  . encrypt/decrypt in-browser |<---ciphertext--->|  . items     |
  |  . key exchange for sharing   |                  |  . sharing   |
  |  . VESpost UI (this repo)     |<---live events---|  . history   |
  +-------------------------------+                  +--------------+
Enter fullscreen mode Exit fullscreen mode

Bootstrap: one domain, one unlock

Everything starts in index.html:

var ves = libVES.subtle('demo');   // pick a VES domain

// on the Launch button:
ves.unlock().then((ves) => new VESpost(ves, demo_lock));
Enter fullscreen mode Exit fullscreen mode

A VES domain is your app's namespace. demo is a public shared sandbox — perfect for a demo, wrong for anything real; you'd register an x-yourapp experimental domain and change that one string.

unlock() opens the standard VES popup where the user signs into (or creates) a vault — their set of keys. This is also where the key-loss problem is handled: VES offers multiple redundancy options, which is the thing that makes at-rest E2EE practical. (Details of the recovery design are on vesvault.com; it's the interesting part of VES but out of scope for this walkthrough.)

Subscribe to everything, then render

The whole sync engine, from the VESpost constructor:

this.ves.onitemadd = this.ves.onitemremove = this.ves.onitemcreate =
this.ves.onitemdelete = this.ves.onitemchange = (ev) => this.event(ev);

this.ves.onauthexpire = (e) => this.done();   // vault about to auto-lock

this.ves.start(false).then(/* order the cards */);
Enter fullscreen mode Exit fullscreen mode

start(false) replays just enough of the stored event history to reconstruct the current state — each surviving note arrives as an event, building this.items — and then keeps streaming live events. So the same code path renders the initial screen and applies remote changes as other users make them. No separate "fetch the list" code needed.

(start(0) would replay the entire history from the beginning — we'll use that for the audit log below.)

Read and write: one call each

An item is one end-to-end encrypted, versioned record. A note is one item, and the entire persistence layer is:

item.put(val)      // encrypt val in-browser, store a new version
item.get()         // fetch + decrypt the latest version
item.writable()    // may this vault overwrite? (drives read-only UI)
Enter fullscreen mode Exit fullscreen mode

Saving in VESpost is a 2-second-debounced put() of the whole textarea. That's honest about what it is: if users with write permission are editing the same note at the same time, last-write-wins. The UI flags a changed state if a remote version lands while you're editing, but it doesn't merge — real co-editing would need a CRDT or field-level merge on top of items.

Sharing: the part that's normally a research project

This is the exchange that justifies the whole approach. To share a note with someone:

item.add(['user@acme.com']);   // e2e key exchange happens here
Enter fullscreen mode Exit fullscreen mode

That's it. VES performs the end-to-end key exchange to that user's vault — no key server to operate, no fingerprint-verification ceremony to walk users through. If the recipient doesn't have a vault yet, a temp key is created, and once the recipient sets up their VES, the E2EE key exchange is completed on your side — by your VESpost session if one is open, or by the VES service worker in the background.

Roles ride on the same call. VESpost's share form grants admin (the right to edit and re-share) by pushing a second URI in a special domain:

let add = [input.value]; // the recipient's email or vault uri
if (admin.checked) {
    // If input.value was always an email, we could just do
    // add.push('//.admin/' + input.value)
    // instead of the next two lines. The ref properly handles a vault uri.
    let ref = item.vault.vault(input.value);
    add.push(libVES.Vault.toUri({domain: '.admin', externalId: ref.externalId}));
}
item.add(add);
Enter fullscreen mode Exit fullscreen mode

And the rest of the ACL surface:

item.share()      // -> vaults, each with .owner/.admin/.current flags
item.remove(uri)  // revoke a share
item.refuse()     // decline a note someone shared with you
Enter fullscreen mode Exit fullscreen mode

share() is what renders the access list on each card; the flags drive the owner/admin badges. The item's owner always retains control; removing yourself from someone else's note just drops your own access.

The change log

Every version of every item carries author metadata in the VES repository's log. VESpost's "History Log" (in the item's hamburger menu) replays an item's full history — item.start(0) this time — and reads it out per event:

ev.detail.at                    // when
ev.detail.item.version          // which version
ev.detail.author.vault.short()  // who
ev.detail.author.sessid         // which session
ev.detail.author.remote         // from what IP
ev.detail.author.userAgent      // on what device
ev.detail.item.get()            // the value at that version (still e2e)
Enter fullscreen mode Exit fullscreen mode

Note what this is and isn't: the values are end-to-end encrypted — get() on a historical version decrypts in your browser like any other read — while the attribution (who/when/where) is an authoritative log kept by the VES repository, visible only to the item's participants. You're trusting the repository to record it honestly, the same way you trust a Git host's audit log.

What it deliberately doesn't do

A reference app is only useful if it's honest about its edges:

  • Last-write-wins, as covered — no merging of concurrent edits.
  • Online-first. No offline cache; the app expects to reach ves.host. A production app should at least surface a clear "disconnected" state.
  • The demo domain is a shared namespace. While all items are E2EE, don't use it for production apps.
  • It's a template, not a product. No theming, no hardening pass, no feature-completeness — three files you're meant to fork.

Ship your own

git clone https://github.com/vesvault/vespost
cd vespost
python3 -m http.server 8080   # any static server works
Enter fullscreen mode Exit fullscreen mode

To turn it into your app:

  • swap 'demo' for your own x-* domain (which can later be swapped for your final name without x- to have a real production look on VES);
  • replace the sticky-note UI with whatever your records actually are;
  • host the static files anywhere — GitHub Pages, S3, nginx.

The integration surface you saw above — unlock, start, get/put, add/share/remove, and the event handlers — is the whole thing. The full method reference is on ves.host.

If you build something on it, I'd genuinely like to hear how the API holds up — issues and PRs welcome on vesvault/vespost.

Top comments (0)