Skip to content
ENC Protocol

4. Build the frontend app

Time for the app — a Vite + React site with two timelines: a public feed and your private notes. It signs every write and authenticates every read through the ENC wallet extension, so your key never touches the app code. The app asks window.enc to sign; the extension holds the seed.

Install the wallet & import your seed

  1. Install the ENC wallet extension (Chrome, MV3) — setup guide.
  2. Open it, choose Import, and paste the 12-word seed keygen.mjs printed in step 1 (it's in owner.seed).
  3. The extension now custodies your owner identity. Because it derives keys the same way owner.mjs does, window.enc.getPublicKey() returns the exact OWNER_PUBKEY your node admits and your enclaves are owned by.

Scaffold the app

npm create vite@latest personal-app -- --template react
cd personal-app
npm config set @enc-protocol:registry https://npm-registry.ocrybit.workers.dev/
npm install @enc-protocol/personal-cli @enc-protocol/core
cp ../enc.config.json .     # the node URL + seqPub + enclave ids from step 3

enc.config.json (written by your deploy script) is all the app needs to find your enclaves:

enc.config.json
{
  "nodeUrl": "http://localhost:8787",
  "seqPub": "79be667e…",
  "enclaves": { "Personal": "fff25e6a…" }
}

src/enc-wallet.js — wire the SDK to the wallet

PersonalSdk is platform-agnostic: it takes one adapter per enclave. This adapter implements the two things the node requires — wallet-signed writes and ECDH-authenticated reads — entirely through window.enc. No private key appears anywhere below.

src/enc-wallet.js
import { PersonalSdk } from '@enc-protocol/personal-cli'
import { mkCommit } from '@enc-protocol/core/event.js'
import {
  hexToBytes, deriveSignerPriv, ecdh, deriveKey, encrypt, decrypt,
} from '@enc-protocol/core/crypto.js'
 
const TTL = 300000 // commit expiry, 5 min
 
/** Wait for the provider, connect (one approval popup), return its pubHex. */
export async function connectWallet(enc = window.enc) {
  if (!enc) throw new Error('ENC wallet extension not found — install it and reload')
  const { approved, pubKey } = await enc.connect()
  if (!approved) throw new Error('wallet connection rejected')
  return pubKey || (await enc.getPublicKey())
}
 
/** An adapter that signs through the wallet and does ECDH reads — no key here. */
class WalletAdapter {
  constructor({ nodeUrl, enclaveId, seqPubHex, pubHex, enc }) {
    Object.assign(this, { nodeUrl, enclaveId, seqPubHex, pubHex, enc })
    this._session = null
  }
 
  async _post(body) {
    const res = await fetch(this.nodeUrl, {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(body),
    })
    return res.json()
  }
 
  // WRITE — the wallet signs the commit hash; the key never leaves it.
  async submit(type, content) {
    const data = typeof content === 'string' ? content : JSON.stringify(content)
    const commit = mkCommit(this.enclaveId, this.pubHex, type, data, Date.now() + TTL, [])
    const sig = await this.enc.signSchnorr(commit.hash)
    const r = await this._post({ ...commit, sig })
    if (r.type === 'Error') return { error: r.code || r.message, ...r }
    return { ok: true, ...r }
  }
 
  // READ — an ECDH session the wallet mints; the node returns ciphertext.
  async _ensureSession() {
    const now = Math.floor(Date.now() / 1000)
    if (!this._session || now >= this._session.expires - 300) {
      const s = await this.enc.createSession(7200)
      this._session = { session: s.session, sessionPriv: hexToBytes(s.sessionPriv), expires: s.expires }
    }
    return this._session
  }
 
  async query(type, opts = {}) {
    const filter = { enclave: this.enclaveId, limit: opts.limit || 100 }
    if (type && type !== '*') filter.type = type
 
    const { session, sessionPriv } = await this._ensureSession()
    const sessionPub = hexToBytes(session.slice(64, 128))
    const seqPub = hexToBytes(this.seqPubHex)
    const signerPriv = deriveSignerPriv(sessionPriv, sessionPub, seqPub, this.enclaveId)
    const shared = ecdh(signerPriv, seqPub)
    const ciphertext = encrypt(deriveKey(shared, 'enc:query'), JSON.stringify({ session, filter }))
 
    const body = await this._post({
      type: 'Query', enclave: this.enclaveId, from: this.pubHex,
      content: session + '.' + ciphertext,
    })
    if (body.type === 'Response' && body.content) {
      const out = JSON.parse(decrypt(deriveKey(shared, 'enc:response'), body.content))
      return (out.events || []).map((e) => e.event || e)
    }
    return (body.events || []).map((e) => e.event || e)
  }
 
  // RBAC helpers — required by the adapter contract, unused by this UI
  async grant(t, role) { return this.submit(`Grant(${role})`, { identity: t }) }
  async revoke(t, role) { return this.submit(`Revoke(${role})`, { identity: t }) }
  async move(t, a, b) { return this.submit(`Move(${a},${b})`, { identity: t }) }
  async transfer(t, tr) { return this.submit(`Transfer(${tr})`, { identity: t }) }
  subscribe() { return () => {} }
  as() { return this }
}
 
/** Connect the wallet and build a PersonalSdk bound to your minted enclave. */
export async function createPersonalSdk(config, enc = window.enc) {
  const pubHex = await connectWallet(enc)
  const adapter = new WalletAdapter({
    nodeUrl: config.nodeUrl, enclaveId: config.enclaves.Personal,
    seqPubHex: config.seqPub, pubHex, enc,
  })
  const sdk = new PersonalSdk({ adapter, identity: { pubHex } })   // one enclave → one adapter
  await sdk.init()
  return { sdk, pubHex }
}

The write path is one swap: build the commit, ask the extension to signSchnorr(commit.hash), and attach the signature — identical wire bytes to a key-signed commit, except the key stayed in the extension. The read path mints a short-lived session with createSession() and derives the ECDH key the node expects; the node hands back ciphertext only this session can open.

src/App.jsx — two timelines

src/App.jsx
import { useCallback, useEffect, useState } from 'react'
import config from '../enc.config.json'
import { createPersonalSdk } from './enc-wallet.js'
 
const draftsOf = (events) => events.map((e) => JSON.parse(e.content).draft)
 
export default function App() {
  const [sdk, setSdk] = useState(null)
  const [me, setMe] = useState(null)
  const [posts, setPosts] = useState([])
  const [notes, setNotes] = useState([])
  const [error, setError] = useState(null)
 
  const refresh = useCallback(async (s) => {
    setPosts(draftsOf(await s.queryPublic()))   // owner reads its public events…
    setNotes(draftsOf(await s.queryPrivate()))  // …and its owner-only private ones
  }, [])
 
  const connect = useCallback(async () => {
    try {
      const { sdk, pubHex } = await createPersonalSdk(config)
      setSdk(sdk); setMe(pubHex)
      await refresh(sdk)
    } catch (e) { setError(e.message) }
  }, [refresh])
 
  const post = async (kind, text) => {
    if (!text.trim()) return
    kind === 'public' ? await sdk.submitPublic({ draft: text })
                      : await sdk.submitPrivate({ draft: text })
    await refresh(sdk)
  }
 
  if (!sdk) return (
    <main style={{ fontFamily: 'system-ui', maxWidth: 640, margin: '4rem auto', padding: '0 1rem' }}>
      <h1>My Personal App</h1>
      <button onClick={connect}>Connect wallet</button>
      {error && <p style={{ color: 'crimson' }}>{error}</p>}
    </main>
  )
 
  return (
    <main style={{ fontFamily: 'system-ui', maxWidth: 640, margin: '2rem auto', padding: '0 1rem' }}>
      <header style={{ display: 'flex', justifyContent: 'space-between' }}>
        <h1>My Personal App</h1>
        <code title={me}>{me.slice(0, 8)}…</code>
      </header>
 
      <Timeline title="🌐 Public feed" kind="public" items={posts} onPost={post}
        hint="published — your dataview projects these to a public feed" />
      <Timeline title="🔒 Private notes" kind="private" items={notes} onPost={post}
        hint="owner-only — the node admits no other reader" />
    </main>
  )
}
 
function Timeline({ title, kind, items, onPost, hint }) {
  const [text, setText] = useState('')
  const submit = (e) => { e.preventDefault(); onPost(kind, text); setText('') }
  return (
    <section style={{ marginTop: '2rem' }}>
      <h2 style={{ marginBottom: 0 }}>{title}</h2>
      <small style={{ color: '#888' }}>{hint}</small>
      <form onSubmit={submit} style={{ display: 'flex', gap: 8, margin: '0.75rem 0' }}>
        <input value={text} onChange={(e) => setText(e.target.value)}
          placeholder={`Write a ${kind} ${kind === 'public' ? 'post' : 'note'}…`}
          style={{ flex: 1, padding: 8 }} />
        <button type="submit">Post</button>
      </form>
      <ul style={{ listStyle: 'none', padding: 0 }}>
        {items.map((t, i) => <li key={i} style={{ padding: '6px 0', borderBottom: '1px solid #eee' }}>{t}</li>)}
      </ul>
    </section>
  )
}

That's the whole app: a Connect wallet button, then two composers writing public and private events, and two lists reading them back. Vite's React template already wired src/main.jsx and index.html, so there's nothing else to add.

Run it

With your node and dataview running and your enclaves minted:

npm run dev      # → http://localhost:5173

Open it, click Connect wallet, approve once, and post. A public post lands in your enclave and gets projected by your dataview into the world-readable feed; a private note stays in the enclave where the RBAC you minted admits no reader but you. Every write was signed by the extension; every read was an ECDH session it authorized — and the app never saw your key.

Next: Write a test →