#!/usr/bin/env node
/**
* Mini web example for hyper-orgs
*
* SSR org browser. Pages:
* GET / → list all orgs + create-org form
* GET /:name → show org + members + resources + grant form
* POST /create → create an org
* POST /:name/member → add a member
* POST /:name/resource → add a resource
* POST /:name/grant → create a group grant for a user
*
* Usage: node examples/web.js [--port 9097] [--storage <path>]
*
* NOTE: Uses its own storage path by default (./.hyper-orgs-web). Does NOT
* share state with examples/cli.js — RocksDB single-writer lock.
*/
import http from 'http'
import Corestore from 'corestore'
import b4a from 'b4a'
import { HyperOrgs } from '../index.js'
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')) || 9097
const storagePath = getFlag('storage') || './.hyper-orgs-web'
const store = new Corestore(storagePath)
await store.ready()
const orgs = new HyperOrgs(store, { namespace: 'orgs' })
await orgs.ready()
function escapeHtml (s) {
return String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
}
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 all = await orgs.list()
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
res.end(renderIndex(all))
return
}
const showMatch = url.match(/^\/([a-z0-9][a-z0-9-]*[a-z0-9])$/)
if (req.method === 'GET' && showMatch) {
const name = showMatch[1]
const org = await orgs.get(name)
if (!org) {
res.writeHead(404, { 'Content-Type': 'text/plain' })
res.end('Not found')
return
}
const grants = await orgs.listGrants({ grantType: 'group', groupName: name })
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
res.end(renderOrg(org, grants))
return
}
if (req.method === 'POST' && url === '/create') {
const params = new URLSearchParams(await collectBody(req))
const name = params.get('name')
const type = params.get('type') || 'group'
const displayName = params.get('displayName') || name
const description = params.get('description') || ''
if (name) {
try {
await orgs.create(name, type, { displayName, description })
res.writeHead(303, { Location: '/' + name })
res.end()
return
} catch (err) {
res.writeHead(400, { 'Content-Type': 'text/plain' })
res.end(err.message)
return
}
}
res.writeHead(400)
res.end('name required')
return
}
const memberMatch = url.match(/^\/([a-z0-9][a-z0-9-]*[a-z0-9])\/member$/)
if (req.method === 'POST' && memberMatch) {
const name = memberMatch[1]
const params = new URLSearchParams(await collectBody(req))
const userId = params.get('userId')
const role = params.get('role') || 'member'
if (userId) await orgs.addMember(name, userId, role)
res.writeHead(303, { Location: '/' + name })
res.end()
return
}
const resourceMatch = url.match(/^\/([a-z0-9][a-z0-9-]*[a-z0-9])\/resource$/)
if (req.method === 'POST' && resourceMatch) {
const name = resourceMatch[1]
const params = new URLSearchParams(await collectBody(req))
const resource = params.get('resource')
if (resource) await orgs.addResource(name, resource)
res.writeHead(303, { Location: '/' + name })
res.end()
return
}
const grantMatch = url.match(/^\/([a-z0-9][a-z0-9-]*[a-z0-9])\/grant$/)
if (req.method === 'POST' && grantMatch) {
const name = grantMatch[1]
const params = new URLSearchParams(await collectBody(req))
const subjectId = params.get('subjectId')
const permission = params.get('permission') || 'write'
if (subjectId) {
await orgs.createGrant({
subjectType: 'user',
subjectId,
grantType: 'group',
groupName: name,
permission,
grantedBy: 'web'
})
}
res.writeHead(303, { Location: '/' + name })
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 styles () {
return `
body { font-family: monospace; background: #0d1117; color: #c9d1d9; padding: 2rem; max-width: 800px; margin: 0 auto; }
h1 { color: #58a6ff; }
h2 { font-size: 1rem; color: #c9d1d9; margin-top: 0; }
a { color: #58a6ff; text-decoration: none; }
a:hover { text-decoration: underline; }
.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; }
.row { display: flex; justify-content: space-between; padding: 0.3rem 0; border-bottom: 1px solid #21262d; }
.row:last-child { border-bottom: none; }
.label { color: #8b949e; }
.org-row { padding: 0.6rem 0.5rem; border-bottom: 1px solid #21262d; }
.org-row:last-child { border-bottom: none; }
.org-row .kind { display: inline-block; width: 4rem; color: #8b949e; font-size: 0.75rem; }
.org-row .name { color: #c9d1d9; font-weight: bold; }
.org-row .meta { color: #6e7681; font-size: 0.75rem; margin-top: 0.2rem; margin-left: 4rem; }
.tag { display: inline-block; padding: 0.05rem 0.4rem; margin-right: 0.3rem; border-radius: 999px; font-size: 0.7rem; background: #1f6feb22; border: 1px solid #1f6feb44; color: #58a6ff; }
.role-admin { background: #2ea04322; color: #3fb950; border: 1px solid #2ea04344; padding: 0.05rem 0.4rem; border-radius: 4px; font-size: 0.7rem; }
.role-member { background: #30363d; color: #8b949e; border: 1px solid #484f58; padding: 0.05rem 0.4rem; border-radius: 4px; font-size: 0.7rem; }
input, textarea, button, select { background: #0d1117; color: #c9d1d9; border: 1px solid #30363d; padding: 0.4rem 0.6rem; border-radius: 4px; font-family: monospace; font-size: 0.85rem; }
textarea { width: 100%; min-height: 3em; }
input, select { width: 100%; box-sizing: border-box; }
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; }
.field { margin-bottom: 0.5rem; }
.field label { display: block; color: #8b949e; font-size: 0.75rem; margin-bottom: 0.2rem; }
.empty { color: #484f58; font-style: italic; padding: 1rem 0; }
form { margin: 0; }
.hstack { display: flex; gap: 0.5rem; }
`
}
function renderIndex (all) {
const groups = all.filter(o => o.type === 'group')
const users = all.filter(o => o.type === 'user')
return `<!DOCTYPE html>
<html><head><title>hyper-orgs</title><style>${styles()}</style></head><body>
<h1><a href="/">hyper-orgs</a></h1>
<div class="card">
<h2>${groups.length} Group${groups.length === 1 ? '' : 's'}</h2>
${groups.length === 0 ? '<div class="empty">(no groups)</div>' : groups.map(o => `
<div class="org-row">
<div>
<span class="kind">group</span>
<a class="name" href="/${o.name}">${escapeHtml(o.name)}</a>
${o.visibility === 'private' ? '<span class="tag">private</span>' : ''}
</div>
<div class="meta">${escapeHtml(o.displayName)} · ${o.members.length} members · ${o.resources.length} resources</div>
</div>`).join('')}
</div>
<div class="card">
<h2>${users.length} User${users.length === 1 ? '' : 's'}</h2>
${users.length === 0 ? '<div class="empty">(no user orgs)</div>' : users.map(o => `
<div class="org-row">
<div>
<span class="kind">user</span>
<a class="name" href="/${o.name}">${escapeHtml(o.name)}</a>
</div>
<div class="meta">${escapeHtml(o.displayName)} · ${o.resources.length} resources</div>
</div>`).join('')}
</div>
<div class="card">
<h2>Create org</h2>
<form action="/create" method="POST">
<div class="field"><label>Name</label><input name="name" placeholder="my-team" required pattern="[a-z0-9][a-z0-9-]*[a-z0-9]"></div>
<div class="field"><label>Type</label>
<select name="type">
<option value="group">group</option>
<option value="user">user</option>
</select>
</div>
<div class="field"><label>Display name</label><input name="displayName"></div>
<div class="field"><label>Description</label><textarea name="description"></textarea></div>
<button class="btn-primary" type="submit">Create</button>
</form>
</div>
</body></html>`
}
function renderOrg (org, grants) {
return `<!DOCTYPE html>
<html><head><title>hyper-orgs / ${escapeHtml(org.name)}</title><style>${styles()}</style></head><body>
<h1><a href="/">hyper-orgs</a> / ${escapeHtml(org.name)}</h1>
<div class="card">
<h2>Org</h2>
<div><strong>${escapeHtml(org.displayName)}</strong> <span class="tag">${org.type}</span> ${org.visibility === 'private' ? '<span class="tag">private</span>' : ''}</div>
${org.description ? '<div style="margin-top:0.5rem;color:#8b949e">' + escapeHtml(org.description) + '</div>' : ''}
<div class="meta" style="color:#6e7681;font-size:0.75rem;margin-top:0.5rem">
created ${new Date(org.createdAt).toLocaleString()}
</div>
</div>
${org.type === 'group' ? `
<div class="card">
<h2>${org.members.length} Member${org.members.length === 1 ? '' : 's'}</h2>
${org.members.length === 0 ? '<div class="empty">(no members)</div>' : org.members.map(m => `
<div class="row">
<div>${escapeHtml(m.userId)}</div>
<div><span class="role-${m.role}">${m.role}</span></div>
</div>`).join('')}
<form action="/${org.name}/member" method="POST" style="margin-top:0.8rem">
<div class="hstack">
<input name="userId" placeholder="userId" required>
<select name="role" style="width:auto">
<option value="member">member</option>
<option value="admin">admin</option>
</select>
<button type="submit">Add</button>
</div>
</form>
</div>
` : ''}
<div class="card">
<h2>${org.resources.length} Resource${org.resources.length === 1 ? '' : 's'}</h2>
${org.resources.length === 0 ? '<div class="empty">(no resources)</div>' : org.resources.map(r => `
<div class="row"><div>${escapeHtml(r)}</div></div>`).join('')}
<form action="/${org.name}/resource" method="POST" style="margin-top:0.8rem">
<div class="hstack">
<input name="resource" placeholder="resource-name" required>
<button type="submit">Add</button>
</div>
</form>
</div>
<div class="card">
<h2>${grants.length} Active grant${grants.length === 1 ? '' : 's'}</h2>
${grants.length === 0 ? '<div class="empty">(no grants)</div>' : grants.map(g => `
<div class="row">
<div>${escapeHtml(g.subjectId)} <span class="tag">${g.subjectType}</span></div>
<div><span class="tag">${g.permission}</span></div>
</div>`).join('')}
<form action="/${org.name}/grant" method="POST" style="margin-top:0.8rem">
<div class="hstack">
<input name="subjectId" placeholder="userId" required>
<select name="permission" style="width:auto">
<option value="write">write</option>
<option value="moderator">moderator</option>
<option value="owner">owner</option>
</select>
<button type="submit">Grant</button>
</div>
</form>
</div>
</body></html>`
}
httpServer.listen(port, () => {
console.log('hyper-orgs dashboard:')
console.log(' http://localhost:%d', port)
console.log(' storage: %s', storagePath)
console.log(' orgs key: %s', orgs.keyHex)
})