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
- Install the ENC wallet extension (Chrome, MV3) — setup guide.
- Open it, choose Import, and paste the 12-word seed
keygen.mjsprinted in step 1 (it's inowner.seed). - The extension now custodies your owner identity. Because it derives keys the same way
owner.mjsdoes,window.enc.getPublicKey()returns the exactOWNER_PUBKEYyour 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 3enc.config.json (written by your deploy script) is all the app needs to find your enclaves:
{
"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.
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
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:5173Open 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 →