2. Write a dataview server
The Personal app has one cross-enclave read: profiles — the latest public post per user,
aggregated across every user's enclave. No single node can answer that, because each enclave
lives on its own node. So you run a dataview: a small server that subscribes to events the
node pushes it, projects them into a read-model, and answers queries.
A dataview does exactly three things:
- Receive — the node POSTs every event you're allowed to see to your
POST /push. - Project — you fold those events into a tiny read-model (here: one row per author).
- Answer — you serve that read-model over
GET /profiles.
This step writes that server by hand — about 120 lines — so the contract with the node is fully visible. (The Personal app ships a generated dataview, but it's plugin-heavy and most of its query methods are V1 codegen stubs; the hand-written version is the clearest working reference.)
How the node feeds the dataview
The node doesn't expose a public, anonymous "give me all events" endpoint — every read
(Pull, Query) is ECDH-session-authenticated. The one path built for a dataview is push:
- You register the dataview's URL with the enclave (once).
- On every committed event, the node checks RBAC: if your dataview's role has
Project permission on that event type, it queues the full event. - A moment later it POSTs you an encrypted batch. The wire message is
{ content, from, to, url }—contentis ciphertext,fromis the node's per-batch sequencer public key. You ECDH-decrypt with your private key and thatfrom. - The plaintext is
{ push_seq, push: [event, …], notify: [...] }.pushis a flat array of full events, each carrying its ownenclavefield.
That's why the dataview needs a keypair: the node encrypts each push to your public key, so only you can read it. Push (decrypt one batch) is far less code than Pull (build a full ECDH session and encrypt the request body), so we use push.
The server
It's a Cloudflare Worker backed by a Durable Object — run with wrangler dev. The DO gives us
a free embedded SQLite (state.storage.sql) with no external database to stand up, and a Worker is
the natural HTTP peer for a node that already pushes over HTTP.
Create a project folder and drop in three files.
// receive · project · answer
import {
ecdh, deriveKey, decrypt, hexToBytes, bytesToHex, derivePublicKey,
} from '@enc-protocol/core/crypto.js'
const CORS = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
}
const json = (body, status = 200) =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json', ...CORS } })
export class DataViewDO {
constructor(state, env) {
this.sql = state.storage.sql
// Our identity. The node ECDH-encrypts each push TO this public key, so we
// need the private key to decrypt. Any 32-byte key works for local dev.
if (!env.DATAVIEW_PRIVATE_KEY) throw new Error('DATAVIEW_PRIVATE_KEY missing (see .dev.vars)')
this.priv = hexToBytes(env.DATAVIEW_PRIVATE_KEY)
this.pubHex = bytesToHex(derivePublicKey(this.priv))
// The read-model: one row per (enclave, author) = "latest public post per
// user, across enclaves" — exactly the cross-enclave `profiles` feed.
this.sql.exec(`CREATE TABLE IF NOT EXISTS profiles (
enclave TEXT NOT NULL,
author TEXT NOT NULL,
draft TEXT NOT NULL,
seq INTEGER NOT NULL,
timestamp INTEGER NOT NULL,
PRIMARY KEY (enclave, author)
)`)
}
async fetch(request) {
const { pathname } = new URL(request.url)
if (request.method === 'OPTIONS') return new Response(null, { status: 204, headers: CORS })
// The node fetches this once to learn the key it must encrypt pushes to.
if (request.method === 'GET' && pathname === '/identity') {
return json({ pubHex: this.pubHex })
}
// ── RECEIVE ── encrypted wire message { content, from, to, url } (no `type`).
if (request.method === 'POST' && pathname === '/push') {
const wire = await request.json()
const payload = this.#decrypt(wire) // { push_seq, push:[event...], notify:[...] }
// `push` is a FLAT array; each event carries its own `.enclave`.
for (const event of payload.push || []) this.#project(event)
return json({ ok: true, ingested: (payload.push || []).length })
}
// ── ANSWER ── the public feed: latest public post per author, newest first.
if (request.method === 'GET' && pathname === '/profiles') {
const rows = this.sql.exec(
`SELECT enclave, author, draft, seq, timestamp FROM profiles
ORDER BY timestamp DESC, seq DESC LIMIT 100`,
).toArray()
return json({ profiles: rows })
}
return json({ error: 'not_found' }, 404)
}
// Mirror the node's encrypt side: ECDH(ourPriv, nodeSeqPub) → HKDF "enc:push" → XChaCha20.
#decrypt(wire) {
const shared = ecdh(this.priv, hexToBytes(wire.from)) // wire.from = node's seq pubkey
const key = deriveKey(shared, 'enc:push')
return JSON.parse(decrypt(key, wire.content))
}
// ── PROJECT ── fold one event in. We only index `public` posts; INSERT OR REPLACE
// keyed on (enclave, author) keeps exactly the latest post per user.
#project(event) {
if (event.type !== 'public') return
let draft = ''
try { draft = JSON.parse(event.content).draft ?? '' } catch { return }
this.sql.exec(
`INSERT OR REPLACE INTO profiles (enclave, author, draft, seq, timestamp)
VALUES (?, ?, ?, ?, ?)`,
event.enclave, event.from, draft, event.seq, event.timestamp,
)
}
}
export default {
async fetch(request, env) {
const id = env.DATAVIEW.idFromName('singleton')
return env.DATAVIEW.get(id).fetch(request)
},
}name = "my-dataview"
main = "dataview.mjs"
compatibility_date = "2024-11-15"
[[durable_objects.bindings]]
name = "DATAVIEW"
class_name = "DataViewDO"
[[migrations]]
tag = "v1"
new_sqlite_classes = ["DataViewDO"]package.json — pull @enc-protocol/core from the ENC registry:
{
"name": "my-dataview",
"type": "module",
"dependencies": { "@enc-protocol/core": "*" }
}Run it
The node from step 1 is on :8787, so run the dataview on :8789 (Vite's dev server in step 4
takes :5173).
npm config set @enc-protocol:registry https://npm-registry.ocrybit.workers.dev/
npm install
# a throwaway 32-byte key for local dev (.dev.vars is gitignored)
echo "DATAVIEW_PRIVATE_KEY=$(python3 -c "print('11'*32)")" > .dev.vars
npx wrangler dev --port 8789 # → http://127.0.0.1:8789Keep it running next to the node. Confirm it's up and grab its public key — the node will need it:
curl http://127.0.0.1:8789/identity
# → {"pubHex":"02ab…"}Register it with the node
The node only pushes to URLs it's been told about. The cleanest way is to name the dataview in
your enclave's manifest — you'll do exactly that in step 3, which
bakes { identity, endpoint } into the manifest's initial_state so the node registers the push
route the moment the enclave is minted.
If instead you want to register against an already-minted enclave, submit one owner-signed
Grant(dataview) that carries the endpoint — it gives the dataview role its Push permission
and hands the node the URL, in one commit:
// run once, as the enclave owner
import { NetworkAdapter } from '@enc-protocol/client/network-adapter.js'
import { ownerFromMnemonic } from './owner.mjs'
import { readFileSync } from 'node:fs'
const DATAVIEW_URL = process.env.DATAVIEW_URL || 'http://localhost:8789'
const PUSH_URL = DATAVIEW_URL.replace(/\/$/, '') + '/push' // the node POSTs the batch here
const cfg = JSON.parse(readFileSync('enc.config.json', 'utf8')) // node URL + enclave ids (step 3)
const owner = ownerFromMnemonic(readFileSync('owner.seed', 'utf8')) // your owner identity (step 1)
// 1. ask the dataview for the key the node must encrypt pushes to
const { pubHex } = await (await fetch(DATAVIEW_URL + '/identity')).json()
// 2. owner-signed Grant(dataview) into the Personal enclave, carrying the push URL.
// The node POSTs each batch to `endpoint` verbatim, so register the FULL /push
// route — not the base URL — or the pushes 404.
const personal = new NetworkAdapter(cfg.nodeUrl, cfg.enclaves.Personal, owner)
await personal.submit('Grant(dataview)', { identity: pubHex, endpoint: PUSH_URL })
console.log('registered dataview', pubHex, '→', PUSH_URL)Run it from your app project — where owner.mjs, enc.config.json, and the
@enc-protocol/client install live (from steps 1 & 3) — not the dataview folder.
See the feed
Now write a public post as the owner (step 4's Public feed composer / sdk.submitPublic(…)
does this), then read the cross-enclave feed straight off the dataview:
curl http://127.0.0.1:8789/profiles
# → {"profiles":[
# {"enclave":"3c1f…","author":"02ab…","draft":"gm from my enclave","seq":7,"timestamp":1750000000000}
# ]}The node pushed the public event to POST /push, the dataview decrypted it, projected it into
the profiles table, and GET /profiles returned it. Post again and the row updates in place —
the feed always holds the latest public post per author, across every enclave that grants this
dataview. That's the whole loop: receive → project → answer.
Next: Deploy the Personal enclave →