Skip to content
ENC Protocol

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:

  1. Receive — the node POSTs every event you're allowed to see to your POST /push.
  2. Project — you fold those events into a tiny read-model (here: one row per author).
  3. 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 }content is ciphertext, from is the node's per-batch sequencer public key. You ECDH-decrypt with your private key and that from.
  • The plaintext is { push_seq, push: [event, …], notify: [...] }. push is a flat array of full events, each carrying its own enclave field.

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.

dataview.mjs
// 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)
  },
}
wrangler.toml
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:

package.json
{
  "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:8789

Keep 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:

register-dataview.mjs
// 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 →