OverviewFilesCommitsIssuesDocsReleasesPeers

trust-map / examples / web.js

196 lines · javascript ref:
#!/usr/bin/env node
/**
 * Mini web example for trust-map
 *
 * SSR dashboard for browsing and managing the trust map. CLI ↔ Web operate
 * on the same Hyperbee, so changes from the CLI show up in the web UI on
 * refresh (and vice versa).
 *
 * Usage: node examples/web.js [--port 9094] [--storage <path>]
 */

import http from 'http'
import Corestore from 'corestore'
import { TrustMap } from '../index.js'
import b4a from 'b4a'

const args = process.argv.slice(2)
function getFlag (name) {
  const i = args.indexOf('--' + name)
  return i !== -1 && args[i + 1] ? args[i + 1] : null
}

const port = parseInt(getFlag('port')) || 9094
// NOTE: distinct default from examples/cli.js — they don't share state.
// See cli.js header for the rationale (RocksDB single-writer lock).
const storagePath = getFlag('storage') || './.trust-map-web'

const store = new Corestore(storagePath)
await store.ready()
const core = store.get({ name: 'trust-map' })
await core.ready()
const tm = new TrustMap({ core })
await tm.ready()

function escapeHtml (s) {
  return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}

function collectBody (req) {
  return new Promise((resolve, reject) => {
    const parts = []
    req.on('data', chunk => parts.push(chunk))
    req.on('end', () => resolve(b4a.concat(parts).toString('utf8')))
    req.on('error', reject)
  })
}

const httpServer = http.createServer(async (req, res) => {
  const url = req.url.split('?')[0]

  try {
    if (req.method === 'GET' && url === '/') {
      const entries = await tm.list()
      res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
      res.end(renderPage(entries))
      return
    }

    if (req.method === 'POST' && url === '/grant') {
      const body = await collectBody(req)
      const params = new URLSearchParams(body)
      const pubkey = params.get('pubkey')
      const resource = params.get('resource')
      const permsStr = params.get('perms')
      if (pubkey && resource && permsStr) {
        const perms = permsStr.split(',').map(s => s.trim()).filter(Boolean)
        if (perms.length) await tm.grant(pubkey, resource, perms)
      }
      res.writeHead(303, { Location: '/' })
      res.end()
      return
    }

    if (req.method === 'POST' && url === '/revoke') {
      const body = await collectBody(req)
      const params = new URLSearchParams(body)
      const pubkey = params.get('pubkey')
      const resource = params.get('resource')
      if (pubkey && resource) {
        await tm.revoke(pubkey, resource)
      }
      res.writeHead(303, { Location: '/' })
      res.end()
      return
    }

    res.writeHead(404, { 'Content-Type': 'text/plain' })
    res.end('Not found')
  } catch (err) {
    console.error(err)
    res.writeHead(500, { 'Content-Type': 'text/plain' })
    res.end(err.message)
  }
})

