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. (A generated dataview is also available, but it's plugin-heavy and most of its query methods are V1 codegen stubs; this 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, type, 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: { enclaves: [{ enclave, events: [event, …] }] } }. The events are grouped by enclave — iterate push.enclaves, then each group's events. Each event also carries 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.

The server

A dataview is an HTTP server that receives encrypted events from the node, projects them into a read-model, and serves queries. Create a project folder and implement the three endpoints: one to advertise your public key (GET /identity), one to receive encrypted event batches (POST /push), and one to serve your read-model queries (GET /profiles).

dataview.mjs
// receive · project · answer
import {
  ecdh, deriveKey, decrypt, hexToBytes, bytesToHex, derivePublicKey,
} from '@enc-protocol/core'
 
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 } })
 
class DataView {
  constructor(privateKeyHex) {
    // Our identity. The node ECDH-encrypts each push TO this public key, so we
    // need the private key to decrypt.
    this.priv = hexToBytes(privateKeyHex)
    this.pubHex = bytesToHex(derivePublicKey(this.priv))
 
    // The read-model: latest public post per (enclave, author).
    // You'd typically persist this to a database; for this example we use in-memory.
    this.profiles = new Map()
  }
 
  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, type, url }.
    if (request.method === 'POST' && pathname === '/push') {
      const wire = await request.json()
      const payload = this.#decrypt(wire)          // { push_seq, push: { enclaves: [{ enclave, events }] } }
      // `push.enclaves` groups events by enclave — flatten to each event (it also
      // carries its own `.enclave`).
      let ingested = 0
      for (const group of payload.push?.enclaves || []) {
        for (const event of group.events || []) { this.#project({ enclave: group.enclave, ...event }); ingested++ }
      }
      return json({ ok: true, ingested })
    }
 
    // ── ANSWER ── the public feed: latest public post per author, newest first.
    if (request.method === 'GET' && pathname === '/profiles') {
      const profiles = Array.from(this.profiles.values())
        .sort((a, b) => (b.timestamp - a.timestamp) || (b.seq - a.seq))
        .slice(0, 100)
      return json({ profiles })
    }
 
    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.
  #project(event) {
    if (event.type !== 'public') return
    let draft = ''
    try { draft = JSON.parse(event.content).draft ?? '' } catch { return }
    const key = `${event.enclave}:${event.from}`
    this.profiles.set(key, {
      enclave: event.enclave,
      author: event.from,
      draft,
      seq: event.seq,
      timestamp: event.timestamp,
    })
  }
}
 
const dataview = new DataView(process.env.DATAVIEW_PRIVATE_KEY)
const server = Bun.serve({
  port: process.env.PORT || 8789,
  async fetch(request) {
    return dataview.fetch(request)
  },
})
console.log(`Dataview listening on http://localhost:${server.port}`)

package.json — install @enc-protocol/core:

package.json
{
  "name": "my-dataview",
  "type": "module",
  "dependencies": { "@enc-protocol/core": "^0.9.0" }
}

Run it

The node from step 1 is on :8080, so run the dataview on :8789 (Vite's dev server in step 4 takes :5173). This example uses Bun as the runtime, but any Node.js HTTP framework (Express, Hono, etc.) works identically — the dataview logic is platform-agnostic.

npm install
 
# a throwaway 32-byte key for local dev (store in .env, keep out of git)
DATAVIEW_PRIVATE_KEY=$(python3 -c "print('11'*32)") bun dataview.mjs
# → Dataview listening on http://localhost: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'
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 →