function renderPage (entries) {
  const grouped = {}
  for (const e of entries) {
    if (!grouped[e.pubkey]) grouped[e.pubkey] = []
    grouped[e.pubkey].push(e)
  }
  const pubkeys = Object.keys(grouped).sort()

  return `<!DOCTYPE html>
<html><head><title>trust-map</title>
<style>
  body { font-family: monospace; background: #0d1117; color: #c9d1d9; padding: 2rem; max-width: 900px; margin: 0 auto; }
  h1 { color: #58a6ff; }
  h2 { font-size: 1rem; color: #58a6ff; margin-top: 1.5rem; }
  .card { background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 1rem; margin: 1rem 0; }
  .card h2 { margin: 0 0 0.5rem; font-size: 0.75rem; text-transform: uppercase; color: #8b949e; }
  table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
  th { text-align: left; color: #8b949e; padding: 0.4rem 0.5rem; border-bottom: 1px solid #30363d; }
  td { padding: 0.4rem 0.5rem; border-bottom: 1px solid #21262d; }
  .pubkey { font-family: monospace; color: #79c0ff; font-size: 0.8rem; }
  .perm { display: inline-block; background: #1f6feb22; border: 1px solid #1f6feb44; color: #58a6ff; padding: 0.1rem 0.4rem; border-radius: 3px; font-size: 0.75rem; margin-right: 0.3rem; }
  .perm-admin { background: #f8514922; border-color: #f8514944; color: #f85149; }
  .perm-write { background: #d29922aa; border-color: #d2992244; color: #d29922; }
  input, button { background: #0d1117; color: #c9d1d9; border: 1px solid #30363d; padding: 0.4rem 0.6rem; border-radius: 4px; font-family: monospace; font-size: 0.85rem; }
  input { margin-right: 0.3rem; }
  input.long { width: 30rem; }
  button { background: #21262d; cursor: pointer; }
  button:hover { background: #30363d; }
  button.btn-primary { background: #1f6feb; border-color: #1f6feb; color: white; }
  button.btn-primary:hover { background: #1158c7; }
  button.btn-danger { border-color: #f8514966; color: #f85149; padding: 0.2rem 0.5rem; font-size: 0.75rem; }
  .empty { color: #484f58; font-style: italic; padding: 1rem; }
</style></head><body>
<h1>trust-map</h1>

<div class="card">
  <h2>Stats</h2>
  <div>${pubkeys.length} pubkey(s) · ${entries.length} grant(s)</div>
</div>

<div class="card">
  <h2>Grant permissions</h2>
  <form method="POST" action="/grant">
    <div style="margin-bottom: 0.5rem">
      <input class="long" name="pubkey" placeholder="pubkey (hex or base64)" required>
    </div>
    <div style="margin-bottom: 0.5rem">
      <input name="resource" placeholder="resource name (or *)" required>
      <input name="perms" placeholder="perms (comma-separated, e.g. read,write)" required>
      <button class="btn-primary" type="submit">Grant</button>
    </div>
  </form>
</div>

<div class="card">
  <h2>Trust map (${entries.length} entries)</h2>
  ${pubkeys.length === 0 ? '<div class="empty">(empty — grant something to get started)</div>' : pubkeys.map(pk => `
  <h2 style="margin-top: 1rem">${escapeHtml(pk)}</h2>
  <table>
    <thead><tr><th>Resource</th><th>Permissions</th><th></th></tr></thead>
    <tbody>
      ${grouped[pk].map(e => `
      <tr>
        <td>${escapeHtml(e.resource)}</td>
        <td>${e.perms.map(p => `<span class="perm ${p === 'admin' ? 'perm-admin' : p === 'write' ? 'perm-write' : ''}">${escapeHtml(p)}</span>`).join('')}</td>
        <td>
          <form method="POST" action="/revoke" style="margin: 0">
            <input type="hidden" name="pubkey" value="${escapeHtml(pk)}">
            <input type="hidden" name="resource" value="${escapeHtml(e.resource)}">
            <button class="btn-danger" type="submit">revoke</button>
          </form>
        </td>
      </tr>`).join('')}
    </tbody>
  </table>`).join('')}
</div>

<div class="card">
  <h2>How this works</h2>
  <p style="font-size: 0.85rem; line-height: 1.6">
    This dashboard manages a <code>trust-map</code> instance backed by a Hyperbee.
    It shares storage with the CLI example — anything you grant here is visible
    to <code>cli.js list</code>, and vice versa. Wildcard resource <code>*</code>
    grants apply to all resources for that pubkey.
  </p>
  <p style="font-size: 0.85rem; line-height: 1.6">
    For chain verification (hyperident attestations), the trust-map module's
    <code>checkChain()</code> method is exercised by the unit tests. The web
    UI shows direct grants only.
  </p>
</div>

</body></html>`
}

httpServer.listen(port, () => {
  console.log('trust-map dashboard:')
  console.log('  http://localhost:%d', port)
  console.log('  storage: %s', storagePath)
})