first commit
Release rolling / release (push) Has been cancelled
CI / test (push) Has been cancelled

This commit is contained in:
Raven Scott
2026-07-18 16:17:38 -04:00
commit 015d92a257
70 changed files with 10033 additions and 0 deletions
+49
View File
@@ -0,0 +1,49 @@
# ── Server identity ──────────────────────────────────────────
# 32-byte seed as 64 hex chars. Auto-generated into .env on first boot if missing.
# Treat SERVER_SEED like a root password (capabilities + admin proofs).
# SERVER_SEED=
# SERVER_PUBLIC_KEY=
# ── Roles ────────────────────────────────────────────────────
# Baseline role for unknown peers: viewer | operator | admin
PEARDATA_DEFAULT_ROLE=viewer
# Comma-separated peer public keys that always get admin
# PEARDATA_ADMIN_KEYS=
# DEV ONLY — every peer is admin (never enable in production)
# PEARDATA_INSECURE_OPEN_ADMIN=1
# Optional allowlist (empty = all non-revoked peers accepted)
# PEARDATA_ALLOWLIST=
# ── Runtime paths & limits ───────────────────────────────────
# Replaces OS home for client identity path:
# $PEARDATA_HOME/.config/peardata/identity.json
# PEARDATA_HOME=
# Peer policy + audit.log (default ./data)
# PEARDATA_DATA_DIR=./data
# Demo room ring buffer size
# PEARDATA_MAX_MESSAGES=500
# Per-peer RPC requests per minute
# PEARDATA_RATE_LIMIT_RPM=120
# Client ConnectionManager reconnect attempts
# PEARDATA_MAX_RECONNECT=20
# ── Logging ──────────────────────────────────────────────────
# LOG_LEVEL=info
# LOG_JSON=1
# ── Healthcheck / soak ───────────────────────────────────────
# Remote dial key (falls back to SERVER_PUBLIC_KEY)
# PEARDATA_HEALTH_KEY=
# HEALTHCHECK_TIMEOUT_MS=8000
# SOAK_DURATION_MS=60000
# SOAK_INTERVAL_MS=500
# ── Tests ────────────────────────────────────────────────────
# SKIP_INTEGRATION=1
+44
View File
@@ -0,0 +1,44 @@
name: CI
on:
push:
branches: [main, master]
pull_request:
workflow_dispatch:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
env:
NODE_OPTIONS: '--dns-result-order=ipv4first'
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- name: Use Node.js 22
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install dependencies
timeout-minutes: 10
env:
GIT_TERMINAL_PROMPT: '0'
npm_config_fetch_retries: '3'
npm_config_fetch_timeout: '120000'
run: |
set -euo pipefail
git config --global url."https://github.com/".insteadOf "ssh://[email protected]/"
git config --global url."https://github.com/".insteadOf "[email protected]:"
npm install --no-audit --no-fund --loglevel=info
- name: Test
run: npm test
- name: Healthcheck script loads
run: node scripts/healthcheck.js
+75
View File
@@ -0,0 +1,75 @@
# Rolling release for Gitea (mirrors peardock-style forge pipelines).
# Runs on every push to main/master — always rebuilds and republishes the `rolling` tag.
#
# Secrets:
# RELEASE_TOKEN — Gitea PAT with repo release write (required)
# GITEA_URL — optional forge base URL (defaults to origin / GITHUB_SERVER_URL)
name: Release rolling
on:
push:
branches: [main, master]
workflow_dispatch:
inputs:
dry_run:
description: 'Build artifacts without uploading'
required: false
default: 'false'
concurrency:
group: release-rolling
cancel-in-progress: true
env:
NODE_OPTIONS: '--dns-result-order=ipv4first'
jobs:
release:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- name: Use Node.js 22
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install + test
timeout-minutes: 15
env:
GIT_TERMINAL_PROMPT: '0'
npm_config_fetch_retries: '3'
npm_config_fetch_timeout: '120000'
run: |
set -euo pipefail
git config --global url."https://github.com/".insteadOf "ssh://[email protected]/"
git config --global url."https://github.com/".insteadOf "[email protected]:"
npm install --no-audit --no-fund
SKIP_INTEGRATION=1 npm test
- name: Build + publish rolling release
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
GITEA_URL: ${{ secrets.GITEA_URL }}
GITHUB_REPOSITORY: ${{ github.repository }}
GITHUB_SERVER_URL: ${{ github.server_url }}
GITHUB_SHA: ${{ github.sha }}
GITEA_SHA: ${{ github.sha }}
RELEASE_TAG: rolling
DRY_RUN: ${{ github.event.inputs.dry_run == 'true' && '1' || '0' }}
run: |
set -euo pipefail
if [ "${DRY_RUN:-0}" != "1" ] && [ -z "${RELEASE_TOKEN:-}" ]; then
echo "ERROR: secret RELEASE_TOKEN is not set"
exit 1
fi
if [ -n "${GITHUB_REPOSITORY:-}" ]; then
export GITEA_OWNER="${GITHUB_REPOSITORY%%/*}"
export GITEA_REPO="${GITHUB_REPOSITORY#*/}"
fi
if [ -z "${GITEA_URL:-}" ]; then
export GITEA_URL="${GITHUB_SERVER_URL:-}"
fi
chmod +x scripts/gitea-rolling-release.sh scripts/release.sh
bash scripts/gitea-rolling-release.sh
+78
View File
@@ -0,0 +1,78 @@
name: CI
on:
push:
branches: [main, master]
pull_request:
workflow_dispatch:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
env:
NODE_OPTIONS: '--dns-result-order=ipv4first'
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
matrix:
node: ['20', '22']
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- name: Install dependencies
run: |
set -euo pipefail
git config --global url."https://github.com/".insteadOf "ssh://[email protected]/"
git config --global url."https://github.com/".insteadOf "[email protected]:"
npm install --no-audit --no-fund
- name: Unit tests
run: npm test
env:
# Integration may hang without UDP; unit suite still runs
SKIP_INTEGRATION: '0'
- name: Syntax check entrypoints
run: |
node --check server/server.js
node --check client/connection.js
node --check app.js
node --check shared/crypto-auth.js
lint-docs:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- name: Required docs present
run: |
set -euo pipefail
for f in \
README.md \
LICENSE \
docs/ARCHITECTURE.md \
docs/PROTOCOL.md \
docs/GETTING-STARTED.md \
docs/SECURITY.md \
docs/DESKTOP.md \
docs/CONFIGURATION.md \
docs/TESTING.md \
docs/CI.md \
docs/RELEASE.md \
docs/EXTENDING.md \
.env.example
do
test -f "$f" || { echo "missing $f"; exit 1; }
done
# Desktop chrome must stay documented in the HTML shell
grep -q 'pear-ctrl' index.html
grep -q 'webkit-app-region: drag' ui/styles.css
+50
View File
@@ -0,0 +1,50 @@
name: Release
on:
push:
tags: ['v*']
workflow_dispatch:
inputs:
dry_run:
description: 'Build artifacts without uploading'
required: false
default: 'false'
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install + test
run: |
npm install --no-audit --no-fund
SKIP_INTEGRATION=1 npm test
- name: Pack source tarball
run: |
set -euo pipefail
VERSION="${GITHUB_REF_NAME:-manual}"
NAME="peardata-${VERSION}"
mkdir -p dist
tar --exclude=node_modules --exclude=.git --exclude=data --exclude=dist \
-czf "dist/${NAME}.tar.gz" .
(cd dist && sha256sum "${NAME}.tar.gz" > "${NAME}.tar.gz.sha256")
ls -la dist
- name: Upload GitHub Release
if: startsWith(github.ref, 'refs/tags/') && github.event.inputs.dry_run != 'true'
uses: softprops/action-gh-release@v2
with:
files: dist/*
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+18
View File
@@ -0,0 +1,18 @@
node_modules/
.env
.env.local
*.log
.DS_Store
dist/
out/
coverage/
.pear/
storage/
data/
*.seed
.cache/
tmp/
.idea/
.vscode/
*.tgz
package-lock.json.bak
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Pear App Template contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+154
View File
@@ -0,0 +1,154 @@
# peardata
**Production-oriented boilerplate for Holepunch / HyperDHT P2P apps.**
Distilled from patterns used in [peardock](https://github.com/snxraven/peardock)-class apps (MIT template — not a copy of peardocks AGPL sources):
| Plane | Stack |
|-------|--------|
| Transport | **HyperDHT** secret streams (Noise) |
| RPC | **protomux-rpc** + compact-encoding JSON |
| Identity | Ed25519 keypairs (server seed + client identity file) |
| AuthZ | Roles (`viewer` / `operator` / `admin`) + HMAC capabilities + admin seed proof |
| Desktop | **Pear** (`pear-electron` + `pear-bridge` + `<pear-ctrl>` titlebar) |
| Server | Node 20+ (systemd unit included) |
The demo product is **PearData**: a multi-peer room with messages, presence, and invite minting. Swap `server/handlers/demo.js` + `server/services/room.js` for your domain.
---
## Quick start
```bash
cd pear_app_template # or your clone path
npm install
# Terminal A — server (prints public key)
npm run start:server
# Terminal B — mint an operator invite (optional)
npm run mint-invite -- operator
# Terminal C — Pear desktop UI
npm start
# or: pear run -d .
```
In the UI, paste the **server public key** (viewer) or a **`pd1.` invite** (elevated role).
Paste `SERVER_SEED` from `.env` into “Admin seed” for full admin without an invite.
Drag the **titlebar** to move the window; resize from the edges (`pear.gui.resizable`).
```bash
npm test
npm run healthcheck
```
---
## Repository layout
```
pear_app_template/
├── app.js # Desktop UI logic
├── index.html / index.js # Pear shell (titlebar + pear-ctrl + drag)
├── ui/styles.css # Titlebar drag regions + layout
├── shared/ # Protocol, encodings, schema, crypto-auth
├── server/ # HyperDHT listener + RPC middleware + demo domain
│ ├── server.js
│ ├── core/ # keys, acl, audit, peer-policy, registry
│ ├── rpc/ # PeerSession + register
│ ├── handlers/ # Domain RPCs (replace demo.js)
│ ├── services/ # Domain state (replace room.js)
│ └── utils/
├── client/ # Connection, manager, identity
├── bin/ # Server binary entry
├── scripts/ # healthcheck, soak, mint-invite, rename, release
├── test/ # brittle unit + integration
├── docs/ # Full documentation set
├── deploy/ # systemd unit
├── .github/workflows/ # GitHub CI + release
└── .gitea/workflows/ # Gitea CI + rolling release skeleton
```
---
## npm scripts
| Script | Purpose |
|--------|---------|
| `npm start` / `npm run dev` | Pear desktop UI |
| `npm run start:server` | HyperDHT server |
| `npm test` | brittle unit + integration |
| `npm run mint-invite -- [role] [ttlMs]` | Offline `pd1.` invite |
| `npm run healthcheck` | Liveness / remote ping |
| `npm run soak` | Load exercise (needs env keys) |
| `npm run rename -- <slug> <Product>` | Rebrand the tree |
| `bash scripts/release.sh` | Source tarball + checksum |
Full env reference: [docs/CONFIGURATION.md](./docs/CONFIGURATION.md).
---
## Rebrand for a new app
```bash
npm run rename -- my-app MyApp
# → package name, protocol id, env prefixes, invite prefix, product strings
```
Then implement your domain:
1. Extend `shared/protocol.js` (`MethodRoles`, `Pushes`, `Methods`)
2. Validate args in `shared/schema.js`
3. Add handlers under `server/handlers/`
4. Register them in `server/rpc/register.js`
5. Call from `client/` + UI
6. Update `docs/PROTOCOL.md` and tests
See [docs/EXTENDING.md](./docs/EXTENDING.md).
---
## Auth model (secure defaults)
| Mode | How | Role |
|------|-----|------|
| Viewer | Dial public key only | `viewer` (read) |
| Capability | `pd1.` invite or raw HMAC token | grant role |
| Admin seed | HMAC proof from `SERVER_SEED` | `admin` |
| Allowlist | `PEARDATA_ADMIN_KEYS` | admin for listed peers |
| Dev escape | `PEARDATA_INSECURE_OPEN_ADMIN=1` | everyone admin |
See [docs/SECURITY.md](./docs/SECURITY.md).
---
## CI
- **GitHub**: `.github/workflows/ci.yml` (Node 20/22 matrix + docs presence), `release.yml` on `v*` tags
- **Gitea**: `.gitea/workflows/ci.yml`, `release-rolling.yml` (every push to `main``rolling` release; needs `RELEASE_TOKEN`)
---
## Documentation
| Doc | Contents |
|-----|----------|
| [Getting started](./docs/GETTING-STARTED.md) | Install, run, connect, systemd, troubleshooting |
| [Desktop](./docs/DESKTOP.md) | Pear shell, `pear-ctrl`, drag/resize, identity |
| [Architecture](./docs/ARCHITECTURE.md) | Planes, boot, session pipeline, module map |
| [Protocol](./docs/PROTOCOL.md) | Methods, pushes, errors, versioning |
| [Security](./docs/SECURITY.md) | Threat model, secrets, hardening checklist |
| [Configuration](./docs/CONFIGURATION.md) | Full environment + scripts reference |
| [Testing](./docs/TESTING.md) | brittle suite, soak, manual checks |
| [CI](./docs/CI.md) | Pipelines and required docs |
| [Release](./docs/RELEASE.md) | Version, tag, tarball, rollback |
| [Extending](./docs/EXTENDING.md) | Grow past the demo room |
---
## License
MIT — use this as a starting point for proprietary or open apps.
(Peardock itself is AGPL; this template does **not** copy peardock source verbatim and is intentionally MIT.)
+304
View File
@@ -0,0 +1,304 @@
/**
* PearData desktop — multi-peer fleet overview + live charts.
*/
import { manager } from './client/manager.js'
import { Methods, Pushes } from './shared/protocol.js'
import { getClientIdentity } from './client/identity.js'
const $ = (id) => document.getElementById(id)
const els = {
connectInput: $('connect-input'),
adminSeed: $('admin-seed'),
btnConnect: $('btn-connect'),
btnDisconnect: $('btn-disconnect'),
btnInvite: $('btn-invite'),
peerList: $('peer-list'),
serverInfo: $('server-info'),
log: $('log'),
status: $('status-chip'),
connMeta: $('conn-meta'),
roleBadge: $('role-badge'),
inviteOut: $('invite-out'),
anomalyList: $('anomaly-list'),
statCpu: $('stat-cpu'),
statRam: $('stat-ram'),
statLoad: $('stat-load'),
statNet: $('stat-net'),
statHealth: $('stat-health'),
}
/** @type {Record<string, number[]>} */
const series = {
cpu: [],
ram: [],
net: [],
io: [],
}
const SERIES_MAX = 60
function log(line) {
const ts = new Date().toLocaleTimeString()
els.log.textContent = `[${ts}] ${line}\n` + els.log.textContent
}
function setOnline(online) {
els.status.textContent = online ? 'live' : 'offline'
els.status.classList.toggle('online', online)
els.status.classList.toggle('offline', !online)
els.btnDisconnect.disabled = !online
els.btnInvite.disabled = !online
els.btnConnect.disabled = online
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function pushPoint(key, value) {
const arr = series[key]
arr.push(Number(value) || 0)
while (arr.length > SERIES_MAX) arr.shift()
}
function drawChart(canvasId, values, color = '#5b8cff') {
const canvas = $(canvasId)
if (!canvas) return
const ctx = canvas.getContext('2d')
const dpr = window.devicePixelRatio || 1
const w = canvas.clientWidth || 320
const h = canvas.height
canvas.width = w * dpr
canvas.height = h * dpr
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, w, h)
ctx.strokeStyle = 'rgba(139,155,184,0.15)'
ctx.lineWidth = 1
for (let i = 1; i < 4; i++) {
const y = (h / 4) * i
ctx.beginPath()
ctx.moveTo(0, y)
ctx.lineTo(w, y)
ctx.stroke()
}
if (values.length < 2) return
const max = Math.max(...values, 1)
const min = Math.min(...values, 0)
const span = max - min || 1
ctx.strokeStyle = color
ctx.lineWidth = 2
ctx.beginPath()
values.forEach((v, i) => {
const x = (i / (SERIES_MAX - 1)) * w
const y = h - ((v - min) / span) * (h - 8) - 4
if (i === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y)
})
ctx.stroke()
// fill
ctx.lineTo(w, h)
ctx.lineTo(0, h)
ctx.closePath()
ctx.fillStyle = color + '22'
ctx.fill()
}
function renderPeers() {
const list = manager.list?.() || []
els.peerList.innerHTML = ''
if (!list.length) {
const li = document.createElement('li')
li.className = 'muted'
li.textContent = 'No agents connected'
els.peerList.appendChild(li)
return
}
for (const p of list) {
const li = document.createElement('li')
const active = manager.activeId === p.id
li.className = active ? 'active' : ''
li.innerHTML = `<span>${escapeHtml(p.id)}</span><span class="muted">${p.connected ? 'live' : '…'}</span>`
li.addEventListener('click', () => {
manager.setActive?.(p.id)
renderPeers()
})
els.peerList.appendChild(li)
}
}
function onSamples(samples) {
for (const s of samples || []) {
if (s.chart === 'system.cpu') {
const used = 100 - (s.values.idle ?? 100)
pushPoint('cpu', used)
els.statCpu.textContent = `${used.toFixed(1)}%`
}
if (s.chart === 'system.ram') {
const used = s.values.used ?? 0
pushPoint('ram', used)
els.statRam.textContent = `${used.toFixed(0)} MiB`
}
if (s.chart === 'system.load') {
els.statLoad.textContent = (s.values.load1 ?? 0).toFixed(2)
}
if (s.chart === 'system.net') {
const rx = s.values.received ?? 0
pushPoint('net', rx)
els.statNet.textContent = `${rx.toFixed(1)} kb/s`
}
if (s.chart === 'system.io') {
pushPoint('io', (s.values.reads || 0) + (s.values.writes || 0))
}
}
drawChart('chart-cpu', series.cpu, '#5b8cff')
drawChart('chart-ram', series.ram, '#3dd6c6')
drawChart('chart-net', series.net, '#f0b429')
drawChart('chart-io', series.io, '#ff6b7a')
}
function prependAnomaly(ev) {
const li = document.createElement('li')
li.className = ev.severity === 'critical' ? 'crit' : ev.cleared ? 'ok' : 'warn'
li.innerHTML = `<strong>${escapeHtml(ev.severity || 'event')}</strong> ${escapeHtml(ev.message || '')}
<span class="muted">${new Date(ev.ts || Date.now()).toLocaleTimeString()}</span>`
els.anomalyList.prepend(li)
while (els.anomalyList.children.length > 40) els.anomalyList.lastChild.remove()
}
async function refreshMeta() {
const [info, auth, health, node] = await Promise.all([
manager.request(Methods.getServerInfo, {}),
manager.request(Methods.getAuthStatus, {}),
manager.request(Methods.getHealth, {}),
manager.request(Methods.getNodeInfo, {}),
])
els.serverInfo.textContent = JSON.stringify({ info, node, health }, null, 2)
els.roleBadge.textContent = auth.role || '—'
els.statHealth.textContent = health.status || '—'
els.statHealth.parentElement.dataset.health = health.status || ''
const id = getClientIdentity()
els.connMeta.textContent = `you ${id.publicKeyHex.slice(0, 12)}… · ${auth.role} · ${auth.authMode}`
renderPeers()
}
function parseConnectInput(raw) {
const input = raw.trim()
if (input.startsWith('pd1.')) {
const inv = decodeInvite(input)
return {
publicKeyHex: inv.publicKeyHex,
capability: inv.capability,
}
}
return { publicKeyHex: input.toLowerCase(), capability: null }
}
els.btnConnect.addEventListener('click', async () => {
const raw = els.connectInput.value.trim()
const adminSeed = els.adminSeed.value.trim() || null
if (!raw) {
log('Enter a public key or pd1 invite')
return
}
els.btnConnect.disabled = true
try {
const { publicKeyHex, capability } = parseConnectInput(raw)
log(`Dialing ${publicKeyHex.slice(0, 16)}`)
await manager.connect(publicKeyHex, { capability, adminSeed })
setOnline(true)
await manager.request(Methods.subscribeMetrics, { charts: ['*'], intervalMs: 1000 })
await manager.request(Methods.subscribeAnomalies, {})
await refreshMeta()
// seed charts from history
for (const chart of ['system.cpu', 'system.ram', 'system.net', 'system.io']) {
try {
const q = await manager.request(Methods.queryData, { chart, after: -60, points: 60 })
const dimIdx = chart === 'system.cpu' ? q.labels.indexOf('user') : 1
for (const row of q.data || []) {
if (chart === 'system.cpu') {
const idleIdx = q.labels.indexOf('idle')
const idle = idleIdx >= 0 ? row[idleIdx] : 100
pushPoint('cpu', 100 - (idle ?? 100))
} else if (dimIdx >= 0) {
const key = chart === 'system.ram' ? 'ram' : chart === 'system.net' ? 'net' : 'io'
pushPoint(key, row[dimIdx] ?? 0)
}
}
} catch {
// ignore
}
}
drawChart('chart-cpu', series.cpu, '#5b8cff')
drawChart('chart-ram', series.ram, '#3dd6c6')
drawChart('chart-net', series.net, '#f0b429')
drawChart('chart-io', series.io, '#ff6b7a')
log('Subscribed to live metrics')
} catch (err) {
log(`Connect failed: ${err.message}`)
setOnline(false)
els.btnConnect.disabled = false
}
})
els.btnDisconnect.addEventListener('click', async () => {
await manager.disconnectAll?.()
setOnline(false)
renderPeers()
log('Disconnected')
})
els.btnInvite.addEventListener('click', async () => {
try {
const res = await manager.request(Methods.mintInvite, { role: 'operator' })
els.inviteOut.classList.remove('hidden')
els.inviteOut.textContent = res.invite
log('Invite minted')
} catch (err) {
log(`Invite failed: ${err.message}`)
}
})
manager.on?.('push', (ev) => {
if (ev.type === Pushes.metrics) onSamples(ev.data?.samples)
if (ev.type === Pushes.anomaly) {
prependAnomaly(ev.data)
log(`Anomaly: ${ev.data?.message || ''}`)
}
if (ev.type === Pushes.health) {
els.statHealth.textContent = ev.data?.status || '—'
}
})
// Compatibility if manager emits per-connection
manager.on?.('connection', (conn) => {
conn.on?.(Pushes.metrics, (data) => onSamples(data?.samples))
conn.on?.(Pushes.anomaly, (data) => prependAnomaly(data))
conn.on?.(Pushes.health, (data) => {
els.statHealth.textContent = data?.status || '—'
})
conn.on?.('disconnected', () => {
setOnline(false)
log('Agent disconnected')
})
})
setOnline(false)
renderPeers()
log('PearData ready — connect an agent public key')
// redraw on resize
window.addEventListener('resize', () => {
drawChart('chart-cpu', series.cpu, '#5b8cff')
drawChart('chart-ram', series.ram, '#3dd6c6')
drawChart('chart-net', series.net, '#f0b429')
drawChart('chart-io', series.io, '#ff6b7a')
})
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env node
/**
* Server binary entry (symlink-friendly).
*/
import '../server/server.js'
+252
View File
@@ -0,0 +1,252 @@
/**
* Single server connection via HyperDHT + protomux-rpc.
*
* Auth modes:
* - viewer: public key only
* - seed: adminProof HMAC from SERVER_SEED
* - capability: HMAC grant (from pa1 invite or direct)
*/
import DHT from 'hyperdht'
import ProtomuxRPC from 'protomux-rpc'
import b4a from 'b4a'
import { EventEmitter } from 'events'
import { PROTOCOL, Pushes, Methods, APP_NAME, APP_VERSION } from '../shared/protocol.js'
import { encodings } from '../shared/encodings.js'
import { normalizeRpcError, unwrapError } from './errors.js'
import { getClientIdentity } from './identity.js'
import { createAdminProof } from '../shared/crypto-auth.js'
/**
* @typedef {object} ConnectionOptions
* @property {number} [timeoutMs=30000]
* @property {string|null} [capability]
* @property {string|null} [adminSeed]
*/
export class PearDataConnection extends EventEmitter {
/**
* @param {string} publicKeyHex
* @param {ConnectionOptions} [opts]
*/
constructor(publicKeyHex, opts = {}) {
super()
if (!/^[0-9a-fA-F]{64}$/.test(publicKeyHex)) {
throw new Error('Server public key must be 64 hex characters')
}
this.publicKeyHex = publicKeyHex.toLowerCase()
this.publicKey = b4a.from(this.publicKeyHex, 'hex')
this.id = this.publicKeyHex.slice(0, 12)
this.timeoutMs = opts.timeoutMs ?? 30000
this.capability = opts.capability || null
this.adminSeed = opts.adminSeed || null
this.dht = null
this.socket = null
this.rpc = null
this.connected = false
this.connectedAt = null
this.latency = null
/** @type {'idle'|'dialing'|'handshaking'|'ready'|'closed'} */
this.state = 'idle'
this.role = null
this.authMode = null
this.protocolVersion = null
this.clientPublicKeyHex = null
}
async connect() {
if (this.connected) return this
this.state = 'dialing'
try {
const identity = getClientIdentity()
this.clientPublicKeyHex = identity.publicKeyHex
this.dht = new DHT({ keyPair: identity.keyPair })
this.socket = this.dht.connect(this.publicKey)
await new Promise((resolve, reject) => {
let settled = false
const timer = setTimeout(() => {
if (!settled) {
settled = true
cleanup()
const err = new Error(`Connection timeout after ${this.timeoutMs}ms`)
err.code = 'CONNECTION_TIMEOUT'
reject(err)
}
}, this.timeoutMs)
const onOpen = () => {
if (settled) return
settled = true
clearTimeout(timer)
cleanup()
resolve()
}
const onError = (err) => {
if (settled) return
settled = true
clearTimeout(timer)
cleanup()
reject(err)
}
const onClose = () => {
if (settled) return
settled = true
clearTimeout(timer)
cleanup()
reject(new Error('Connection closed before open'))
}
const cleanup = () => {
this.socket?.off?.('open', onOpen)
this.socket?.off?.('connect', onOpen)
this.socket?.off?.('error', onError)
this.socket?.off?.('close', onClose)
}
this.socket.once('open', onOpen)
this.socket.once('connect', onOpen)
this.socket.once('error', onError)
this.socket.once('close', onClose)
if (this.socket.publicKey && this.socket.rawStream) onOpen()
})
this.rpc = new ProtomuxRPC(this.socket, {
id: this.publicKey,
protocol: PROTOCOL,
...encodings,
})
await this.rpc.fullyOpened?.().catch(() => {})
this._registerPushHandlers()
this.socket.on('close', () => this._onDisconnect())
this.socket.on('error', (err) => {
this.emit('error', err)
if (this.connected) this._onDisconnect()
})
this.rpc.on('close', () => this._onDisconnect())
this.state = 'handshaking'
const hsArgs = {
clientName: APP_NAME,
clientVersion: APP_VERSION,
}
if (this.capability) hsArgs.capability = this.capability
if (this.adminSeed) {
hsArgs.adminProof = createAdminProof(this.adminSeed, {
peerId: this.clientPublicKeyHex,
serverPublicKeyHex: this.publicKeyHex,
})
}
let hs
try {
hs = await this.request(Methods.handshake, hsArgs)
} catch (err) {
const root = unwrapError(err)
const code = root?.code || err?.code
const msg = String(root?.message || err?.message || '')
const softSpent =
this.capability &&
(code === 'CAPABILITY_SPENT' ||
code === 'CAPABILITY_EXPIRED' ||
/already used or revoked|Capability expired/i.test(msg))
if (softSpent) {
this.capability = null
const retryArgs = {
clientName: APP_NAME,
clientVersion: APP_VERSION,
}
if (this.adminSeed) {
retryArgs.adminProof = createAdminProof(this.adminSeed, {
peerId: this.clientPublicKeyHex,
serverPublicKeyHex: this.publicKeyHex,
})
}
hs = await this.request(Methods.handshake, retryArgs)
} else {
throw err
}
}
this.role = hs?.role || null
this.authMode = hs?.auth?.mode || null
this.protocolVersion = hs?.protocolVersion ?? null
this.connected = true
this.connectedAt = Date.now()
this.state = 'ready'
this.emit('connected', hs)
return this
} catch (err) {
this.state = 'closed'
await this.destroy().catch(() => {})
throw normalizeRpcError(err, 'connect')
}
}
/**
* @param {string} method
* @param {object} [args]
*/
async request(method, args = {}) {
if (!this.rpc) {
const err = new Error('Not connected')
err.code = 'NOT_CONNECTED'
throw err
}
try {
const t0 = Date.now()
const res = await this.rpc.request(method, args, encodings)
this.latency = Date.now() - t0
return res
} catch (err) {
throw normalizeRpcError(err, method)
}
}
async ping() {
return this.request(Methods.ping, {})
}
_registerPushHandlers() {
for (const push of Object.values(Pushes)) {
this.rpc.on(push, (data) => {
this.emit('push', { type: push, data })
this.emit(push, data)
})
}
}
_onDisconnect() {
if (!this.connected && this.state === 'closed') return
const was = this.connected
this.connected = false
this.state = 'closed'
if (was) this.emit('disconnected')
}
async destroy() {
this.connected = false
this.state = 'closed'
try {
this.rpc?.destroy?.()
} catch {
// ignore
}
try {
this.socket?.destroy?.()
} catch {
// ignore
}
try {
await this.dht?.destroy?.()
} catch {
// ignore
}
this.rpc = null
this.socket = null
this.dht = null
}
}
+30
View File
@@ -0,0 +1,30 @@
/**
* Normalize protomux-rpc / DHT errors for UI.
*/
/**
* @param {unknown} err
* @returns {Error}
*/
export function unwrapError(err) {
let cur = err
let depth = 0
while (cur?.cause && depth < 5) {
cur = cur.cause
depth++
}
return cur instanceof Error ? cur : new Error(String(cur?.message || cur || 'Unknown error'))
}
/**
* @param {unknown} err
* @param {string} [method]
*/
export function normalizeRpcError(err, method) {
const root = unwrapError(err)
const e = new Error(root.message || String(err))
e.code = root.code || err?.code || 'RPC_ERROR'
e.method = method || root.method || null
e.cause = err
return e
}
+78
View File
@@ -0,0 +1,78 @@
/**
* Persistent client DHT identity for stable peerId across reconnects.
* Stored at ~/.config/peardata/identity.json (mode 0600).
*/
import fs from 'fs'
import path from 'path'
import os from 'os'
import crypto from 'crypto'
import DHT from 'hyperdht'
import b4a from 'b4a'
const IDENTITY_VERSION = 1
export function getIdentityPath() {
const home =
process.env.PEARDATA_HOME ||
process.env.HOME ||
process.env.USERPROFILE ||
(typeof os.homedir === 'function' ? os.homedir() : '') ||
''
return path.join(home, '.config', 'peardata', 'identity.json')
}
export function loadOrCreateClientIdentity() {
const filePath = getIdentityPath()
let seedHex = null
try {
if (fs.existsSync(filePath)) {
const raw = JSON.parse(fs.readFileSync(filePath, 'utf8'))
if (raw?.seedHex && /^[0-9a-fA-F]{64}$/.test(raw.seedHex)) {
seedHex = String(raw.seedHex).toLowerCase()
}
}
} catch {
// regenerate
}
if (!seedHex) {
seedHex = crypto.randomBytes(32).toString('hex')
try {
const dir = path.dirname(filePath)
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
fs.writeFileSync(
filePath,
JSON.stringify(
{
version: IDENTITY_VERSION,
seedHex,
createdAt: new Date().toISOString(),
},
null,
2
),
{ mode: 0o600 }
)
try {
fs.chmodSync(filePath, 0o600)
} catch {
// ignore
}
} catch {
// In-memory only if FS unavailable
}
}
const seed = b4a.from(seedHex, 'hex')
const keyPair = DHT.keyPair(seed)
const publicKeyHex = b4a.toString(keyPair.publicKey, 'hex')
return { seed, keyPair, publicKeyHex, seedHex }
}
let cached = null
export function getClientIdentity() {
if (!cached) cached = loadOrCreateClientIdentity()
return cached
}
+7
View File
@@ -0,0 +1,7 @@
/**
* Client barrel export.
*/
export { PearDataConnection } from './connection.js'
export { ConnectionManager, manager } from './manager.js'
export { getClientIdentity, getIdentityPath } from './identity.js'
export { normalizeRpcError, unwrapError } from './errors.js'
+139
View File
@@ -0,0 +1,139 @@
/**
* Multi-connection manager with reconnect + active selection.
*/
import { EventEmitter } from 'events'
import { PearDataConnection } from './connection.js'
import { classifyConnectionInput } from '../shared/crypto-auth.js'
export class ConnectionManager extends EventEmitter {
constructor() {
super()
/** @type {Map<string, PearDataConnection>} */
this.connections = new Map()
/** @type {PearDataConnection|null} */
this.active = null
/** @type {Map<string, ReturnType<typeof setTimeout>>} */
this._reconnectTimers = new Map()
this.maxReconnectTries = Number(process.env.PEARDATA_MAX_RECONNECT) || 20
/** @type {Map<string, number>} */
this._tries = new Map()
}
/**
* @param {string} input - public key, pa1 invite, or capability+key object fields
* @param {{ adminSeed?: string, alias?: string, autoReconnect?: boolean }} [opts]
*/
async connect(input, opts = {}) {
const parsed = typeof input === 'string' ? classifyConnectionInput(input) : input
let publicKeyHex
let capability = opts.capability || null
if (parsed.kind === 'invite') {
publicKeyHex = parsed.publicKeyHex
capability = parsed.capability
} else if (parsed.kind === 'publicKey') {
publicKeyHex = parsed.publicKeyHex
} else if (parsed.publicKeyHex) {
publicKeyHex = parsed.publicKeyHex
capability = parsed.capability || capability
} else {
throw new Error(parsed.error || 'Invalid connection input')
}
publicKeyHex = String(publicKeyHex).toLowerCase()
await this.disconnect(publicKeyHex)
const conn = new PearDataConnection(publicKeyHex, {
capability,
adminSeed: opts.adminSeed || null,
})
conn.on('disconnected', () => {
this.emit('disconnected', conn)
if (opts.autoReconnect !== false) this._scheduleReconnect(publicKeyHex, opts)
})
conn.on('push', (ev) => this.emit('push', ev, conn))
conn.on('error', (err) => this.emit('error', err, conn))
await conn.connect()
this.connections.set(publicKeyHex, conn)
this._tries.set(publicKeyHex, 0)
this.setActive(publicKeyHex)
this.emit('connected', conn)
return conn
}
/**
* @param {string} publicKeyHex
*/
setActive(publicKeyHex) {
const conn = this.connections.get(String(publicKeyHex).toLowerCase())
if (!conn) return false
this.active = conn
this.emit('active', conn)
return true
}
/**
* @param {string} method
* @param {object} [args]
*/
async request(method, args) {
if (!this.active?.connected) {
const err = new Error('No active connection')
err.code = 'NOT_CONNECTED'
throw err
}
return this.active.request(method, args)
}
list() {
return [...this.connections.values()]
}
/**
* @param {string} [publicKeyHex]
*/
async disconnect(publicKeyHex) {
if (!publicKeyHex) {
for (const id of [...this.connections.keys()]) await this.disconnect(id)
return
}
const id = String(publicKeyHex).toLowerCase()
const timer = this._reconnectTimers.get(id)
if (timer) {
clearTimeout(timer)
this._reconnectTimers.delete(id)
}
const conn = this.connections.get(id)
if (conn) {
this.connections.delete(id)
if (this.active === conn) this.active = null
await conn.destroy()
}
}
_scheduleReconnect(publicKeyHex, opts) {
const id = String(publicKeyHex).toLowerCase()
if (this._reconnectTimers.has(id)) return
const tries = (this._tries.get(id) || 0) + 1
this._tries.set(id, tries)
if (tries > this.maxReconnectTries) {
this.emit('reconnect-exhausted', { publicKeyHex: id, tries })
return
}
const delay = Math.min(30_000, 1000 * 2 ** Math.min(tries, 5))
const timer = setTimeout(async () => {
this._reconnectTimers.delete(id)
try {
await this.connect(id, { ...opts, autoReconnect: true })
} catch (err) {
this.emit('reconnect-failed', { publicKeyHex: id, err, tries })
this._scheduleReconnect(id, opts)
}
}, delay)
this._reconnectTimers.set(id, timer)
}
}
export const manager = new ConnectionManager()
+22
View File
@@ -0,0 +1,22 @@
[Unit]
Description=Pear App P2P server (HyperDHT + protomux-rpc)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=/opt/peardata
EnvironmentFile=-/opt/peardata/.env
ExecStart=/usr/bin/node /opt/peardata/server/server.js
ExecStartPost=/usr/bin/node /opt/peardata/scripts/healthcheck.js
Restart=on-failure
RestartSec=3
# Hardening
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/peardata/data /opt/peardata/.env
[Install]
WantedBy=multi-user.target
+193
View File
@@ -0,0 +1,193 @@
# Architecture
## Design goals
1. **No central control plane** — peers dial a public key, not a SaaS tenant.
2. **Cryptographic identity** — HyperDHT Noise streams authenticate both ends.
3. **Clear AuthZ** — roles, rate limits, audit, optional allowlist / revoke.
4. **Shared wire contract**`shared/*` is the single source of truth for client + server.
5. **Replaceable domain** — demo room is a thin layer over the session stack.
6. **Pear-native desktop**`pear-electron` shell with `<pear-ctrl>` window chrome.
## System context
```mermaid
flowchart LR
UI[Pear desktop / scripts] -->|HyperDHT Noise| SRV[Node server]
SRV --> STATE[Room / your domain]
UI -.->|bootstrap / punch| NET[HyperDHT network]
SRV -.-> NET
```
## Layered stack
```mermaid
flowchart TB
subgraph Presentation
HTML[index.html + app.js + ui/styles.css]
PEAR[index.js pear-electron + bridge]
end
subgraph ClientCore
MGR[client/manager.js]
CON[client/connection.js]
ID[client/identity.js]
end
subgraph Wire
PROT[shared/protocol.js]
ENC[shared/encodings.js]
AUTH[shared/crypto-auth.js]
SCH[shared/schema.js]
end
subgraph ServerCore
BOOT[server/server.js]
SESS[server/rpc/session.js]
ACL[server/core/acl.js]
HAND[server/handlers/*]
SVC[server/services/*]
end
PEAR --> HTML
HTML --> MGR --> CON
CON --> PROT
CON --> AUTH
SESS --> PROT
SESS --> ACL
SESS --> SCH
HAND --> SVC
BOOT --> SESS
CON <-->|secret stream| SESS
```
## Process model
| Process | Entry | Responsibility |
|---------|-------|----------------|
| **Server** | `server/server.js` or `bin/peardata-server.mjs` | HyperDHT listen, RPC, domain state |
| **Desktop** | `index.js` → Pear Runtime | Window + HTML UI; dials servers as a client |
| **Scripts** | `scripts/*` | mint-invite, healthcheck, soak (use client stack) |
Server and desktop are **independent**. You can run many clients against one server, or headless scripts with no UI.
## Server boot
1. `loadOrCreateKeyPair()` → persist `SERVER_SEED` / `SERVER_PUBLIC_KEY` in `.env`
2. `initAuthKeys()` → HKDF MAC key for capabilities
3. `loadPeerPolicy()` → roles / revocations / spent JTIs from `PEARDATA_DATA_DIR`
4. `dht.createServer().listen(keyPair)`
5. On connection → revoke check → `PeerSession``registerAllHandlers` → peer registry
6. Banner logs public key + secure/insecure mode
7. `graceful-goodbye` / SIGINT / SIGTERM drain peers and destroy DHT
## Session middleware (every non-hot RPC)
```mermaid
flowchart TD
IN[method + args] --> RL{Rate limit}
RL -->|deny| E1[RATE_LIMIT_EXCEEDED]
RL -->|ok| ACL{roleAllows MethodRoles}
ACL -->|deny| E2[PERMISSION_DENIED + audit]
ACL -->|ok| VAL{validateMethodArgs}
VAL -->|fail| E3[INVALID_ARGS]
VAL -->|ok| H[Handler]
H --> OK[Result + optional audit]
```
**Hot path** (`session.respond(method, handler, { hot: true })` or stream method names):
- Still rate-limited and ACL-checked
- Skips full schema validation / success audit (for high-frequency streams)
## Client connection states
```
idle → dialing → handshaking → ready
↘ closed → (manager reconnect timer)
```
| State | Meaning |
|-------|---------|
| `idle` | Constructed, not dialing |
| `dialing` | `dht.connect(serverPk)` in flight |
| `handshaking` | Stream open; `handshake` RPC |
| `ready` | Authenticated; RPCs allowed |
| `closed` | Torn down |
`ConnectionManager` tracks multiple peers, active selection, and reconnect (max `PEARDATA_MAX_RECONNECT`).
## Identity planes
| Plane | Storage | Purpose |
|-------|---------|---------|
| **Server keypair** | `.env` (`SERVER_SEED`) | DHT listen address + HMAC root |
| **Client keypair** | `~/.config/peardata/identity.json` | Stable peerId for AuthZ / revoke |
| **Capabilities** | Issued as tokens / `pd1.` invites | Role grants with optional expiry & peer bind |
| **Peer policy** | `data/peer-policy.json` | Registered roles, revocations, spent JTIs |
| **Audit** | `data/audit.log` | Mutating RPC trail |
## Module map
### `shared/`
| File | Role |
|------|------|
| `protocol.js` | `PROTOCOL`, roles, `MethodRoles`, `Methods`, `Pushes` |
| `encodings.js` | compact-encoding JSON for protomux-rpc |
| `crypto-auth.js` | MAC key, capabilities, admin proof, invites |
| `schema.js` | Lightweight request validation |
### `server/`
| Path | Role |
|------|------|
| `server.js` | Boot + DHT accept loop |
| `core/keys.js` | Seed load / generate |
| `core/auth-keys.js` | Process-wide MAC key |
| `core/acl.js` | Role resolution + assert |
| `core/peer-policy.js` | File-backed policy |
| `core/peer-registry.js` | Live sessions |
| `core/audit.js` | Audit log writer |
| `rpc/session.js` | ProtomuxRPC + middleware |
| `rpc/register.js` | Wire handlers per session |
| `handlers/demo.js` | **Replace** — domain RPCs |
| `services/room.js` | **Replace** — domain state |
| `utils/logger.js` | Structured / pretty logs |
| `utils/rateLimiter.js` | Per-peer RPM |
### `client/`
| File | Role |
|------|------|
| `identity.js` | Persistent client seed |
| `connection.js` | Single peer RPC client |
| `manager.js` | Multi-peer + reconnect |
| `errors.js` | Error normalization |
| `index.js` | Public re-exports |
### Desktop shell
| File | Role |
|------|------|
| `index.js` | Pear Runtime + Bridge |
| `index.html` | Titlebar (`pear-ctrl`) + layout |
| `app.js` | UI → manager |
| `ui/styles.css` | Drag regions + theme |
See [DESKTOP.md](./DESKTOP.md).
## What to keep vs replace
| Keep | Replace when productizing |
|------|---------------------------|
| `shared/*` wire + crypto | Method names / schema for your domain |
| `server/rpc/session.js` | Rarely — middleware is generic |
| `server/core/*` | Peer policy storage backend if needed |
| `client/connection.js` + `manager.js` | UI-specific multi-peer UX |
| Titlebar / `pear-ctrl` patterns | Visual design only — keep drag + controls |
| `server/handlers/demo.js` + `services/room.js` | **Your product** |
## Related docs
- [PROTOCOL.md](./PROTOCOL.md)
- [SECURITY.md](./SECURITY.md)
- [DESKTOP.md](./DESKTOP.md)
- [CONFIGURATION.md](./CONFIGURATION.md)
- [EXTENDING.md](./EXTENDING.md)
+81
View File
@@ -0,0 +1,81 @@
# CI & pipelines
## GitHub Actions
| Workflow | Trigger | Jobs |
|----------|---------|------|
| `.github/workflows/ci.yml` | push / PR / manual | **test** (Node 20 + 22 matrix), **lint-docs** |
| `.github/workflows/release.yml` | `v*` tags / manual | install, test (`SKIP_INTEGRATION=1`), pack tarball + sha256, GitHub Release |
### CI job details (`ci.yml`)
**test**
- `actions/checkout@v4` + `setup-node` matrix `20` / `22`
- HTTPS rewrite for GitHub git deps
- `npm install --no-audit --no-fund`
- `npm test` (integration enabled by default)
- `node --check` on key entrypoints: `server/server.js`, `client/connection.js`, `app.js`, `shared/crypto-auth.js`
- `NODE_OPTIONS=--dns-result-order=ipv4first`
**lint-docs**
- Asserts required documentation files exist (README, architecture, protocol, getting started, security, desktop, configuration, LICENSE, etc.)
### Release job details (`release.yml`)
- Node 22
- `SKIP_INTEGRATION=1 npm test` (avoids flaky UDP on some runners)
- Source tarball under `dist/`
- Upload via `softprops/action-gh-release` when ref is a tag
## Gitea Actions
| Workflow | Trigger | Job |
|----------|---------|-----|
| `.gitea/workflows/ci.yml` | push / PR / manual | install, `npm test`, `node scripts/healthcheck.js` (liveness) |
| `.gitea/workflows/release-rolling.yml` | **every** push to `main`/`master` + manual | test, `scripts/gitea-rolling-release.sh` → Gitea `rolling` prerelease |
Mirrors patterns from peardock-class forge pipelines (IPv4-first DNS, HTTPS rewrite for GitHub deps, always-on rolling release).
## Local parity
```bash
npm install
npm test
node --check server/server.js
node --check client/connection.js
node --check app.js
node --check shared/crypto-auth.js
bash scripts/release.sh
```
## Integration tests in CI
Integration spins a real HyperDHT listener. If a runner blocks UDP/DHT:
```yaml
env:
SKIP_INTEGRATION: '1'
```
Unit tests (`crypto-auth`, `protocol`, `acl`) always run. See [TESTING.md](./TESTING.md).
## Secrets (forge)
| Secret | Used by |
|--------|---------|
| `GITHUB_TOKEN` | GitHub Release (automatic) |
| `RELEASE_TOKEN` | Gitea rolling release (**required** for publish) |
| `GITEA_URL` | Forge base URL (optional; defaults to runner `GITHUB_SERVER_URL`) |
Do not store `SERVER_SEED` in CI unless a dedicated deploy workflow needs it.
## Required docs check
When adding documentation, update the `lint-docs` step in `.github/workflows/ci.yml` so the file is asserted present.
## Related
- [RELEASE.md](./RELEASE.md)
- [TESTING.md](./TESTING.md)
+146
View File
@@ -0,0 +1,146 @@
# Configuration reference
All knobs can be set via environment variables. The server loads `.env` through `dotenv` on boot (`server/core/keys.js`). Copy `.env.example` to get started.
```bash
cp .env.example .env
```
---
## Server identity
| Variable | Default | Description |
|----------|---------|-------------|
| `SERVER_SEED` | *auto-generated* | 32-byte secret as **64 hex** chars. Root of HMAC capabilities + admin proofs. **Keep offline / mode 0600.** |
| `SERVER_PUBLIC_KEY` | *derived* | 32-byte public key as **64 hex**. Clients dial this. Auto-synced to `.env` when seed loads. |
| `SERVER_KEY` | — | Alias accepted for `SERVER_SEED` (legacy). Prefer `SERVER_SEED`. |
On first boot without a seed, the server appends both values to `.env`.
Treat `SERVER_SEED` like a root password. Prefer `pd1.` invites for operators.
---
## Roles & access control
| Variable | Default | Description |
|----------|---------|-------------|
| `PEARDATA_DEFAULT_ROLE` | `viewer` | Baseline role for unknown peers: `viewer` \| `operator` \| `admin` |
| `PEARDATA_ADMIN_KEYS` | empty | Comma-separated peer public keys always elevated to **admin** |
| `PEARDATA_ALLOWLIST` | empty | If **non-empty**, only listed peer pubs (plus already-registered policy peers) may connect |
| `PEARDATA_INSECURE_OPEN_ADMIN` | off | `1` / `true` / `yes` → every peer is admin. **Dev only.** |
---
## Runtime paths & limits
| Variable | Default | Description |
|----------|---------|-------------|
| `PEARDATA_DATA_DIR` | `./data` | Peer policy JSON + `audit.log` |
| `PEARDATA_HOME` | OS home | Root for client identity path construction (`client/identity.js`) |
| `PEARDATA_MAX_MESSAGES` | `500` | In-memory demo room ring buffer size |
| `PEARDATA_RATE_LIMIT_RPM` | `120` | Per-peer RPC requests per minute |
| `PEARDATA_MAX_RECONNECT` | `20` | Client manager reconnect attempts per peer |
### Data directory layout
```
data/
├── peer-policy.json # registered peers, roles, revocations, spent JTIs
└── audit.log # JSON lines for mutating RPCs + failures
```
Recommended permissions: directory `0700`. Do **not** commit `data/` or `.env`.
---
## Logging
| Variable | Default | Description |
|----------|---------|-------------|
| `LOG_LEVEL` | `info` | `debug` \| `info` \| `warn` \| `error` |
| `LOG_JSON` | off | `1` → structured JSON logs (good for journald) |
---
## Healthcheck & soak
| Variable | Default | Description |
|----------|---------|-------------|
| `PEARDATA_HEALTH_KEY` | — | Public key for remote health dial (falls back to `SERVER_PUBLIC_KEY`) |
| `HEALTHCHECK_TIMEOUT_MS` | `8000` | Healthcheck hard timeout (ms) |
| `SOAK_DURATION_MS` | `60000` | Soak test run length |
| `SOAK_INTERVAL_MS` | `500` | Delay between soak posts |
```bash
# Liveness only (no dial) when no key is set
npm run healthcheck
# Full dial + ping (server must be running)
SERVER_PUBLIC_KEY=SERVER_SEED=… npm run healthcheck
# Load soak (admin seed recommended so postMessage is allowed)
SERVER_PUBLIC_KEY=SERVER_SEED=… npm run soak
```
---
## Testing
| Variable | Default | Description |
|----------|---------|-------------|
| `SKIP_INTEGRATION` | unset | `1` skips live HyperDHT integration test |
---
## Pear GUI (package.json)
Not environment variables — set under `package.json``pear.gui`. See [DESKTOP.md](./DESKTOP.md).
| Field | Template |
|-------|----------|
| `width` × `height` | 1100 × 780 |
| `minWidth` × `minHeight` | 720 × 480 |
| `resizable` / `movable` | `true` |
| `backgroundColor` | `#0b1020` |
---
## systemd
`deploy/peardata.service` expects:
| Path | Purpose |
|------|---------|
| `/opt/peardata` | WorkingDirectory |
| `/opt/peardata/.env` | `EnvironmentFile` |
| `/opt/peardata/data` | Writable data dir |
Edit unit paths before enabling. See [GETTING-STARTED.md](./GETTING-STARTED.md#systemd).
---
## npm scripts
| Script | Command | Purpose |
|--------|---------|---------|
| `npm start` / `npm run dev` | `pear run -d .` | Pear desktop UI |
| `npm run start:server` / `server` | `node server/server.js` | P2P server |
| `npm run start:server:bin` | `node bin/peardata-server.mjs` | Alternate server entry |
| `npm test` | brittle suite | Unit + integration |
| `npm run test:integration` | integration only | Live DHT test |
| `npm run healthcheck` | dial or liveness | Process / network check |
| `npm run soak` | long connect loop | Stability exercise |
| `npm run mint-invite -- [role] [ttlMs]` | mint `pd1.` invite | Offline invite tooling |
| `npm run rename -- <slug> <Product>` | rebrand tree | New product from template |
| `bash scripts/release.sh` | source tarball | Local release artifacts |
---
## Security notes
- Never ship `SERVER_SEED` in client bundles or public repos.
- Prefer invites over seed distribution.
- Rotate seed = new public key → all clients re-dial and re-invite.
- See [SECURITY.md](./SECURITY.md) for the full checklist.
+170
View File
@@ -0,0 +1,170 @@
# Desktop (Pear) UI
The client shell is a **Pear desktop application** built with `pear-electron` + `pear-bridge`. The HTML UI runs inside a frameless-style window with in-content chrome.
## Entrypoints
| File | Role |
|------|------|
| `index.js` | Pear process entry — starts `pear-electron` Runtime + `pear-bridge` |
| `index.html` | GUI main (`pear.gui.main`) — titlebar + panels |
| `app.js` | UI logic (connect, room, presence, invites) |
| `ui/styles.css` | Layout, theme, **titlebar drag regions** |
| `client/*` | HyperDHT connection stack used by the UI |
```bash
npm start # pear run -d .
npm run dev # same
pear run -d . # equivalent
```
Requires the [Pear](https://docs.pears.com) CLI installed and bootstrapped (`pear` once to fetch the runtime).
## Window configuration (`package.json` → `pear.gui`)
| Field | Template default | Purpose |
|-------|------------------|---------|
| `main` | `index.html` | HTML entry |
| `width` / `height` | `1100` / `780` | Initial size |
| `minWidth` / `minHeight` | `720` / `480` | Resize floor |
| `resizable` | `true` | Edge/corner resize |
| `movable` | `true` | Allow OS move (with drag region) |
| `minimizable` / `maximizable` / `closable` | `true` | Window buttons |
| `hasShadow` | `true` | Native shadow |
| `backgroundColor` | `#0b1020` | Avoid white flash on boot |
| `pre` | `pear-electron/pre` | Runtime bootstrap (required) |
Platform overrides are supported:
```json
{
"pear": {
"gui": {
"darwin": { "resizable": true },
"linux": { "autoHideMenuBar": true },
"win32": { "autoHideMenuBar": true }
}
}
}
```
See [pear-electron README](https://github.com/holepunchto/pear-electron) for the full option list (`center`, `alwaysOnTop`, `transparent`, `closeHides`, etc.).
## Titlebar + `<pear-ctrl>`
Pear provides a custom element **`<pear-ctrl>`** for platform window controls:
- **macOS (`darwin`)** — layout for system traffic lights (hidden title bar chrome)
- **Windows / Linux** — minimize, maximize, close controls rendered by the runtime
### Required HTML shape
```html
<div id="titlebar" role="banner">
<div class="titlebar-left">
<pear-ctrl></pear-ctrl>
<div class="app-brand"></div>
</div>
<div class="titlebar-right">
<!-- status chips, non-drag interactive bits -->
</div>
</div>
```
Do **not** remove `<pear-ctrl>` unless you intentionally ship a different window frame model and understand OS differences.
### Drag to move
```css
#titlebar {
-webkit-app-region: drag;
height: var(--titlebar-h); /* 42px in this template */
}
/* Interactive controls must not start a drag */
#titlebar pear-ctrl,
#titlebar .chip,
#titlebar button,
#titlebar input,
#titlebar a {
-webkit-app-region: no-drag;
}
.no-drag {
-webkit-app-region: no-drag;
}
```
### Resize
- Enabled by `pear.gui.resizable: true` (default in this template).
- Users resize via the OS window edges/corners.
- Content layout should flex with the viewport (`#app` uses `height: calc(100vh - var(--titlebar-h))`).
### Platform spacing
Reserve space so brand/text never sits under traffic lights:
```css
#titlebar pear-ctrl[data-platform='darwin'] { min-width: 78px; }
#titlebar pear-ctrl[data-platform='win32'],
#titlebar pear-ctrl[data-platform='linux'] { min-width: 110px; }
```
## Client identity (desktop process)
When the UI dials servers it uses a **persistent Ed25519 identity**:
| Item | Value |
|------|--------|
| Path | `~/.config/peardata/identity.json` (or `$PEARDATA_HOME/.config/peardata/…` if you set `PEARDATA_HOME` as home root — see `client/identity.js`) |
| Mode | Directory `0700`, file `0600` |
| Contents | `{ version, seedHex, createdAt }` |
Same identity ⇒ same `peerId` across restarts (useful for peer-bound capabilities and revoke).
Override home with `PEARDATA_HOME` if you need isolation (CI, multi-profile).
## UI modules
| Module | Responsibility |
|--------|----------------|
| `client/identity.js` | Load/create keypair on disk |
| `client/connection.js` | Single HyperDHT + protomux-rpc session |
| `client/manager.js` | Multi-peer map, active selection, reconnect |
| `client/errors.js` | Unwrap / normalize RPC errors for UI |
| `app.js` | Wire DOM to manager + protocol methods |
### Manager reconnect
- Default max tries: `PEARDATA_MAX_RECONNECT` or **20**
- `connect(input, { adminSeed, autoReconnect })`
- Input may be **64-hex public key** or **`pd1.` invite**
## LocalStorage keys (demo UI)
| Key | Purpose |
|-----|---------|
| `peardata:last-connect` | Last public key / invite string |
| `peardata:display-name` | Last display name |
These are demo convenience only — production apps often prefer a file under app storage.
## Development tips
1. Keep DevTools available via `pear run -d .`
2. After HTML/CSS/JS edits, reload the Pear window (or restart `npm start`)
3. Server changes require restarting `npm run start:server`
4. If the window cannot be moved: check `#titlebar` has `drag` and children that cover the bar incorrectly are not all `no-drag` without a parent drag region
5. If controls dont work: ensure `<pear-ctrl>` is present and not covered by another element with a higher z-index and full-width hit target
## Packaging beyond Pear
This template ships the **Pear run** path only. For Electron-forge / multi-arch standalone binaries, adapt packaging from a fuller product (e.g. peardocks forge + bare-standalone scripts) once the app stabilizes.
## Related
- [GETTING-STARTED.md](./GETTING-STARTED.md)
- [ARCHITECTURE.md](./ARCHITECTURE.md)
- [CONFIGURATION.md](./CONFIGURATION.md)
- [EXTENDING.md](./EXTENDING.md)
+159
View File
@@ -0,0 +1,159 @@
# Extending the template
## 1. Rebrand
```bash
npm run rename -- notes-mesh NotesMesh
```
This rewrites:
- package name / product name
- protocol id (`notes-mesh/rpc`)
- env prefix (`NOTES_MESH_`)
- invite prefix (derived, e.g. `no1.`)
- binary + systemd unit filenames
Then:
```bash
npm install
npm test
git diff # review
```
## 1b. Titlebar / window chrome
Keep these when restyling:
| Piece | Role |
|-------|------|
| `<pear-ctrl>` in `#titlebar` | Close / minimize / maximize (Pear runtime custom element) |
| `#titlebar { -webkit-app-region: drag }` | Drag the window |
| Interactive children `no-drag` | Buttons, chips, inputs stay clickable |
| `pear.gui.resizable: true` | Edge/corner resize |
| `pear.gui.minWidth` / `minHeight` | Floor size while resizing |
Do not remove `<pear-ctrl>` unless you intentionally want a frame without in-content controls (and understand platform differences). Details: [DESKTOP.md](./DESKTOP.md).
## 2. Add an RPC method
### `shared/protocol.js`
```js
export const MethodRoles = Object.freeze({
// ...
listNotes: Roles.viewer,
createNote: Roles.operator,
})
```
`Methods` is derived automatically from `MethodRoles` keys.
### `shared/schema.js`
Validate `createNote` args (required fields, max lengths).
### `server/services/notes.js`
Domain logic / storage (keep IO out of handlers when possible).
### `server/handlers/notes.js`
```js
export function registerNoteHandlers(session) {
session.respond('listNotes', async () => ({ notes: [] }))
session.respond('createNote', async (args, s) => { /* ... */ })
}
```
### `server/rpc/register.js`
Call `registerNoteHandlers(session)`.
### Client / UI
```js
await manager.request(Methods.createNote, { title: '…' })
```
### Tests
- Unit for pure helpers + schema
- Integration for the happy path if AuthZ/wire matter
## 3. Add a push channel
1. Add to `Pushes` in `shared/protocol.js`
2. Optionally map in `PushToType`
3. `session.push(Pushes.foo, payload)` or broadcast via peer registry
4. Listen in UI: `manager.on('push', …)` or `conn.on(Pushes.foo, …)`
(`connection.js` auto-registers all `Pushes` values)
## 4. Persistence
Demo room is in-memory (`server/services/room.js`). Swap for:
| Store | Good for |
|-------|----------|
| **Corestore / Hypercore** | Append-only logs, P2P replication |
| **SQLite** | Structured queries |
| **JSON files** under `PEARDATA_DATA_DIR` | Small config (peer-policy already does this) |
Keep RPC handlers thin; put IO in `services/`.
## 5. Binary streams
Peardock-class apps use chunked binary RPC for uploads. Pattern:
- methods `binaryStreamOpen` / `Chunk` / `Close` marked `hot: true` in `session.respond`
- skip heavy schema/audit on the hot path
- still enforce ACL + rate limits
Stub hooks exist via `rateLimiter.isStreamMethod`.
## 6. Multi-server fleet
`client/manager.js` already holds many connections + active selection + reconnect:
```js
await manager.connect(keyOrInviteA, { autoReconnect: true })
await manager.connect(keyOrInviteB, { autoReconnect: true })
manager.setActive(keyA)
await manager.request(Methods.ping, {})
```
Point UI at a peer list stored in `localStorage` or a file cache for a fuller fleet UX.
## 7. Custom desktop UX
| Goal | Touch |
|------|-------|
| New screens | `index.html` + `app.js` + `ui/styles.css` |
| Window size | `package.json``pear.gui` |
| Branding in titlebar | `.app-brand` markup/CSS |
| Persist UI prefs | replace demo `localStorage` keys |
Preserve drag / `pear-ctrl` behavior — [DESKTOP.md](./DESKTOP.md).
## 8. Desktop packaging
This template ships the **Pear** GUI path (`pear run`). For Electron-forge / bare-standalone multi-arch releases, copy more complex packaging scripts from a production app (forge config, make scripts) once your protocol stabilizes.
## 9. Checklist for a new product
- [ ] `npm run rename -- …`
- [ ] Replace demo handlers + services
- [ ] Update PROTOCOL.md method table
- [ ] Update SECURITY / CONFIG if new secrets
- [ ] Tests green (`npm test`)
- [ ] README product description
- [ ] CI still green
- [ ] First release tag ([RELEASE.md](./RELEASE.md))
## Related
- [ARCHITECTURE.md](./ARCHITECTURE.md)
- [PROTOCOL.md](./PROTOCOL.md)
- [TESTING.md](./TESTING.md)
- [DESKTOP.md](./DESKTOP.md)
+188
View File
@@ -0,0 +1,188 @@
# Getting started
## Prerequisites
| Tool | Required | Notes |
|------|----------|--------|
| **Node.js ≥ 20** | Yes | Server + tests |
| **npm** | Yes | Install deps |
| **[Pear](https://docs.pears.com) CLI** | For desktop UI | Run `pear` once to bootstrap the runtime |
| UDP / network | For real peers | HyperDHT hole-punching |
## Install
```bash
git clone <your-fork-or-template-url> my-app
cd my-app
npm install
cp .env.example .env # optional; server auto-writes seed on first boot
```
## Run the server
```bash
npm run start:server
# alias: npm run server
```
On first boot the server appends to `.env`:
```
SERVER_SEED=<64 hex secret>
SERVER_PUBLIC_KEY=<64 hex public>
```
**Treat `SERVER_SEED` like a root password.** Anyone with it can mint admin proofs and capabilities.
Banner output shows the public key clients dial, for example:
```
Client → dial <SERVER_PUBLIC_KEY>
```
Leave this process running while clients connect.
## Run the desktop UI
```bash
npm start
# or: npm run dev
# or: pear run -d .
```
### Desktop window chrome
The Pear UI uses a custom titlebar:
| Piece | Behavior |
|-------|----------|
| `<pear-ctrl>` | Platform window controls (macOS traffic lights / Windows & Linux min·max·close) |
| `#titlebar` | `-webkit-app-region: drag` — drag to move the window |
| Interactive children | `no-drag` so buttons and chips stay clickable |
| `pear.gui.resizable` | Edge/corner resize (`true` by default) |
| `minWidth` / `minHeight` | 720 × 480 floor |
Full details: [DESKTOP.md](./DESKTOP.md).
## Connect as admin (dev)
1. Start the Pear UI: `npm start`
2. Paste `SERVER_PUBLIC_KEY` into **Server / invite**
3. Paste `SERVER_SEED` into **Admin seed**
4. Optionally set a **Display name**
5. Click **Connect** → role badge should show `admin`
The seed never goes over the wire as plaintext — the client sends an HMAC **admin proof**.
## Connect via invite
```bash
# Persistent operator invite (default)
npm run mint-invite -- operator
# 7-day operator invite (ttl in ms)
npm run mint-invite -- operator 604800000
# Admin invite
npm run mint-invite -- admin
```
Stdout prints a `pd1.…` string. Paste it into the UI connect field (no seed needed).
You can also mint from a connected admin session with **Mint invite** in the UI.
## Viewer-only
Paste only the public key. You can:
- `listMessages`, `getPresence`, `getServerInfo`, `setDisplayName`, `ping`
You cannot:
- `postMessage` (needs `operator+`)
- `clearMessages`, `mintInvite`, `listPeers`, `revokePeer` (needs `admin`)
Unless you raise `PEARDATA_DEFAULT_ROLE` (not recommended for multi-user hosts).
## Quick verification
```bash
npm test
SKIP_INTEGRATION=1 npm test # unit only
# With server running:
export SERVER_PUBLIC_KEY=# from .env
export SERVER_SEED=# optional but enables admin dial
npm run healthcheck
npm run soak # optional load exercise
```
## Environment knobs (summary)
| Variable | Purpose |
|----------|---------|
| `SERVER_SEED` / `SERVER_PUBLIC_KEY` | Server identity |
| `PEARDATA_DEFAULT_ROLE` | Baseline role (`viewer` default) |
| `PEARDATA_ADMIN_KEYS` | Peer pubs always admin |
| `PEARDATA_INSECURE_OPEN_ADMIN` | Dev only — all peers admin |
| `PEARDATA_ALLOWLIST` | If set, only listed / registered peers |
| `PEARDATA_DATA_DIR` | Peer policy + audit log directory |
| `PEARDATA_RATE_LIMIT_RPM` | Per-peer RPC budget |
| `PEARDATA_MAX_MESSAGES` | Demo room history cap |
| `PEARDATA_MAX_RECONNECT` | Client reconnect tries |
| `LOG_LEVEL` / `LOG_JSON` | Logging |
Full table: [CONFIGURATION.md](./CONFIGURATION.md).
## Rebrand for a new product
```bash
npm run rename -- my-app MyApp
npm install
npm test
```
Rewrites package name, protocol id, env prefixes, invite prefix (`pd1.` → derived), product strings, and renames the server binary / systemd unit. Review `git diff` after.
## systemd
```bash
# Install tree to /opt/peardata (example)
sudo mkdir -p /opt/peardata
sudo rsync -a --exclude node_modules --exclude .git ./ /opt/peardata/
cd /opt/peardata && sudo npm install --omit=dev
sudo cp deploy/peardata.service /etc/systemd/system/
# Edit WorkingDirectory, EnvironmentFile, ReadWritePaths if paths differ
sudo systemctl daemon-reload
sudo systemctl enable --now peardata
sudo journalctl -u peardata -f
```
The unit sets `NoNewPrivileges`, `ProtectSystem=strict`, and writable paths for `data/` + `.env`.
## Troubleshooting
| Symptom | What to check |
|---------|----------------|
| `Connection timeout` | Server running? Correct 64-hex key? Firewall / UDP? |
| `PERMISSION_DENIED` on send | Role is viewer — use invite or admin seed |
| `Rate limit exceeded` | Raise `PEARDATA_RATE_LIMIT_RPM` or slow clients |
| Window wont drag | Titlebar drag CSS; dont cover bar with full-screen `no-drag` overlay |
| Window wont resize | `pear.gui.resizable` must be true; try edges not just corners |
| No `<pear-ctrl>` buttons | Running under Pear (`pear run`)? Element is runtime-provided |
| Integration test fails in CI | Set `SKIP_INTEGRATION=1` or allow UDP |
| Seed regenerated every boot | `.env` not writable / wrong cwd |
## Next steps
| Doc | When |
|-----|------|
| [DESKTOP.md](./DESKTOP.md) | Titlebar, pear-ctrl, packaging |
| [ARCHITECTURE.md](./ARCHITECTURE.md) | Stack & session pipeline |
| [PROTOCOL.md](./PROTOCOL.md) | Methods, pushes, versioning |
| [SECURITY.md](./SECURITY.md) | Production hardening |
| [CONFIGURATION.md](./CONFIGURATION.md) | Full env reference |
| [EXTENDING.md](./EXTENDING.md) | Replace the demo room |
| [TESTING.md](./TESTING.md) | Tests & soak |
| [CI.md](./CI.md) / [RELEASE.md](./RELEASE.md) | Pipelines & shipping |
+158
View File
@@ -0,0 +1,158 @@
# Protocol
## Constants
| Constant | Value |
|----------|--------|
| `PROTOCOL` | `peardata/rpc` |
| `PROTOCOL_VERSION` | `1` |
| `APP_NAME` | `peardata` |
| `APP_VERSION` | `0.1.0` (keep in sync with package where useful) |
| Encoding | compact-encoding JSON (`shared/encodings.js`) |
| Schema | lightweight validators (`shared/schema.js`) `SCHEMA_VERSION=1` |
Bump `PROTOCOL_VERSION` on breaking request/response shapes. Additive methods may land without a bump if clients ignore unknown methods.
After `npm run rename`, `PROTOCOL` becomes `<slug>/rpc` and invite prefix is regenerated.
## Transport
1. Client opens HyperDHT secret stream to server public key (Noise, mutual key auth).
2. `ProtomuxRPC` is attached with `protocol: PROTOCOL` and shared encodings.
3. Client calls `handshake` before other RPCs (connection helper does this automatically).
4. Server may `push` events on named channels.
## Roles
| Role | Intent |
|------|--------|
| `viewer` | Read-only |
| `operator` | Mutate domain data |
| `admin` | Invite mint, clear, revoke, config |
Hierarchy: `admin > operator > viewer` (`roleAllows`).
Unknown methods default to **admin** required in `assertAllowed` if missing from `MethodRoles` — always register new methods.
## Methods
| Method | Min role | Request args | Response (summary) |
|--------|----------|--------------|--------------------|
| `handshake` | viewer | `clientName`, `clientVersion`, optional `capability`, `adminProof` | role, auth, versions, features |
| `ping` | viewer | `{}` | `{ ok, pong, peerId }` |
| `getServerInfo` | viewer | `{}` | app, versions, host, peer count |
| `getAuthStatus` | viewer | `{}` | peerId, role, authMode, displayName |
| `listMessages` | viewer | `{ limit? }` | `{ messages: [...] }` |
| `getPresence` | viewer | `{}` | `{ peers: [...] }` |
| `postMessage` | operator | `{ text }` (12000 chars) | created message |
| `setDisplayName` | viewer | `{ name }` (140 chars) | updated label |
| `clearMessages` | admin | `{}` | success + system push |
| `mintInvite` | admin | `{ role?, ttlMs?, peerId?, alias? }` | `invite` (`pd1.…`), jti, exp |
| `listPeers` | admin | `{}` | live + policy peers |
| `revokePeer` | admin | `{ peerId }` (64 hex) | success; target dropped |
### Handshake request
```json
{
"clientName": "peardata",
"clientVersion": "0.1.0",
"capability": "<optional HMAC token>",
"adminProof": { "nonce": "<hex>", "mac": "<hex>" }
}
```
### Handshake response
```json
{
"success": true,
"protocol": "peardata/rpc",
"protocolVersion": 1,
"schemaVersion": 1,
"role": "operator",
"peerId": "<64 hex>",
"serverTime": 0,
"auth": { "mode": "capability", "role": "operator" },
"features": { "hmacAuth": true, "invites": true, "room": true }
}
```
### Auth modes (`auth.mode` / `session.authMode`)
| Mode | How obtained |
|------|----------------|
| `viewer` | Default after connect with no grant |
| `capability` | Valid capability / invite |
| `seed` | Valid admin proof from `SERVER_SEED` |
| `allowlist` / registered | Elevated via policy / admin keys (implementation in ACL + policy) |
Exact labels depend on handshake path; UI shows `authMode` from `getAuthStatus`.
## Pushes (server → client events)
| Push | Payload |
|------|---------|
| `push:message` | `{ id, peerId, displayName, text, ts }` |
| `push:presence` | `{ peers: [...] }` |
| `push:system` | `{ type, ... }` e.g. `{ type: "cleared" }` |
Registered via `rpc.event` / `session.push`. Client `connection.js` binds all `Pushes` values.
## Invites
Envelope: `pd1.` + base64url(JSON):
```json
{
"v": 1,
"publicKeyHex": "<server>",
"capability": "<token>",
"role": "operator",
"jti": "...",
"expiresAt": null
}
```
Capability token: `base64url(payload).base64url(HMAC-SHA256)`.
Payload fields (canonical order for MAC): `v`, `role`, `peerId`, `exp`, `jti`, `iat`.
### Connection input classification
`classifyConnectionInput(string)` accepts:
| Input | Kind |
|-------|------|
| 64 hex chars | `publicKey` |
| `pd1.…` | `invite` (extracts key + capability) |
| other | error |
## Error codes
| Code | When |
|------|------|
| `RATE_LIMIT_EXCEEDED` | Peer over RPM budget |
| `PERMISSION_DENIED` | Role too low for method |
| `INVALID_ARGS` | Schema validation failed |
| `CONNECTION_TIMEOUT` | Client dial timeout |
| `RPC_ERROR` | Generic client-normalized failure |
| `UNKNOWN_ERROR` | Unclassified server handler error |
Clients should read `error.code` when present (`client/errors.js` preserves codes).
## Versioning policy
1. Document every method in this file.
2. Add `MethodRoles` entry before implementing handlers.
3. Add `validateMethodArgs` case for mutating methods.
4. Add brittle tests for pure helpers; integration test for critical paths.
5. Bump `PROTOCOL_VERSION` when existing response shapes break.
6. Bump `SCHEMA_VERSION` when validation semantics change meaningfully.
## Related
- [ARCHITECTURE.md](./ARCHITECTURE.md)
- [SECURITY.md](./SECURITY.md)
- [EXTENDING.md](./EXTENDING.md)
- [TESTING.md](./TESTING.md)
+18
View File
@@ -0,0 +1,18 @@
# Documentation index
| Doc | Audience | Contents |
|-----|----------|----------|
| [GETTING-STARTED.md](./GETTING-STARTED.md) | New operators | Install, run, connect, systemd, troubleshooting |
| [DESKTOP.md](./DESKTOP.md) | UI developers | Pear shell, `pear-ctrl`, drag/resize, identity |
| [ARCHITECTURE.md](./ARCHITECTURE.md) | Engineers | Planes, boot, middleware, module map |
| [PROTOCOL.md](./PROTOCOL.md) | Protocol owners | Methods, pushes, errors, versioning |
| [SECURITY.md](./SECURITY.md) | Operators / security | Trust model, checklist, crypto, incidents |
| [CONFIGURATION.md](./CONFIGURATION.md) | Operators | Full env vars + npm scripts |
| [TESTING.md](./TESTING.md) | Contributors | brittle suite, soak, manual QA |
| [CI.md](./CI.md) | Maintainers | GitHub/Gitea pipelines |
| [RELEASE.md](./RELEASE.md) | Maintainers | Version, tag, tarball, rollback |
| [EXTENDING.md](./EXTENDING.md) | Product builders | Rebrand, new RPCs, persistence, fleet |
Start here if you are new: **[GETTING-STARTED.md](./GETTING-STARTED.md)**.
Root overview: **[../README.md](../README.md)**.
+106
View File
@@ -0,0 +1,106 @@
# Release process
## Preconditions
- [ ] `npm test` passes (use `SKIP_INTEGRATION=1` only if the runner cannot do DHT)
- [ ] Version bumped in `package.json`
- [ ] Docs updated if protocol / env / UX changed
- [ ] No secrets in the tree (`.env`, `data/`, identity files)
- [ ] `git status` clean except intended changes
## Version & tag
```bash
# 1. Bump version in package.json (semver)
# 2. Commit
git add package.json
git commit -m "Release vX.Y.Z"
# 3. Tag
git tag -a vX.Y.Z -m "vX.Y.Z"
# 4. Push
git push origin main
git push origin vX.Y.Z
```
Tag pattern **`v*`** triggers versioned GitHub releases. On Gitea, **every push to `main`/`master`** rebuilds the rolling release:
| Forge | Workflow | Trigger | Output |
|-------|----------|---------|--------|
| GitHub | `.github/workflows/release.yml` | `v*` tags / manual | Source tarball + sha256 → GitHub Release |
| Gitea | `.gitea/workflows/release-rolling.yml` | push to `main`/`master` / manual | `scripts/gitea-rolling-release.sh` → prerelease tag **`rolling`** |
The rolling job always runs after a successful install+test on the default branch. It deletes and recreates the `rolling` tag/release so operators can always download the latest main build.
## Local artifacts
```bash
bash scripts/release.sh
```
Produces:
```
dist/
├── peardata-vX.Y.Z.tar.gz
├── peardata-vX.Y.Z.tar.gz.sha256
└── RELEASE_NOTES.md
```
Verify:
```bash
cd dist
sha256sum -c peardata-vX.Y.Z.tar.gz.sha256
# or: shasum -a 256 -c …
```
Tarball **excludes** `node_modules/`, `.git/`, `data/`, `dist/`.
## What ships
| Included | Not included |
|----------|--------------|
| Source (server, client, shared, UI) | `node_modules` |
| CI workflows | `.env` / secrets |
| Docs + systemd unit | Runtime `data/` |
| Scripts | Generated release dist |
Consumers install with `npm install` after unpacking.
## Changelog guidance
For each release note:
1. **Protocol** — method / push / version bumps
2. **Security** — auth or default role changes
3. **Desktop** — pear-ctrl / window / Pear dependency bumps
4. **Ops** — env vars, systemd, healthcheck
5. **Breaking** — call out re-dial / re-invite requirements
## Hotfix without retag
Push commits to `main` for CI only. Prefer a new patch tag for anything operators must download.
## Rollback
- **App code**: redeploy previous tag tarball / checkout
- **Server seed**: only if compromised — generate new seed (new public key); re-issue all invites; clients must dial the new key
- **Peer policy**: restore `data/peer-policy.json` from backup if revoke tables were corrupted
## Secrets for forges
| Secret | Where | Purpose |
|--------|-------|---------|
| `GITHUB_TOKEN` | GitHub (automatic) | Upload release assets |
| `RELEASE_TOKEN` | Gitea (**required**) | Publish/update `rolling` release |
| `GITEA_URL` | Gitea (optional) | Forge API base (defaults from runner) |
Never put `SERVER_SEED` in CI secrets unless a dedicated deploy job needs it — prefer generating seeds on the target host.
## Related
- [CI.md](./CI.md)
- [SECURITY.md](./SECURITY.md)
- [CONFIGURATION.md](./CONFIGURATION.md)
+96
View File
@@ -0,0 +1,96 @@
# Security
## Trust model
- **Server seed** (`SERVER_SEED`) is the root of HMAC auth (capabilities + admin proofs). Compromise = full admin minting.
- **Client identity** seed under `~/.config/peardata/identity.json` identifies the peer across reconnects. Protect it if you bind capabilities to `peerId`.
- **HyperDHT** provides mutual authentication of keypairs on the secret stream. RPC still needs application AuthZ (roles).
- **UI process** can hold an admin seed in memory when the user pastes it — treat the desktop machine as trusted for that session.
## Auth paths
1. **Admin proof** — client proves knowledge of seed-derived MAC key without sending the seed (`createAdminProof` / `verifyAdminProof`).
2. **Capability** — server-signed grant with role, optional expiry, optional peer binding, JTI spend tracking.
3. **Registered peer** — after a successful grant, reconnects may use the stored role without replaying a spent JTI (see `redeemCapability` / peer policy).
4. **Admin keys env**`PEARDATA_ADMIN_KEYS` forces admin for listed peer public keys.
5. **Revocation**`revokePeer` drops live sessions and blocks future dials.
6. **Allowlist** — when `PEARDATA_ALLOWLIST` is non-empty, unknown peers are rejected.
## Secure defaults
| Default | Value |
|---------|--------|
| Unknown peer role | `viewer` |
| Open admin | **off** |
| Capability forever | yes unless `ttlMs` set |
| Rate limit | 120 RPC / minute / peer |
| Audit | mutating methods + handshake failures |
| Identity file mode | `0600` |
| Data directory | local `./data` (not committed) |
## Production checklist
- [ ] Never set `PEARDATA_INSECURE_OPEN_ADMIN` outside local demos
- [ ] Keep `PEARDATA_DEFAULT_ROLE=viewer`
- [ ] Prefer invites over sharing `SERVER_SEED`
- [ ] Use short `ttlMs` for high-privilege invites when practical
- [ ] Set `PEARDATA_ALLOWLIST` if only known operators should dial
- [ ] Back up `SERVER_SEED` offline; rotate by redeploying a new keypair (clients must re-dial)
- [ ] Persist `data/` with mode `0700`; `audit.log` may contain peer ids
- [ ] Run under systemd with `ProtectSystem` / `NoNewPrivileges` (see `deploy/`)
- [ ] Do not embed seed in frontend builds, CI logs, or crash reports
- [ ] Review `LOG_LEVEL=debug` before production (avoid verbose auth noise)
- [ ] Keep Pear / dependency updates current (`npm outdated`)
## Threat notes
| Threat | Mitigation |
|--------|------------|
| Stolen invite | Short TTL; peer-bound capabilities; revoke JTI / peer |
| Stolen client identity | Revoke peer id; re-issue invites |
| Stolen `SERVER_SEED` | Rotate keypair; all grants invalid; re-onboard clients |
| RPC spam | Rate limiter (`PEARDATA_RATE_LIMIT_RPM`) |
| Confused deputy role | Server never trusts client-supplied role field |
| Log leakage | Logger never prints seeds/tokens |
| Rogue desktop | OS user access = ability to paste seed; use invites on shared machines |
| Supply chain | Pin deps; review `npm audit`; CI from trusted runners |
## Crypto details
| Item | Algorithm / format |
|------|---------------------|
| DHT identity | Ed25519 via HyperDHT keyPair(seed) |
| MAC key | HKDF-SHA256(seed, salt=`peardata-hmac-v1`, info=`capability`) → 32 bytes |
| Capability MAC | HMAC-SHA256(macKey, canonical JSON payload) |
| Admin proof | HMAC-SHA256(macKey, `peardata-admin-v1` ‖ nonce ‖ peerId ‖ serverPk) |
| Invite envelope | `pd1.` + base64url(JSON) |
Implementation: `shared/crypto-auth.js`.
### Canonical capability payload fields
`v`, `role`, `peerId`, `exp`, `jti`, `iat` — ordered JSON before MAC.
## Operational security
| Artifact | Sensitivity | Handling |
|----------|-------------|----------|
| `.env` | Critical | Never commit; backup offline |
| `data/peer-policy.json` | High | Contains roles & JTIs |
| `data/audit.log` | Medium | Peer activity metadata |
| `identity.json` | High for that user | Per-machine client secret |
| Release tarballs | Low | Source only; no secrets |
## Incident response (seed leak)
1. Stop accepting connections on the compromised key (shutdown / firewall).
2. Generate new `SERVER_SEED` on a clean host (new public key).
3. Deploy new server; do not reuse old seed.
4. Re-issue invites to operators; notify clients of new public key.
5. Review `audit.log` for abuse window.
## Related
- [CONFIGURATION.md](./CONFIGURATION.md)
- [PROTOCOL.md](./PROTOCOL.md)
- [RELEASE.md](./RELEASE.md)
+108
View File
@@ -0,0 +1,108 @@
# Testing
## Test stack
| Piece | Tool |
|-------|------|
| Runner | [brittle](https://github.com/holepunchto/brittle) via `brittle-node` |
| Location | `test/*.test.js` |
| Command | `npm test` |
## Suite map
| File | Coverage |
|------|----------|
| `test/acl.test.js` | Role hierarchy, `assertAllowed` |
| `test/crypto-auth.test.js` | MAC key, capabilities, admin proof, invites, classify input |
| `test/protocol.test.js` | Constants, `MethodRoles`, schema validators |
| `test/integration.test.js` | Live HyperDHT server + client handshake, post, push |
```bash
npm test
npm run test:integration
# Skip live DHT (CI runners without UDP, offline laptops)
SKIP_INTEGRATION=1 npm test
```
## Integration test behavior
1. Starts an ephemeral HyperDHT server in-process
2. Sets `PEARDATA_INSECURE_OPEN_ADMIN=1` for the process (restored in teardown)
3. Dials as a client, handshakes, posts a message, asserts push delivery
4. Tears down sockets / DHT
Requires outbound/inbound UDP for HyperDHT. If the test hangs or fails on a locked-down network, use `SKIP_INTEGRATION=1`.
## Writing tests
### Unit (preferred for pure logic)
- Put pure helpers in `shared/` or thin `server/core` modules
- Assert without networking
- Cover: validation failures, role denials, crypto tampering, expiry
Example pattern:
```js
import test from 'brittle'
import { roleAllows, Roles } from '../shared/protocol.js'
test('operator cannot admin-only methods', (t) => {
t.ok(roleAllows(Roles.operator, Roles.viewer))
t.absent(roleAllows(Roles.operator, Roles.admin))
})
```
### Integration (critical paths)
- Boot real `PeerSession` stack or full server when AuthZ + wire encoding matter
- Always clean up DHT / sockets in `t.teardown`
- Prefer one happy-path + one auth-failure path over many flaky cases
### Schema / protocol
When adding an RPC method:
1. `MethodRoles` entry
2. `validateMethodArgs` case (if args matter)
3. Unit test for validator
4. Optional integration call for the happy path
## Manual checks
| Check | How |
|-------|-----|
| Server boots | `npm run start:server` — prints public key |
| Invite mint | `npm run mint-invite -- operator` |
| Desktop chrome | `npm start` — drag titlebar, resize edges, min/max/close |
| Admin connect | Paste key + `SERVER_SEED` in UI |
| Invite connect | Paste `pd1.…` without seed |
| Viewer denial | Public key only → `postMessage` fails with permission error |
| Health | With server up: `SERVER_PUBLIC_KEY=… npm run healthcheck` |
| Soak | `SERVER_PUBLIC_KEY=… SERVER_SEED=… npm run soak` |
## Soak test
```bash
# default 60s
SERVER_PUBLIC_KEY=<hex> SERVER_SEED=<hex> npm run soak
SOAK_DURATION_MS=300000 SOAK_INTERVAL_MS=250 npm run soak
```
Reports sent / received / errors. Use before release when changing session or room code.
## CI
GitHub CI runs `npm test` on Node 20 and 22. Gitea CI runs `npm test` on Node 22 plus a liveness `healthcheck`.
See [CI.md](./CI.md).
## Coverage philosophy
This template prioritizes **critical pure paths + one live DHT smoke test** over heavy mock frameworks. When productizing:
- Add domain unit tests next to new services
- Keep integration tests few and deterministic
- Gate flaky network tests behind `SKIP_INTEGRATION`
+110
View File
@@ -0,0 +1,110 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PearData — P2P Fleet Monitoring</title>
<link rel="stylesheet" href="./ui/styles.css" />
</head>
<body>
<div id="titlebar" role="banner">
<div class="titlebar-left">
<pear-ctrl></pear-ctrl>
<div class="app-brand" aria-hidden="true">
<span class="logo"></span>
<div class="app-brand-text">
<strong>PearData</strong>
<span class="muted">P2P · Netdata-class observability</span>
</div>
</div>
</div>
<div class="titlebar-right">
<span id="role-badge" class="badge"></span>
<div id="status-chip" class="chip offline">offline</div>
</div>
</div>
<div id="app">
<aside class="fleet-rail panel">
<h2>Fleet</h2>
<p class="hint">Connect agents by public key or <code>pd1.</code> invite.</p>
<label>
Agent / invite
<textarea id="connect-input" rows="2" placeholder="64-hex key or pd1.…"></textarea>
</label>
<label>
Admin seed (optional)
<input id="admin-seed" type="password" autocomplete="off" placeholder="SERVER_SEED" />
</label>
<div class="row">
<button id="btn-connect" class="primary">Connect</button>
<button id="btn-disconnect" class="ghost" disabled>Disconnect</button>
</div>
<ul id="peer-list" class="peer-list"></ul>
<div id="conn-meta" class="meta muted"></div>
<div class="admin-block">
<button id="btn-invite" class="ghost" disabled>Mint invite</button>
<pre id="invite-out" class="invite-out hidden"></pre>
</div>
</aside>
<main class="dash-main">
<section class="overview-strip panel">
<div class="stat" data-stat="cpu">
<span class="stat-label">CPU</span>
<strong id="stat-cpu"></strong>
</div>
<div class="stat" data-stat="ram">
<span class="stat-label">RAM used</span>
<strong id="stat-ram"></strong>
</div>
<div class="stat" data-stat="load">
<span class="stat-label">Load 1m</span>
<strong id="stat-load"></strong>
</div>
<div class="stat" data-stat="net">
<span class="stat-label">Net RX</span>
<strong id="stat-net"></strong>
</div>
<div class="stat" data-stat="health">
<span class="stat-label">Health</span>
<strong id="stat-health"></strong>
</div>
</section>
<section class="charts-grid">
<article class="panel chart-panel">
<header><h3>CPU</h3><span class="muted">system.cpu</span></header>
<canvas id="chart-cpu" height="140"></canvas>
</article>
<article class="panel chart-panel">
<header><h3>Memory</h3><span class="muted">system.ram</span></header>
<canvas id="chart-ram" height="140"></canvas>
</article>
<article class="panel chart-panel">
<header><h3>Network</h3><span class="muted">system.net</span></header>
<canvas id="chart-net" height="140"></canvas>
</article>
<article class="panel chart-panel">
<header><h3>Disk I/O</h3><span class="muted">system.io</span></header>
<canvas id="chart-io" height="140"></canvas>
</article>
</section>
<section class="bottom-row">
<div class="panel">
<h3>Anomalies</h3>
<ul id="anomaly-list" class="event-list"></ul>
</div>
<div class="panel">
<h3>Node</h3>
<pre id="server-info" class="server-info muted">Not connected</pre>
<h3>Log</h3>
<pre id="log" class="log"></pre>
</div>
</section>
</main>
</div>
<script type="module" src="./app.js"></script>
</body>
</html>
+33
View File
@@ -0,0 +1,33 @@
/**
* Pear desktop entrypoint.
* Boots pear-electron UI + pear-bridge HTTP for the HTML app shell.
*
* @typedef {import('pear-interface')}
*/
/* global Pear */
import Runtime from 'pear-electron'
import Bridge from 'pear-bridge'
const bridge = new Bridge()
await bridge.ready()
const runtime = new Runtime()
const pipe = await runtime.start({ bridge })
const shutdown = async () => {
try {
Pear.exit()
} catch {
// ignore
}
}
pipe.on('close', () => {
shutdown()
})
try {
Pear.teardown?.(async () => {})
} catch {
// ignore
}
+2230
View File
File diff suppressed because it is too large Load Diff
+70
View File
@@ -0,0 +1,70 @@
{
"name": "peardata",
"version": "0.1.0",
"description": "Production-ready HyperDHT + protomux-rpc P2P app template (demo room + presence)",
"type": "module",
"license": "MIT",
"main": "index.js",
"productName": "PearData",
"engines": {
"node": ">=20"
},
"pear": {
"pre": "pear-electron/pre",
"name": "peardata",
"gui": {
"main": "index.html",
"backgroundColor": "#0b1020",
"height": 780,
"width": 1100,
"minWidth": 720,
"minHeight": 480,
"resizable": true,
"movable": true,
"minimizable": true,
"maximizable": true,
"closable": true,
"hasShadow": true
},
"links": [
"http://*",
"https://*",
"ws://*",
"wss://*"
]
},
"scripts": {
"dev": "pear run -d .",
"start": "pear run -d .",
"start:server": "node server/server.js",
"server": "node server/server.js",
"start:server:bin": "node bin/peardata-server.mjs",
"test": "brittle-node test/*.test.js",
"test:integration": "brittle-node test/integration.test.js",
"healthcheck": "node scripts/healthcheck.js",
"soak": "node scripts/soak.js",
"mint-invite": "node scripts/mint-invite.js",
"rename": "bash scripts/rename-template.sh",
"release:notes": "node -e \"console.log('See docs/RELEASE.md')\""
},
"dependencies": {
"b4a": "^1.8.1",
"compact-encoding": "^3.3.0",
"dotenv": "^17.4.2",
"graceful-goodbye": "^1.3.3",
"hypercore-crypto": "^3.7.0",
"hyperdht": "^6.33.0",
"pear-bridge": "^1.2.5",
"pear-electron": "^1.7.28",
"pear-pipe": "^1.0.6",
"pear-run": "^1.0.8",
"protomux": "^3.11.0",
"protomux-rpc": "^1.10.0",
"safety-catch": "^1.0.3",
"z32": "^1.1.0"
},
"devDependencies": {
"brittle": "^4.1.0",
"pear-interface": "^1.1.0"
}
}
+158
View File
@@ -0,0 +1,158 @@
#!/usr/bin/env bash
# Build source release artifacts and publish a rolling Gitea release.
#
# Required:
# RELEASE_TOKEN — Gitea PAT with repository release write
# Optional:
# GITEA_URL / GITEA_OWNER / GITEA_REPO
# RELEASE_TAG (default: rolling)
# DRY_RUN=1 — build + stage only, no upload
# GITHUB_SHA / GITEA_SHA — target commit for the release tag
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
VERSION="$(node -p "require('./package.json').version")"
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
SHA="$(git rev-parse --short HEAD 2>/dev/null || echo unknown)"
FULL_SHA="${GITHUB_SHA:-${GITEA_SHA:-$(git rev-parse HEAD 2>/dev/null || echo main)}}"
TAG="${RELEASE_TAG:-rolling}"
PKG_NAME="$(node -p "require('./package.json').name")"
RELEASE_TITLE="${RELEASE_NAME:-${PKG_NAME} rolling}"
DIST="$ROOT/dist"
log() { echo "[release] $*"; }
detect_remote() {
local url
url="$(git remote get-url origin 2>/dev/null || true)"
if [[ "$url" =~ git@([^:]+):([^/]+)/([^/.]+) ]]; then
echo "https://${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" "${BASH_REMATCH[3]}"
elif [[ "$url" =~ https?://([^/]+)/([^/]+)/([^/.]+) ]]; then
echo "https://${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" "${BASH_REMATCH[3]}"
else
echo "" "" ""
fi
}
read -r DETECTED_URL DETECTED_OWNER DETECTED_REPO <<<"$(detect_remote)"
GITEA_URL="${GITEA_URL:-${DETECTED_URL:-}}"
GITEA_OWNER="${GITEA_OWNER:-${DETECTED_OWNER:-}}"
GITEA_REPO="${GITEA_REPO:-${DETECTED_REPO:-${PKG_NAME}}}"
if [[ -z "${GITEA_URL}" ]]; then
log "WARN: could not detect GITEA_URL — set GITEA_URL for upload"
fi
# --- build artifacts (source tarball + checksum + notes) ---
log "building release artifacts via scripts/release.sh"
bash scripts/release.sh
# Enrich notes with rolling metadata (release.sh writes a base file)
cat >"$DIST/RELEASE_NOTES.md" <<EOF
# ${PKG_NAME} ${VERSION} (${TAG})
- Commit: \`${SHA}\` (\`${FULL_SHA}\`)
- Built: ${STAMP}
HyperDHT + protomux-rpc application template.
## Contents
- Node server (\`npm run start:server\`)
- Pear desktop client (\`npm start\` / \`pear run -d .\`)
- Demo room (messages + presence + invites)
## Install
\`\`\`bash
mkdir -p app && tar -xzf ${PKG_NAME}-v${VERSION}.tar.gz -C app
cd app
npm install
npm run start:server
\`\`\`
## Verify
\`\`\`bash
sha256sum -c ${PKG_NAME}-v${VERSION}.tar.gz.sha256
\`\`\`
## Checksums
See \`*.sha256\` beside each archive.
EOF
log "artifacts in $DIST:"
ls -la "$DIST" || true
shopt -s nullglob
ARTIFACTS=("$DIST"/*.tar.gz)
if [[ ${#ARTIFACTS[@]} -eq 0 ]]; then
log "ERROR: no tarball artifacts in $DIST"
exit 1
fi
if [[ "${DRY_RUN:-0}" == "1" ]]; then
log "DRY_RUN=1 — skip Gitea upload"
exit 0
fi
if [[ -z "${RELEASE_TOKEN:-}" ]]; then
log "ERROR: RELEASE_TOKEN is required (or DRY_RUN=1)"
exit 1
fi
if [[ -z "${GITEA_URL}" || -z "${GITEA_OWNER}" || -z "${GITEA_REPO}" ]]; then
log "ERROR: GITEA_URL / GITEA_OWNER / GITEA_REPO must be set"
exit 1
fi
API="${GITEA_URL%/}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}"
AUTH="Authorization: token ${RELEASE_TOKEN}"
log "Gitea API: $API tag=$TAG"
REL_JSON="$(curl -fsSL -H "$AUTH" "$API/releases/tags/${TAG}" 2>/dev/null || true)"
if [[ -n "$REL_JSON" ]]; then
REL_ID="$(node -e "try{const j=JSON.parse(process.argv[1]);console.log(j.id||'')}catch{console.log('')}" "$REL_JSON")"
if [[ -n "$REL_ID" ]]; then
log "deleting previous release id=$REL_ID"
curl -fsSL -X DELETE -H "$AUTH" "$API/releases/$REL_ID" >/dev/null || true
fi
fi
curl -fsSL -X DELETE -H "$AUTH" "$API/tags/${TAG}" >/dev/null 2>&1 || true
CREATE_BODY="$(node -e "
const notes=require('fs').readFileSync(process.argv[1],'utf8');
console.log(JSON.stringify({
tag_name: process.argv[2],
name: process.argv[3],
body: notes,
draft: false,
prerelease: true,
target_commitish: process.argv[4]
}));
" "$DIST/RELEASE_NOTES.md" "$TAG" "${RELEASE_TITLE} ${VERSION} ${SHA}" "$FULL_SHA")"
CREATE_RESP="$(curl -fsSL -X POST -H "$AUTH" -H 'Content-Type: application/json' \
-d "$CREATE_BODY" "$API/releases")"
REL_ID="$(node -e "console.log(JSON.parse(process.argv[1]).id)" "$CREATE_RESP")"
log "created release id=$REL_ID"
for f in "$DIST"/*; do
[[ -f "$f" ]] || continue
base="$(basename "$f")"
case "$base" in
*.tar.gz|*.sha256|*.md) ;;
*) continue ;;
esac
log "upload $base"
name_q="$(node -e "console.log(encodeURIComponent(process.argv[1]))" "$base")"
curl -fsSL -X POST -H "$AUTH" \
-F "attachment=@${f}" \
"$API/releases/${REL_ID}/assets?name=${name_q}" \
>/dev/null
done
log "release published: ${GITEA_URL}/${GITEA_OWNER}/${GITEA_REPO}/releases/tag/${TAG}"
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env node
/**
* Process supervision healthcheck.
* If PEARDATA_HEALTH_KEY is set, dials the server and pings.
* Otherwise exits 0 when the process can import server modules (liveness).
*
* Exit 0 = healthy, 1 = unhealthy.
*/
import { PearDataConnection } from '../client/connection.js'
const timeoutMs = Number(process.env.HEALTHCHECK_TIMEOUT_MS) || 8000
const publicKey = process.env.PEARDATA_HEALTH_KEY || process.env.SERVER_PUBLIC_KEY
const timer = setTimeout(() => {
console.error('healthcheck: timeout')
process.exit(1)
}, timeoutMs)
try {
if (!publicKey || !/^[0-9a-fA-F]{64}$/.test(publicKey)) {
// Liveness without remote dial
clearTimeout(timer)
console.log('ok liveness')
process.exit(0)
}
const conn = new PearDataConnection(publicKey, {
timeoutMs,
adminSeed: process.env.SERVER_SEED || null,
})
await conn.connect()
const pong = await conn.ping()
await conn.destroy()
clearTimeout(timer)
if (!pong?.ok) throw new Error('ping failed')
console.log('ok ping', pong.pong)
process.exit(0)
} catch (err) {
clearTimeout(timer)
console.error('healthcheck failed:', err.message)
process.exit(1)
}
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env node
/**
* Mint a pa1 invite from SERVER_SEED without starting the full server process.
*
* Usage:
* node scripts/mint-invite.js [role=operator] [ttlMs]
*/
import dotenv from 'dotenv'
import { loadOrCreateKeyPair } from '../server/core/keys.js'
import { signCapability, encodeInvite } from '../shared/crypto-auth.js'
dotenv.config()
const { publicKeyHex, seedHex } = loadOrCreateKeyPair()
const role = process.argv[2] || 'operator'
const ttlArg = process.argv[3]
const ttlMs = ttlArg === undefined ? null : Number(ttlArg)
const { token, payload } = signCapability(seedHex, {
role,
ttlMs,
forever: ttlMs == null || ttlMs === 0,
})
const invite = encodeInvite({
publicKeyHex,
capability: token,
role: payload.role,
jti: payload.jti,
expiresAt: payload.exp,
})
console.log(invite)
console.error(`# role=${payload.role} jti=${payload.jti} exp=${payload.exp ?? 'never'}`)
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
VERSION="$(node -p "require('./package.json').version")"
NAME="peardata-v${VERSION}"
mkdir -p dist
tar --exclude=node_modules --exclude=.git --exclude=data --exclude=dist \
-czf "dist/${NAME}.tar.gz" .
(
cd dist
if command -v sha256sum >/dev/null; then
sha256sum "${NAME}.tar.gz" > "${NAME}.tar.gz.sha256"
else
shasum -a 256 "${NAME}.tar.gz" > "${NAME}.tar.gz.sha256"
fi
)
cat > dist/RELEASE_NOTES.md <<EOF
# ${NAME}
HyperDHT + protomux-rpc application template.
## Contents
- Node server (\`npm run start:server\`)
- Pear desktop client (\`npm start\` / \`pear run -d .\`)
- Demo room (messages + presence + invites)
## Verify
\`\`\`
sha256sum -c ${NAME}.tar.gz.sha256
\`\`\`
EOF
ls -la dist
echo "Release artifacts ready in dist/"
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
# Rebrand peardata → your product name.
# Usage: bash scripts/rename-template.sh my-app MyApp
set -euo pipefail
OLD_SLUG="peardata"
OLD_NAME="peardata"
OLD_PRODUCT="PearData"
OLD_PROTOCOL="peardata/rpc"
OLD_PREFIX="pd1."
OLD_ENV="PEARDATA_"
NEW_SLUG="${1:-}"
NEW_PRODUCT="${2:-}"
if [[ -z "$NEW_SLUG" || -z "$NEW_PRODUCT" ]]; then
echo "Usage: $0 <slug> <ProductName>"
echo " example: $0 notes-mesh NotesMesh"
exit 1
fi
if [[ ! "$NEW_SLUG" =~ ^[a-z][a-z0-9-]*$ ]]; then
echo "slug must be lowercase alphanumeric + dashes"
exit 1
fi
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
# Derive invite prefix from first letters + 1.
PREFIX="$(echo "$NEW_SLUG" | tr -cd 'a-z' | cut -c1-2)1."
ENV_PREFIX="$(echo "$NEW_SLUG" | tr 'a-z-' 'A-Z_')_"
ENV_PREFIX="${ENV_PREFIX//__/_}"
echo "Renaming:"
echo " package: $OLD_NAME$NEW_SLUG"
echo " product: $OLD_PRODUCT$NEW_PRODUCT"
echo " protocol: $OLD_PROTOCOL${NEW_SLUG}/rpc"
echo " invite: $OLD_PREFIX$PREFIX"
echo " env: $OLD_ENV$ENV_PREFIX"
export LC_ALL=C
find . -type f \
\( -name '*.js' -o -name '*.mjs' -o -name '*.cjs' -o -name '*.json' -o -name '*.md' -o -name '*.html' -o -name '*.css' -o -name '*.yml' -o -name '*.yaml' -o -name '*.service' -o -name '*.example' -o -name '*.sh' \) \
! -path './node_modules/*' ! -path './.git/*' ! -path './data/*' \
-print0 | while IFS= read -r -d '' f; do
perl -pi -e "
s/\Q$OLD_NAME\E/$NEW_SLUG/g;
s/\Q$OLD_PRODUCT\E/$NEW_PRODUCT/g;
s/\Q$OLD_PROTOCOL\E/${NEW_SLUG}\\/rpc/g;
s/\Q$OLD_SLUG\E/$NEW_SLUG/g;
s/\Q$OLD_PREFIX\E/$PREFIX/g;
s/\Q$OLD_ENV\E/$ENV_PREFIX/g;
" "$f"
done
if [[ -f bin/peardata-server.mjs ]]; then
mv bin/peardata-server.mjs "bin/${NEW_SLUG}-server.mjs"
fi
if [[ -f deploy/peardata.service ]]; then
mv deploy/peardata.service "deploy/${NEW_SLUG}.service"
fi
echo "Done. Review git diff, then: npm install && npm test"
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env node
/**
* Soak test: connect, post messages, verify pushes for SOAK_DURATION_MS.
*
* Usage:
* SERVER_PUBLIC_KEY=… SERVER_SEED=… node scripts/soak.js
*/
import { PearDataConnection } from '../client/connection.js'
import { Methods, Pushes } from '../shared/protocol.js'
const duration = Number(process.env.SOAK_DURATION_MS) || 60_000
const publicKey = process.env.SERVER_PUBLIC_KEY
const seed = process.env.SERVER_SEED
if (!publicKey) {
console.error('SERVER_PUBLIC_KEY required')
process.exit(1)
}
const conn = new PearDataConnection(publicKey, {
adminSeed: seed || null,
timeoutMs: 30_000,
})
let received = 0
let sent = 0
let errors = 0
conn.on(Pushes.message, () => {
received++
})
await conn.connect()
console.log('soak connected as', conn.role)
const end = Date.now() + duration
while (Date.now() < end) {
try {
await conn.request(Methods.postMessage, {
text: `soak ${sent} @ ${new Date().toISOString()}`,
})
sent++
await conn.ping()
} catch (err) {
errors++
console.error('soak error', err.message)
}
await new Promise((r) => setTimeout(r, Number(process.env.SOAK_INTERVAL_MS) || 500))
}
await conn.destroy()
console.log(JSON.stringify({ duration, sent, received, errors }, null, 2))
process.exit(errors > 0 ? 1 : 0)
+76
View File
@@ -0,0 +1,76 @@
/**
* Capability / role ACL for RPC methods.
*
* Secure default: every peer is viewer (read-only).
* Elevate via:
* - admin seed HMAC proof (handshake)
* - HMAC capability grant (pa1 invite)
* - PEARDATA_ADMIN_KEYS peer allowlist
* - peer policy registered role
* - PEARDATA_INSECURE_OPEN_ADMIN=1 (dev escape hatch)
*/
import { Roles, roleAllows, MethodRoles } from '../../shared/protocol.js'
import { resolvePeerRole } from './peer-policy.js'
import { isInsecureOpenAdmin } from '../../shared/crypto-auth.js'
const DEFAULT_ROLE = (process.env.PEARDATA_DEFAULT_ROLE || Roles.viewer).toLowerCase()
const ADMIN_KEYS = new Set(
(process.env.PEARDATA_ADMIN_KEYS || '')
.split(',')
.map((s) => s.trim().toLowerCase())
.filter(Boolean)
)
/**
* @param {string} peerIdHex
*/
export function resolveRole(peerIdHex) {
const id = (peerIdHex || '').toLowerCase()
if (isInsecureOpenAdmin()) {
try {
return resolvePeerRole(id, Roles.admin)
} catch {
return Roles.admin
}
}
let envRole = Roles.viewer
if (ADMIN_KEYS.size > 0) {
envRole = ADMIN_KEYS.has(id) ? Roles.admin : DEFAULT_ROLE
} else if ([Roles.viewer, Roles.operator, Roles.admin].includes(DEFAULT_ROLE)) {
envRole = DEFAULT_ROLE
}
if (envRole === Roles.admin && ADMIN_KEYS.size === 0 && DEFAULT_ROLE !== Roles.admin) {
envRole = Roles.viewer
}
try {
return resolvePeerRole(id, envRole)
} catch {
return envRole
}
}
/**
* @param {string} role
* @param {string} method
*/
export function assertAllowed(role, method) {
if (!roleAllows(role, MethodRoles[method] || Roles.admin)) {
const need = MethodRoles[method] || Roles.admin
const err = new Error(`Permission denied: ${method} requires role "${need}" (have "${role}")`)
err.code = 'PERMISSION_DENIED'
throw err
}
}
/**
* @param {string} a
* @param {string} b
*/
export function maxRole(a, b) {
const rank = { [Roles.viewer]: 1, [Roles.operator]: 2, [Roles.admin]: 3 }
return (rank[a] || 0) >= (rank[b] || 0) ? a : b
}
+47
View File
@@ -0,0 +1,47 @@
/**
* Append-only audit log for sensitive RPCs.
*/
import fs from 'fs'
import path from 'path'
const MUTATING = new Set([
'postMessage',
'clearMessages',
'mintInvite',
'revokePeer',
'handshake',
])
function auditPath() {
const dir = process.env.PEARDATA_DATA_DIR || path.resolve('data')
return path.join(dir, 'audit.log')
}
/**
* @param {string} method
*/
export function shouldAudit(method) {
return MUTATING.has(method)
}
/**
* @param {{ method: string, peerId: string, role: string, ok: boolean, error?: string, args?: object, force?: boolean }} entry
*/
export function audit(entry) {
if (!entry.force && !shouldAudit(entry.method)) return
const line = JSON.stringify({
ts: new Date().toISOString(),
method: entry.method,
peerId: String(entry.peerId || '').slice(0, 16),
role: entry.role,
ok: entry.ok,
error: entry.error || null,
})
try {
const dir = path.dirname(auditPath())
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
fs.appendFileSync(auditPath(), line + '\n', { mode: 0o600 })
} catch {
// never break RPC on audit failure
}
}
+30
View File
@@ -0,0 +1,30 @@
/**
* Server MAC key derived from SERVER_SEED for capabilities + admin proofs.
*/
import { deriveMacKey } from '../../shared/crypto-auth.js'
let macKey = null
let serverPublicKeyHex = null
let seedHex = null
/**
* @param {{ seedHex: string, publicKeyHex: string }} opts
*/
export function initAuthKeys(opts) {
seedHex = opts.seedHex
serverPublicKeyHex = opts.publicKeyHex
macKey = deriveMacKey(seedHex)
}
export function getMacKey() {
if (!macKey) throw new Error('Auth keys not initialized')
return macKey
}
export function getServerPublicKeyHex() {
return serverPublicKeyHex
}
export function getSeedHex() {
return seedHex
}
+64
View File
@@ -0,0 +1,64 @@
/**
* Persistent HyperDHT keypair management.
* SERVER_SEED (32-byte hex) is the secret seed.
* Clients connect using the derived public key.
*/
import fs from 'fs'
import path from 'path'
import DHT from 'hyperdht'
import b4a from 'b4a'
import crypto from 'hypercore-crypto'
import dotenv from 'dotenv'
import logger from '../utils/logger.js'
dotenv.config()
const log = logger.child('keys')
/**
* @param {string} [envPath]
*/
export function loadOrCreateKeyPair(envPath = '.env') {
let seedHex = process.env.SERVER_SEED || process.env.SERVER_KEY
if (!seedHex) {
const seed = crypto.randomBytes(32)
seedHex = b4a.toString(seed, 'hex')
const publicKeyHex = b4a.toString(DHT.keyPair(seed).publicKey, 'hex')
const line = `\nSERVER_SEED=${seedHex}\nSERVER_PUBLIC_KEY=${publicKeyHex}\n`
fs.appendFileSync(envPath, line, { flag: 'a' })
log.info('Generated new SERVER_SEED and SERVER_PUBLIC_KEY', {
path: path.resolve(envPath),
})
}
if (!/^[0-9a-fA-F]{64}$/.test(seedHex)) {
throw new Error('SERVER_SEED must be 64 hex characters (32 bytes)')
}
const seed = b4a.from(seedHex, 'hex')
const keyPair = DHT.keyPair(seed)
const publicKeyHex = b4a.toString(keyPair.publicKey, 'hex')
if (process.env.SERVER_PUBLIC_KEY !== publicKeyHex) {
try {
let env = fs.existsSync(envPath) ? fs.readFileSync(envPath, 'utf8') : ''
if (env.includes('SERVER_PUBLIC_KEY=')) {
env = env.replace(/SERVER_PUBLIC_KEY=.*/g, `SERVER_PUBLIC_KEY=${publicKeyHex}`)
} else {
env += `\nSERVER_PUBLIC_KEY=${publicKeyHex}\n`
}
if (!env.includes('SERVER_SEED=')) {
env += `SERVER_SEED=${seedHex}\n`
}
fs.writeFileSync(envPath, env)
} catch (err) {
log.warn('Could not update .env with SERVER_PUBLIC_KEY', { error: err.message })
}
}
process.env.SERVER_SEED = seedHex
process.env.SERVER_PUBLIC_KEY = publicKeyHex
return { seed, keyPair, publicKeyHex, seedHex }
}
+178
View File
@@ -0,0 +1,178 @@
/**
* Peer roles, revocations, capability spend tracking.
* File-backed under PEARDATA_DATA_DIR (default ./data).
*/
import fs from 'fs'
import path from 'path'
import { Roles } from '../../shared/protocol.js'
import { verifyCapability } from '../../shared/crypto-auth.js'
import { getMacKey } from './auth-keys.js'
import logger from '../utils/logger.js'
const log = logger.child('peer-policy')
function dataDir() {
return process.env.PEARDATA_DATA_DIR || path.resolve('data')
}
function policyPath() {
return path.join(dataDir(), 'peer-policy.json')
}
/** @type {{ peers: Record<string, { role: string, displayName?: string, firstSeen: string, lastSeen: string }>, revoked: string[], spentJti: string[] }} */
let state = { peers: {}, revoked: [], spentJti: [] }
export function loadPeerPolicy() {
try {
const p = policyPath()
if (fs.existsSync(p)) {
const raw = JSON.parse(fs.readFileSync(p, 'utf8'))
state = {
peers: raw.peers || {},
revoked: Array.isArray(raw.revoked) ? raw.revoked : [],
spentJti: Array.isArray(raw.spentJti) ? raw.spentJti.slice(-5000) : [],
}
log.info('Loaded peer policy', {
peers: Object.keys(state.peers).length,
revoked: state.revoked.length,
})
}
} catch (err) {
log.warn('Failed to load peer policy', { error: err.message })
}
}
function save() {
try {
const dir = dataDir()
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
fs.writeFileSync(policyPath(), JSON.stringify(state, null, 2), { mode: 0o600 })
} catch (err) {
log.warn('Failed to save peer policy', { error: err.message })
}
}
/**
* @param {string} peerId
* @param {string} fallback
*/
export function resolvePeerRole(peerId, fallback) {
const id = String(peerId || '').toLowerCase()
const entry = state.peers[id]
if (entry?.role) return entry.role
return fallback
}
/**
* @param {string} peerId
*/
export function getPeerEntry(peerId) {
return state.peers[String(peerId || '').toLowerCase()] || null
}
/**
* @param {string} peerId
* @param {{ role: string, displayName?: string }} info
*/
export function registerPeer(peerId, info) {
const id = String(peerId || '').toLowerCase()
const now = new Date().toISOString()
const prev = state.peers[id]
state.peers[id] = {
role: info.role,
displayName: info.displayName || prev?.displayName,
firstSeen: prev?.firstSeen || now,
lastSeen: now,
}
save()
}
/**
* @param {string} peerId
* @param {string} [displayName]
*/
export function touchPeer(peerId, displayName) {
const id = String(peerId || '').toLowerCase()
const prev = state.peers[id]
if (!prev) return
prev.lastSeen = new Date().toISOString()
if (displayName) prev.displayName = displayName
save()
}
/**
* @param {string} peerId
*/
export function isPeerRevoked(peerId) {
return state.revoked.includes(String(peerId || '').toLowerCase())
}
/**
* @param {string} peerId
* @param {{ authMode?: string }} [opts]
*/
export function isPeerAllowed(peerId, opts = {}) {
const id = String(peerId || '').toLowerCase()
if (isPeerRevoked(id)) return false
const allow = (process.env.PEARDATA_ALLOWLIST || '')
.split(',')
.map((s) => s.trim().toLowerCase())
.filter(Boolean)
if (allow.length === 0) return true
// Seed/capability auth can onboard onto allowlist environments
if (opts.authMode === 'seed' || opts.authMode === 'capability') return true
return allow.includes(id) || Boolean(state.peers[id])
}
/**
* @param {string} peerId
*/
export function revokePeer(peerId) {
const id = String(peerId || '').toLowerCase()
if (!state.revoked.includes(id)) state.revoked.push(id)
delete state.peers[id]
save()
}
/**
* @param {string} token
* @param {string} peerId
* @returns {{ role: string, reconnected: boolean }}
*/
export function redeemCapability(token, peerId) {
const spent = new Set(state.spentJti)
const res = verifyCapability(getMacKey(), token, {
peerId,
allowSpentCheck: (jti) => {
// Already-registered peers may reconnect with same grant
if (spent.has(jti) && getPeerEntry(peerId)?.role) return true
return !spent.has(jti)
},
})
if (!res.ok) {
const err = new Error(res.error)
err.code = res.code
throw err
}
const jti = res.payload.jti
const existing = getPeerEntry(peerId)
const reconnected = Boolean(existing?.role) && spent.has(jti)
if (!spent.has(jti)) {
state.spentJti.push(jti)
if (state.spentJti.length > 5000) state.spentJti = state.spentJti.slice(-4000)
}
registerPeer(peerId, { role: res.payload.role })
save()
return { role: res.payload.role, reconnected }
}
export function listPolicyPeers() {
return Object.entries(state.peers).map(([id, p]) => ({
peerId: id,
...p,
revoked: isPeerRevoked(id),
}))
}
+47
View File
@@ -0,0 +1,47 @@
/**
* Live connected peer sessions.
*/
/** @type {Map<string, import('../rpc/session.js').PeerSession>} */
const byId = new Map()
export const peers = {
/**
* @param {import('../rpc/session.js').PeerSession} session
*/
add(session) {
byId.set(session.id, session)
},
/**
* @param {string} id
*/
remove(id) {
byId.delete(id)
},
/**
* @param {string} id
*/
get(id) {
return byId.get(id) || null
},
list() {
return [...byId.values()]
},
size() {
return byId.size
},
/**
* Push event to every connected peer.
* @param {string} event
* @param {unknown} payload
*/
broadcast(event, payload) {
for (const s of byId.values()) {
try {
s.push(event, payload)
} catch {
// ignore
}
}
},
}
+213
View File
@@ -0,0 +1,213 @@
/**
* PearMonitor agent RPC handlers — metrics, anomalies, alerts, jobs, ACL.
*/
import os from 'os'
import {
APP_NAME,
APP_VERSION,
PROTOCOL,
PROTOCOL_VERSION,
Roles,
} from '../../shared/protocol.js'
import { SCHEMA_VERSION } from '../../shared/schema.js'
import { CHART_DEFS, CHART_BY_ID, CONTEXT_IDS, chartSummary } from '../../shared/metrics.js'
import { peers } from '../core/peer-registry.js'
import {
listPolicyPeers,
revokePeer as policyRevoke,
touchPeer,
} from '../core/peer-policy.js'
import { signCapability, encodeInvite } from '../../shared/crypto-auth.js'
import { getMacKey, getServerPublicKeyHex } from '../core/auth-keys.js'
import { getCollector } from '../services/collector.js'
import { getStore } from '../services/store.js'
import { getAnomalyEngine } from '../services/anomaly.js'
import {
listAlerts,
getAlert,
setAlertConfig,
ackAlert,
silenceAlert,
} from '../services/alerts.js'
import {
subscribeMetrics,
unsubscribeMetrics,
subscribeAnomalies,
unsubscribeAnomalies,
} from '../services/subscriptions.js'
import { getJobs, knownJobNames } from '../services/jobs.js'
import { formatAllMetrics } from '../rest/formatters.js'
/**
* @param {import('../rpc/session.js').PeerSession} session
*/
export function registerMonitorHandlers(session) {
const collector = getCollector()
const store = getStore()
const anomalies = getAnomalyEngine(os.cpus().length)
session.respond('ping', async () => ({
ok: true,
pong: Date.now(),
peerId: session.id,
}), { hot: true })
session.respond('getServerInfo', async () => ({
app: APP_NAME,
version: APP_VERSION,
protocol: PROTOCOL,
protocolVersion: PROTOCOL_VERSION,
schemaVersion: SCHEMA_VERSION,
publicKeyHex: getServerPublicKeyHex(),
hostname: os.hostname(),
platform: `${os.platform()}/${os.arch()}`,
uptimeSec: Math.floor(process.uptime()),
connectedPeers: peers.size(),
node: process.version,
role: 'agent',
charts: CHART_DEFS.length,
}))
session.respond('getAuthStatus', async () => ({
peerId: session.id,
role: session.role,
authMode: session.authMode,
displayName: session.displayName,
}))
session.respond('setDisplayName', async (args, s) => {
s.displayName = args.name
touchPeer(s.id, args.name)
return { success: true, displayName: args.name }
})
session.respond('getNodeInfo', async () =>
collector.getNodeInfo(getServerPublicKeyHex(), APP_VERSION)
)
session.respond('getHealth', async () => anomalies.getHealth())
session.respond('listContexts', async () => ({
contexts: CONTEXT_IDS.map((id) => {
const charts = CHART_DEFS.filter((c) => c.context === id)
return {
id,
family: charts[0]?.family || id.split('.')[0],
title: charts[0]?.title || id,
charts: charts.map((c) => c.id),
}
}),
}))
session.respond('getContext', async (args) => {
const charts = CHART_DEFS.filter((c) => c.context === args.id || c.id === args.id)
if (!charts.length) return { error: 'unknown context', id: args.id }
return {
id: args.id,
charts: charts.map((c) => store.getMeta(c.id) || chartSummary(c)),
}
})
session.respond('listCharts', async () => ({
charts: store.listChartSummaries(),
hostname: os.hostname(),
version: APP_VERSION,
}))
session.respond('getChart', async (args) => {
const meta = store.getMeta(args.id)
if (!meta) {
const def = CHART_BY_ID.get(args.id)
if (!def) return { error: 'unknown chart', id: args.id }
return chartSummary(def)
}
return meta
})
session.respond('queryData', async (args) => store.query(args), { hot: true })
session.respond('getAllMetrics', async (args) => formatAllMetrics(args.format || 'json'))
session.respond('subscribeMetrics', async (args, s) => subscribeMetrics(s, args), {
hot: true,
})
session.respond('unsubscribeMetrics', async (_a, s) => unsubscribeMetrics(s), { hot: true })
session.respond('subscribeAnomalies', async (_a, s) => subscribeAnomalies(s))
session.respond('unsubscribeAnomalies', async (_a, s) => unsubscribeAnomalies(s))
session.respond('listAnomalies', async (args) => ({
anomalies: anomalies.listRecent(args?.limit || 50),
}))
session.respond('listAlerts', async () => ({ alerts: listAlerts() }))
session.respond('getAlert', async (args) => {
const a = getAlert(args.id)
return a || { error: 'unknown alert', id: args.id }
})
session.respond('setAlertConfig', async (args) => ({
success: true,
config: setAlertConfig(args),
}))
session.respond('ackAlert', async (args) => ackAlert(args.id))
session.respond('silenceAlert', async (args) => silenceAlert(args.id, args))
session.respond('listJobs', async () => ({
jobs: getJobs().list(),
known: knownJobNames(),
}))
session.respond('runJob', async (args) => getJobs().run(args.name, args.args || {}))
session.respond('cancelJob', async (args) => getJobs().cancel(args.id))
session.respond('mintInvite', async (args) => {
const role = args.role || Roles.operator
const ttlMs = args.ttlMs === undefined ? null : args.ttlMs
const { token, payload } = signCapability(getMacKey(), {
role,
ttlMs,
forever: ttlMs == null || ttlMs === 0,
peerId: args.peerId || null,
})
const invite = encodeInvite({
publicKeyHex: getServerPublicKeyHex(),
capability: token,
role: payload.role,
jti: payload.jti,
alias: args.alias || null,
expiresAt: payload.exp,
})
return {
success: true,
invite,
capability: token,
role: payload.role,
jti: payload.jti,
exp: payload.exp,
}
})
session.respond('listPeers', async () => ({
connected: peers.list().map((s) => ({
peerId: s.id,
role: s.role,
displayName: s.displayName,
authMode: s.authMode,
})),
known: listPolicyPeers(),
}))
session.respond('revokePeer', async (args) => {
policyRevoke(args.peerId)
const live = peers.get(args.peerId)
if (live) live.destroy()
return { success: true, peerId: args.peerId }
})
session.respond('exportSnapshot', async () => ({
success: true,
node: collector.getNodeInfo(getServerPublicKeyHex(), APP_VERSION),
latest: store.latestValues(),
health: anomalies.getHealth(),
alerts: listAlerts(),
ts: Date.now(),
}))
}
+52
View File
@@ -0,0 +1,52 @@
/**
* Agent data pipeline: collector → store → anomaly → push fan-out.
*/
import os from 'os'
import { getCollector } from './services/collector.js'
import { getStore } from './services/store.js'
import { getAnomalyEngine } from './services/anomaly.js'
import {
broadcastMetrics,
broadcastAnomaly,
broadcastHealth,
} from './services/subscriptions.js'
import { Pushes } from '../shared/protocol.js'
import { peers } from './core/peer-registry.js'
import logger from './utils/logger.js'
const log = logger.child('pipeline')
let healthEvery = 0
export function startPipeline() {
const collector = getCollector()
const store = getStore()
const anomalies = getAnomalyEngine(os.cpus().length)
collector.on('samples', (batch) => {
store.ingest(batch)
broadcastMetrics(batch)
const fired = anomalies.evaluate(batch)
for (const ev of fired) {
broadcastAnomaly(ev)
if (!ev.cleared) {
peers.broadcast(Pushes.alert, {
id: ev.id,
status: ev.severity === 'critical' ? 'CRITICAL' : 'WARNING',
...ev,
})
}
}
// periodic health push (~15s)
healthEvery++
if (healthEvery >= 15) {
healthEvery = 0
broadcastHealth(anomalies.getHealth())
}
})
collector.start()
log.info('Metrics pipeline started')
return { collector, store, anomalies }
}
+65
View File
@@ -0,0 +1,65 @@
/**
* Export formatters (JSON / Prometheus / shell) for /api/v*/allmetrics.
*/
import os from 'os'
import { getStore } from '../services/store.js'
import { CHART_BY_ID } from '../../shared/metrics.js'
import { APP_NAME, APP_VERSION } from '../../shared/protocol.js'
/**
* @param {'json'|'prometheus'|'shell'} format
*/
export function formatAllMetrics(format = 'json') {
const latest = getStore().latestValues()
if (format === 'prometheus') return { contentType: 'text/plain; version=0.0.4', body: toPrometheus(latest) }
if (format === 'shell') return { contentType: 'text/plain', body: toShell(latest) }
return {
contentType: 'application/json',
body: {
hostname: os.hostname(),
app: APP_NAME,
version: APP_VERSION,
charts: Object.fromEntries(
Object.entries(latest).map(([chart, point]) => [
chart,
{
name: chart,
context: CHART_BY_ID.get(chart)?.context || chart,
last_updated: Math.floor(point.ts / 1000),
dimensions: point.values,
},
])
),
},
}
}
/**
* @param {Record<string, { ts: number, values: Record<string, number|null> }>} latest
*/
function toPrometheus(latest) {
const lines = [`# HELP peardata_info PearData agent info`, `# TYPE peardata_info gauge`]
lines.push(`peardata_info{version="${APP_VERSION}",hostname="${os.hostname()}"} 1`)
for (const [chart, point] of Object.entries(latest)) {
const metric = chart.replace(/\./g, '_')
for (const [dim, val] of Object.entries(point.values)) {
if (val == null || Number.isNaN(val)) continue
lines.push(`${metric}{dimension="${dim}"} ${val}`)
}
}
return lines.join('\n') + '\n'
}
/**
* @param {Record<string, { ts: number, values: Record<string, number|null> }>} latest
*/
function toShell(latest) {
const lines = []
for (const [chart, point] of Object.entries(latest)) {
for (const [dim, val] of Object.entries(point.values)) {
if (val == null || Number.isNaN(val)) continue
lines.push(`NETDATA_${chart.replace(/\./g, '_').toUpperCase()}_${dim.toUpperCase()}="${val}"`)
}
}
return lines.join('\n') + '\n'
}
+88
View File
@@ -0,0 +1,88 @@
/**
* Optional Netdata-style HTTP API bound to the PearMonitor agent.
*
* Default: 127.0.0.1:19999 (Netdata's classic port) — local only.
* Disable with PEARDATA_REST=0
*/
import http from 'http'
import { handleRest } from './routes.js'
import logger from '../utils/logger.js'
const log = logger.child('rest')
/**
* @returns {http.Server|null}
*/
export function startRestServer() {
if (process.env.PEARDATA_REST === '0' || process.env.PEARDATA_REST === 'off') {
log.info('REST API disabled (PEARDATA_REST=0)')
return null
}
const host = process.env.PEARDATA_REST_HOST || '127.0.0.1'
const port = Number(process.env.PEARDATA_REST_PORT) || 19999
const server = http.createServer((req, res) => {
try {
const url = new URL(req.url || '/', `http://${host}:${port}`)
if (req.method !== 'GET' && req.method !== 'HEAD' && req.method !== 'OPTIONS') {
res.writeHead(405, { 'content-type': 'application/json', allow: 'GET, HEAD, OPTIONS' })
res.end(JSON.stringify({ error: 'method not allowed' }))
return
}
if (req.method === 'OPTIONS') {
res.writeHead(204, corsHeaders())
res.end()
return
}
const result = handleRest(url.pathname, url.searchParams)
const headers = {
...corsHeaders(),
'content-type': result.contentType || 'application/json',
'cache-control': 'no-cache',
'x-peardata-api': 'v3',
}
const body =
typeof result.body === 'string' ? result.body : JSON.stringify(result.body, null, 0)
if (req.method === 'HEAD') {
headers['content-length'] = Buffer.byteLength(body)
res.writeHead(result.status || 200, headers)
res.end()
return
}
res.writeHead(result.status || 200, headers)
res.end(body)
} catch (err) {
log.error('REST handler error', { error: err.message })
res.writeHead(500, { 'content-type': 'application/json' })
res.end(JSON.stringify({ error: 'internal error' }))
}
})
server.listen(port, host, () => {
log.info('REST API listening', {
url: `http://${host}:${port}`,
examples: [
`http://${host}:${port}/api/v3/info`,
`http://${host}:${port}/api/v3/data?chart=system.cpu&after=-60&points=60`,
`http://${host}:${port}/api/v1/charts`,
],
})
})
server.on('error', (err) => {
log.error('REST server error', { error: err.message })
})
return server
}
function corsHeaders() {
return {
'access-control-allow-origin': process.env.PEARDATA_REST_CORS || '*',
'access-control-allow-methods': 'GET, HEAD, OPTIONS',
'access-control-allow-headers': 'Content-Type, Authorization',
}
}
+320
View File
@@ -0,0 +1,320 @@
/**
* Netdata-compatible REST route handlers (v1 + v2 + v3).
*
* Local-agent style GET endpoints. Not a byte-for-byte clone of every
* Netdata field, but intentional compatibility for charts/data/contexts/nodes/info/allmetrics.
*/
import os from 'os'
import { APP_NAME, APP_VERSION, PROTOCOL, PROTOCOL_VERSION } from '../../shared/protocol.js'
import { CHART_DEFS, CHART_BY_ID, CONTEXT_IDS, chartSummary } from '../../shared/metrics.js'
import { getStore } from '../services/store.js'
import { getCollector } from '../services/collector.js'
import { getAnomalyEngine } from '../services/anomaly.js'
import { listAlerts, getAlert } from '../services/alerts.js'
import { getServerPublicKeyHex } from '../core/auth-keys.js'
import { formatAllMetrics } from './formatters.js'
import { peers } from '../core/peer-registry.js'
/**
* @param {string} pathname
* @param {URLSearchParams} query
* @returns {{ status: number, contentType: string, body: any }}
*/
export function handleRest(pathname, query) {
const path = pathname.replace(/\/+$/, '') || '/'
// ── info / versions ──────────────────────────────────────
if (path === '/api/v1/info' || path === '/api/v2/info' || path === '/api/v3/info') {
return json(infoPayload())
}
if (path === '/api/v3/versions') {
return json({
agent: APP_VERSION,
protocol: PROTOCOL,
protocolVersion: PROTOCOL_VERSION,
api: ['v1', 'v2', 'v3'],
node: process.version,
})
}
if (path === '/api/v3/me') {
return json({
authenticated: false,
role: 'anonymous-rest',
note: 'REST is local by default; P2P uses pubkey/invite roles',
})
}
// ── nodes ────────────────────────────────────────────────
if (path === '/api/v2/nodes' || path === '/api/v3/nodes') {
const node = nodePayload()
return json({ nodes: [node], ...([node][0] && {}) })
}
if (path === '/api/v3/node_instances') {
return json({ nodes: [nodePayload()] })
}
// ── contexts ─────────────────────────────────────────────
if (path === '/api/v2/contexts' || path === '/api/v3/contexts') {
return json({
contexts: Object.fromEntries(
CONTEXT_IDS.map((id) => {
const charts = CHART_DEFS.filter((c) => c.context === id)
return [
id,
{
family: charts[0]?.family,
title: charts[0]?.title,
units: charts[0]?.units,
charts: charts.map((c) => c.id),
},
]
})
),
})
}
if (path === '/api/v3/context' || path === '/api/v2/context') {
const id = query.get('context') || query.get('id') || ''
const charts = CHART_DEFS.filter((c) => c.context === id || c.id === id)
if (!charts.length) return err(404, 'unknown context')
return json({
id,
charts: charts.map((c) => getStore().getMeta(c.id) || chartSummary(c)),
})
}
// ── charts (v1 legacy + still useful) ────────────────────
if (path === '/api/v1/charts') {
return json({
hostname: os.hostname(),
version: APP_VERSION,
os: `${os.platform()} ${os.release()}`,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
history: Number(process.env.PEARDATA_TIER0_POINTS) || 3600,
update_every: 1,
charts: getStore().listChartSummaries(),
})
}
if (path === '/api/v1/chart') {
const id = query.get('chart') || ''
const meta = getStore().getMeta(id) || (CHART_BY_ID.has(id) ? chartSummary(CHART_BY_ID.get(id)) : null)
if (!meta) return err(404, 'unknown chart')
return json(meta)
}
// ── data queries ─────────────────────────────────────────
if (
path === '/api/v1/data' ||
path === '/api/v2/data' ||
path === '/api/v3/data'
) {
const chart = query.get('chart') || query.get('context') || query.get('scopes') || ''
if (!chart) return err(400, 'chart or context required')
// scopes may be comma-separated contexts — take first for MVP
const chartId = chart.split(',')[0].trim()
const resolved = CHART_BY_ID.has(chartId)
? chartId
: CHART_DEFS.find((c) => c.context === chartId)?.id || chartId
const result = getStore().query({
chart: resolved,
after: num(query.get('after'), -60),
before: num(query.get('before'), 0),
points: num(query.get('points'), 60),
group: query.get('group') || query.get('time_group') || 'average',
tier: num(query.get('tier'), 0),
})
if (result.error) return err(404, result.error)
const format = query.get('format') || 'json'
if (format === 'csv') {
const lines = [result.labels.join(',')]
for (const row of result.data) lines.push(row.join(','))
return { status: 200, contentType: 'text/csv', body: lines.join('\n') + '\n' }
}
if (format === 'array') {
return json(result.data)
}
return json(result)
}
// ── weights / q (stubs with useful MVP behavior) ─────────
if (path === '/api/v3/weights' || path === '/api/v2/weights') {
const health = getAnomalyEngine().getHealth()
return json({
status: health.status,
score: health.score,
results: health.checks.map((c) => ({
id: c.id,
weight: c.ok ? 0 : 1,
info: c.detail,
})),
})
}
if (path === '/api/v3/q' || path === '/api/v2/q') {
const q = (query.get('q') || query.get('query') || '').toLowerCase()
const hits = CHART_DEFS.filter(
(c) =>
!q ||
c.id.includes(q) ||
c.title.toLowerCase().includes(q) ||
c.context.includes(q) ||
c.family.includes(q)
).map((c) => ({ type: 'chart', id: c.id, title: c.title, context: c.context }))
return json({ results: hits, q })
}
// ── alerts ───────────────────────────────────────────────
if (
path === '/api/v1/alarms' ||
path === '/api/v2/alerts' ||
path === '/api/v3/alerts'
) {
return json({ alerts: listAlerts(), alarms: listAlerts() })
}
if (path === '/api/v1/alarm_variables' || path === '/api/v3/variable') {
const id = query.get('alarm') || query.get('alert') || query.get('name') || ''
const a = id ? getAlert(id) : null
return json(a || { alerts: listAlerts() })
}
if (path === '/api/v3/alert_transitions') {
return json({ transitions: getAnomalyEngine().listRecent(100) })
}
if (path === '/api/v3/alert_config') {
return json({ alerts: getAnomalyEngine().listConfigs() })
}
// ── allmetrics export ────────────────────────────────────
if (
path === '/api/v1/allmetrics' ||
path === '/api/v2/allmetrics' ||
path === '/api/v3/allmetrics'
) {
const format = (query.get('format') || 'json').toLowerCase()
const out = formatAllMetrics(format)
return {
status: 200,
contentType: out.contentType,
body: typeof out.body === 'string' ? out.body : out.body,
}
}
// ── badge ────────────────────────────────────────────────
if (path === '/api/v1/badge.svg' || path === '/api/v3/badge.svg') {
const chart = query.get('chart') || 'system.cpu'
const dim = query.get('dimensions') || query.get('dimension') || 'user'
const latest = getStore().latestValues()[chart]
const val = latest?.values?.[dim]
const label = query.get('label') || `${chart}.${dim}`
const text = val == null ? 'n/a' : String(Math.round(val * 100) / 100)
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="160" height="20">
<rect width="160" height="20" fill="#555"/>
<rect x="90" width="70" height="20" fill="#4c1"/>
<text x="45" y="14" fill="#fff" font-family="sans-serif" font-size="11" text-anchor="middle">${escapeXml(label)}</text>
<text x="125" y="14" fill="#fff" font-family="sans-serif" font-size="11" text-anchor="middle">${escapeXml(text)}</text>
</svg>`
return { status: 200, contentType: 'image/svg+xml', body: svg }
}
// ── functions / settings stubs ───────────────────────────
if (path === '/api/v3/functions' || path === '/api/v2/functions') {
return json({ functions: [{ name: 'collectOnce' }, { name: 'snapshot' }, { name: 'gcBuffers' }] })
}
if (path === '/api/v3/settings' || path === '/api/v3/config') {
return json({
sample_ms: Number(process.env.PEARDATA_SAMPLE_MS) || 1000,
rest_host: process.env.PEARDATA_REST_HOST || '127.0.0.1',
rest_port: Number(process.env.PEARDATA_REST_PORT) || 19999,
tier0_points: Number(process.env.PEARDATA_TIER0_POINTS) || 3600,
})
}
if (path === '/api/v3/stream_path') {
return json({
path: [
{
node: getServerPublicKeyHex(),
hostname: os.hostname(),
hops: 0,
role: 'agent',
},
],
})
}
// ── health / root ────────────────────────────────────────
if (path === '/api/v1/health' || path === '/health' || path === '/api/v3/health') {
return json(getAnomalyEngine().getHealth())
}
if (path === '/' || path === '/api') {
return json({
name: APP_NAME,
version: APP_VERSION,
apis: ['/api/v1', '/api/v2', '/api/v3'],
docs: 'See docs/REST-API.md',
p2p: { protocol: PROTOCOL, publicKeyHex: getServerPublicKeyHex() },
})
}
return err(404, `not found: ${path}`)
}
function infoPayload() {
const c = getCollector()
return {
version: APP_VERSION,
uid: getServerPublicKeyHex(),
mirrored_hosts: [os.hostname()],
mirrored_hosts_status: [{ hostname: os.hostname(), reachable: true }],
alarms: { normal: 0, warning: 0, critical: 0 },
os_name: os.platform(),
os_id: os.release(),
cores_total: os.cpus().length,
total_ram: os.totalmem(),
hostname: os.hostname(),
collected: c.sampleCount,
update_every: 1,
peers_connected: peers.size(),
peardata: {
protocol: PROTOCOL,
protocolVersion: PROTOCOL_VERSION,
publicKeyHex: getServerPublicKeyHex(),
},
}
}
function nodePayload() {
const pk = getServerPublicKeyHex()
return {
nm: os.hostname(),
nd: pk?.slice(0, 16),
guid: pk,
hw: {
cpu_cores: os.cpus().length,
ram_total: os.totalmem(),
},
os: {
id: os.platform(),
nm: os.release(),
},
st: 'online',
}
}
function json(body) {
return { status: 200, contentType: 'application/json', body }
}
function err(status, message) {
return { status, contentType: 'application/json', body: { error: message, status } }
}
function num(v, fallback) {
if (v == null || v === '') return fallback
const n = Number(v)
return Number.isFinite(n) ? n : fallback
}
function escapeXml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
+20
View File
@@ -0,0 +1,20 @@
/**
* Wire all RPC handlers onto a PeerSession.
*/
import { registerHandshake } from './session.js'
import { registerMonitorHandlers } from '../handlers/monitor.js'
/**
* @param {import('./session.js').PeerSession} session
*/
export function registerAllHandlers(session) {
registerHandshake(session)
registerMonitorHandlers(session)
}
/**
* @param {import('./session.js').PeerSession} session
*/
export function cleanupSession(session) {
session.state.clear()
}
+309
View File
@@ -0,0 +1,309 @@
/**
* ProtomuxRPC session wrapping a HyperDHT secret stream.
*/
import ProtomuxRPC from 'protomux-rpc'
import b4a from 'b4a'
import { PROTOCOL, PROTOCOL_VERSION, Roles } from '../../shared/protocol.js'
import { encodings } from '../../shared/encodings.js'
import rateLimiter from '../utils/rateLimiter.js'
import logger from '../utils/logger.js'
import { resolveRole, assertAllowed, maxRole } from '../core/acl.js'
import { audit, shouldAudit } from '../core/audit.js'
import {
redeemCapability,
isPeerAllowed,
getPeerEntry,
registerPeer,
touchPeer,
} from '../core/peer-policy.js'
import { validateMethodArgs, SCHEMA_VERSION } from '../../shared/schema.js'
import { verifyAdminProof } from '../../shared/crypto-auth.js'
import { getMacKey, getServerPublicKeyHex } from '../core/auth-keys.js'
export class PeerSession {
/**
* @param {import('stream').Duplex} stream
* @param {{ serverPublicKey: Uint8Array, onClose?: (s: PeerSession) => void }} opts
*/
constructor(stream, { serverPublicKey, onClose } = {}) {
this.stream = stream
this.id = stream.remotePublicKey
? b4a.toString(stream.remotePublicKey, 'hex')
: `anon-${Date.now()}`
this.remotePublicKey = stream.remotePublicKey
this.closed = false
this.onClose = onClose
this.role = resolveRole(this.id)
this.clientInfo = null
this.authMode = 'viewer'
this.displayName = null
/** @type {Map<string, any>} */
this.state = new Map()
this.rpc = new ProtomuxRPC(stream, {
id: serverPublicKey,
protocol: PROTOCOL,
...encodings,
})
this.rpc.on('close', () => this._handleClose())
this.rpc.on('destroy', () => this._handleClose())
stream.on('close', () => this._handleClose())
stream.on('error', (err) => {
logger.error('Peer stream error', {
peerId: this.id.slice(0, 12),
error: err.message,
})
})
}
/**
* @param {string} method
* @param {(args: any, session: PeerSession) => Promise<any>|any} handler
* @param {{ hot?: boolean }} [opts]
*/
respond(method, handler, opts = {}) {
const hot = opts.hot === true || rateLimiter.isStreamMethod?.(method)
this.rpc.respond(method, encodings, async (args) => {
if (!rateLimiter.isAllowed(this, method)) {
const err = new Error('Rate limit exceeded. Please wait before making more requests.')
err.code = 'RATE_LIMIT_EXCEEDED'
throw err
}
if (hot) {
try {
assertAllowed(this.role, method)
return await handler(args ?? {}, this)
} catch (err) {
if (err?.code === 'PERMISSION_DENIED') {
audit({
method,
peerId: this.id,
role: this.role,
ok: false,
error: err.message,
force: true,
})
}
const safe = new Error(sanitizeError(err))
safe.code = err.code || 'UNKNOWN_ERROR'
throw safe
}
}
const t0 = Date.now()
try {
assertAllowed(this.role, method)
const validated = validateMethodArgs(method, args ?? {})
if (!validated.ok) {
const err = new Error(validated.error || 'Invalid arguments')
err.code = 'INVALID_ARGS'
throw err
}
const result = await handler(validated.args, this)
if (shouldAudit(method)) {
audit({
method,
peerId: this.id,
role: this.role,
ok: true,
args: args ?? {},
})
}
const latencyMs = Date.now() - t0
if (latencyMs >= 2000) {
logger.warn('Slow RPC', {
method,
peerId: this.id.slice(0, 12),
latencyMs,
})
} else {
logger.debug('RPC ok', {
method,
peerId: this.id.slice(0, 12),
latencyMs,
})
}
return result
} catch (err) {
if (shouldAudit(method) || err?.code === 'PERMISSION_DENIED') {
audit({
method,
peerId: this.id,
role: this.role,
ok: false,
error: err.message,
args: args ?? {},
force: err?.code === 'PERMISSION_DENIED',
})
}
logger.error('RPC handler failed', {
method,
peerId: this.id.slice(0, 12),
role: this.role,
error: err.message,
code: err.code,
})
const safe = new Error(sanitizeError(err))
safe.code = err.code || 'UNKNOWN_ERROR'
throw safe
}
})
}
/**
* @param {string} method
* @param {unknown} payload
*/
push(method, payload) {
if (this.closed || this.rpc.closed) return
this.rpc.event(method, payload, encodings)
}
destroy() {
if (this.closed) return
this.closed = true
try {
this.rpc.destroy()
} catch {
// ignore
}
try {
this.stream.destroy()
} catch {
// ignore
}
}
_handleClose() {
if (this.closed) return
this.closed = true
if (this.onClose) this.onClose(this)
}
}
/**
* @param {PeerSession} session
*/
export function registerHandshake(session) {
session.respond('handshake', async (args) => {
if (args?.clientName || args?.clientVersion) {
session.clientInfo = {
name: args.clientName || 'unknown',
version: args.clientVersion || null,
}
}
let authMode = 'viewer'
let elevatedRole = null
if (args?.adminProof) {
const serverPk = getServerPublicKeyHex()
const proofRes = verifyAdminProof(getMacKey(), args.adminProof, {
peerId: session.id,
serverPublicKeyHex: serverPk || '',
})
if (!proofRes.ok) {
const e = new Error(proofRes.error || 'Admin proof failed')
e.code = proofRes.code || 'ADMIN_PROOF_FAILED'
audit({
method: 'handshake',
peerId: session.id,
role: session.role,
ok: false,
error: e.message,
force: true,
})
throw e
}
elevatedRole = Roles.admin
authMode = 'seed'
registerPeer(session.id, { role: Roles.admin })
}
const capabilityToken = args?.capability || null
if (capabilityToken && authMode !== 'seed') {
try {
const { role, reconnected } = redeemCapability(String(capabilityToken), session.id)
elevatedRole = role
authMode = reconnected ? 'registered' : 'capability'
} catch (err) {
const registered = getPeerEntry(session.id)
if (registered?.role && registered.role !== Roles.viewer) {
elevatedRole = registered.role
authMode = 'registered'
} else {
const e = new Error(err.message || 'Capability redeem failed')
e.code = err.code || 'CAPABILITY_INVALID'
audit({
method: 'handshake',
peerId: session.id,
role: session.role,
ok: false,
error: e.message,
force: true,
})
throw e
}
}
}
if (!elevatedRole && authMode === 'viewer') {
const registered = getPeerEntry(session.id)
if (registered?.role) {
elevatedRole = registered.role
authMode = 'registered'
}
}
const baseline = resolveRole(session.id)
session.role = elevatedRole ? maxRole(baseline, elevatedRole) : baseline
session.authMode = authMode
if (!isPeerAllowed(session.id, { authMode })) {
const err = new Error('Peer not allowed (revoked or not on allowlist)')
err.code = 'PEER_DENIED'
throw err
}
touchPeer(session.id)
logger.info('Handshake complete', {
peerId: session.id.slice(0, 12),
role: session.role,
authMode,
})
audit({
method: 'handshake',
peerId: session.id,
role: session.role,
ok: true,
force: true,
args: { authMode, role: session.role },
})
return {
success: true,
protocol: PROTOCOL,
protocolVersion: PROTOCOL_VERSION,
schemaVersion: SCHEMA_VERSION,
role: session.role,
peerId: session.id,
serverTime: Date.now(),
auth: { mode: authMode, role: session.role },
features: {
schemaValidation: true,
hmacAuth: true,
invites: true,
room: true,
},
}
})
}
function sanitizeError(err) {
const msg = String(err?.message || err || 'Unknown error')
return msg.length > 800 ? msg.slice(0, 800) + '…' : msg
}
+141
View File
@@ -0,0 +1,141 @@
/**
* PearData / PearMonitor agent entry point.
*
* - HyperDHT listener + protomux-rpc (P2P control + metric streams)
* - Optional Netdata-compatible REST API (default :19999)
* - 1s system metrics pipeline
*/
import DHT from 'hyperdht'
import b4a from 'b4a'
import gracefulGoodbye from 'graceful-goodbye'
import { loadOrCreateKeyPair } from './core/keys.js'
import { initAuthKeys } from './core/auth-keys.js'
import { peers } from './core/peer-registry.js'
import { PeerSession } from './rpc/session.js'
import { registerAllHandlers, cleanupSession } from './rpc/register.js'
import { isPeerRevoked, loadPeerPolicy } from './core/peer-policy.js'
import { isInsecureOpenAdmin } from '../shared/crypto-auth.js'
import { APP_NAME, APP_VERSION } from '../shared/protocol.js'
import { startPipeline } from './pipeline.js'
import { startRestServer } from './rest/http-server.js'
import { getCollector } from './services/collector.js'
import logger from './utils/logger.js'
const bootStarted = Date.now()
const log = logger.child('server')
/** @type {{ keyPair: any, publicKeyHex: string, seedHex: string }} */
let keyPair
let publicKeyHex
let seedHex
try {
;({ keyPair, publicKeyHex, seedHex } = loadOrCreateKeyPair())
initAuthKeys({ seedHex, publicKeyHex })
loadPeerPolicy()
} catch (err) {
log.error('Failed to load server keypair', { error: err.message })
process.exit(1)
}
if (isInsecureOpenAdmin()) {
log.warn(
'PEARDATA_INSECURE_OPEN_ADMIN=1 — every peer is admin. Disable for production multi-operator use.'
)
}
startPipeline()
const restServer = startRestServer()
const dht = new DHT()
const server = dht.createServer()
server.on('connection', (socket) => {
const peerId = socket.remotePublicKey
? b4a.toString(socket.remotePublicKey, 'hex')
: null
if (peerId && isPeerRevoked(peerId)) {
log.warn('Rejected revoked peer', { peerId: peerId.slice(0, 12) })
try {
socket.destroy()
} catch {
// ignore
}
return
}
const session = new PeerSession(socket, {
serverPublicKey: keyPair.publicKey,
onClose: (s) => {
cleanupSession(s)
peers.remove(s.id)
log.info('Peer disconnected', {
peerId: s.id.slice(0, 12),
role: s.role,
})
},
})
registerAllHandlers(session)
peers.add(session)
log.info('Peer connected', {
peerId: session.id.slice(0, 12),
role: session.role,
connected: peers.size(),
})
})
await server.listen(keyPair)
const bootMs = Date.now() - bootStarted
const restPort = Number(process.env.PEARDATA_REST_PORT) || 19999
const restHost = process.env.PEARDATA_REST_HOST || '127.0.0.1'
logger.banner({
title: `${APP_NAME} agent v${APP_VERSION}`,
publicKey: publicKeyHex,
connect: `Client → dial ${publicKeyHex}`,
admin: 'Use SERVER_SEED as admin proof, or mint pd1 invites',
rest: restServer ? `http://${restHost}:${restPort}/api/v3/info` : 'disabled',
insecure: isInsecureOpenAdmin() ? 'OPEN ADMIN (dev)' : 'secure defaults',
bootMs: `${bootMs}ms`,
})
log.info('HyperDHT agent listening', { publicKeyHex })
async function shutdown() {
log.info('Shutting down…')
try {
getCollector().stop()
} catch {
// ignore
}
for (const s of peers.list()) {
try {
s.destroy()
} catch {
// ignore
}
}
if (restServer) {
await new Promise((resolve) => restServer.close(() => resolve()))
}
try {
await server.close()
} catch {
// ignore
}
try {
await dht.destroy()
} catch {
// ignore
}
process.exit(0)
}
gracefulGoodbye(shutdown)
process.on('SIGINT', shutdown)
process.on('SIGTERM', shutdown)
export { publicKeyHex, dht, server, restServer }
+51
View File
@@ -0,0 +1,51 @@
/**
* Alert state machine wrapping AnomalyEngine configs.
*/
import { getAnomalyEngine } from './anomaly.js'
/**
* @returns {import('../../shared/data-model.js').AlertState[]}
*/
export function listAlerts() {
const engine = getAnomalyEngine()
return engine.listConfigs().map((cfg) => {
const status = engine.status.get(cfg.id) || 'CLEAR'
return {
id: cfg.id,
name: cfg.id,
chart: cfg.chart,
dimension: cfg.dimension,
status,
value: null,
units: '',
info: cfg.info || '',
lastStatusChange: Date.now(),
config: cfg,
}
})
}
export function getAlert(id) {
return listAlerts().find((a) => a.id === id) || null
}
export function setAlertConfig(cfg) {
return getAnomalyEngine().setConfig(cfg)
}
/** Soft ack — clears active status until next breach. */
export function ackAlert(id) {
const engine = getAnomalyEngine()
if (!engine.configs.has(id)) return { success: false, error: 'unknown alert' }
engine.status.set(id, 'CLEAR')
return { success: true, id }
}
export function silenceAlert(id, opts = {}) {
const engine = getAnomalyEngine()
const cfg = engine.configs.get(id)
if (!cfg) return { success: false, error: 'unknown alert' }
const until = opts.until || Date.now() + (opts.ttlMs || 3600_000)
engine.setConfig({ ...cfg, enabled: false, _silencedUntil: until })
return { success: true, id, until }
}
+212
View File
@@ -0,0 +1,212 @@
/**
* Threshold-based anomaly detection (MVP).
* Later: scoring, k-means consensus, ML jobs.
*/
import { EventEmitter } from 'events'
import { normalizeAnomaly } from '../../shared/data-model.js'
/** @typedef {import('../../shared/data-model.js').AlertConfig} AlertConfig */
/** @typedef {import('../../shared/data-model.js').AnomalyEvent} AnomalyEvent */
/** Default thresholds — tunable via setAlertConfig / REST. */
export const DEFAULT_THRESHOLDS = [
{
id: 'cpu_user_high',
chart: 'system.cpu',
dimension: 'user',
warn: 80,
crit: 95,
comparator: '>',
enabled: true,
info: 'CPU user time high',
},
{
id: 'load1_high',
chart: 'system.load',
dimension: 'load1',
warn: null, // set dynamically from cpu count in evaluate
crit: null,
comparator: '>',
enabled: true,
info: 'Load average high vs CPU count',
_dynamicLoad: true,
},
{
id: 'mem_avail_low',
chart: 'mem.available',
dimension: 'avail',
warn: 512,
crit: 256,
comparator: '<',
enabled: true,
info: 'Available memory low (MiB)',
},
]
function compare(op, value, threshold) {
if (threshold == null || value == null || Number.isNaN(value)) return false
switch (op) {
case '<':
return value < threshold
case '<=':
return value <= threshold
case '>=':
return value >= threshold
case '>':
default:
return value > threshold
}
}
export class AnomalyEngine extends EventEmitter {
/**
* @param {{ cpuCount?: number }} [opts]
*/
constructor(opts = {}) {
super()
this.cpuCount = opts.cpuCount || 1
/** @type {Map<string, AlertConfig & { _dynamicLoad?: boolean }>} */
this.configs = new Map(DEFAULT_THRESHOLDS.map((c) => [c.id, { ...c }]))
/** @type {Map<string, string>} status CLEAR|WARNING|CRITICAL */
this.status = new Map()
/** @type {AnomalyEvent[]} */
this.recent = []
this.recentMax = 500
}
/**
* @param {AlertConfig} cfg
*/
setConfig(cfg) {
const prev = this.configs.get(cfg.id) || {}
this.configs.set(cfg.id, { ...prev, ...cfg, id: cfg.id })
return this.configs.get(cfg.id)
}
listConfigs() {
return [...this.configs.values()]
}
listRecent(limit = 50) {
return this.recent.slice(-limit).reverse()
}
/**
* @param {Array<{ chart: string, context: string, ts: number, values: Record<string, number|null> }>} batch
*/
evaluate(batch) {
/** @type {AnomalyEvent[]} */
const fired = []
const byChart = new Map(batch.map((s) => [s.chart, s]))
for (const cfg of this.configs.values()) {
if (cfg.enabled === false) continue
const sample = byChart.get(cfg.chart)
if (!sample) continue
const value = sample.values[cfg.dimension]
if (value == null) continue
let warn = cfg.warn
let crit = cfg.crit
if (cfg._dynamicLoad) {
warn = this.cpuCount * 1.5
crit = this.cpuCount * 3
}
const op = cfg.comparator || '>'
let severity = null
let threshold = null
if (compare(op, value, crit)) {
severity = 'critical'
threshold = crit
} else if (compare(op, value, warn)) {
severity = 'warning'
threshold = warn
}
const prev = this.status.get(cfg.id) || 'CLEAR'
const next = severity === 'critical' ? 'CRITICAL' : severity === 'warning' ? 'WARNING' : 'CLEAR'
if (next !== prev) {
this.status.set(cfg.id, next)
if (next === 'CLEAR') {
const cleared = normalizeAnomaly({
id: `${cfg.id}:${sample.ts}`,
chart: cfg.chart,
context: sample.context,
dimension: cfg.dimension,
severity: prev === 'CRITICAL' ? 'critical' : 'warning',
score: 0,
value,
threshold: threshold ?? warn ?? crit ?? 0,
comparator: op,
message: `${cfg.info || cfg.id} cleared`,
ts: sample.ts,
cleared: true,
})
this._push(cleared)
fired.push(cleared)
} else if (severity) {
const score = severity === 'critical' ? 1 : 0.6
const ev = normalizeAnomaly({
id: `${cfg.id}:${sample.ts}`,
chart: cfg.chart,
context: sample.context,
dimension: cfg.dimension,
severity,
score,
value,
threshold,
comparator: op,
message: `${cfg.info || cfg.id}: ${cfg.dimension}=${round2(value)} ${op} ${threshold}`,
ts: sample.ts,
})
this._push(ev)
fired.push(ev)
this.emit('anomaly', ev)
}
}
}
return fired
}
/** @param {AnomalyEvent} ev */
_push(ev) {
this.recent.push(ev)
if (this.recent.length > this.recentMax) {
this.recent.splice(0, this.recent.length - this.recentMax)
}
}
getHealth() {
let critical = 0
let warning = 0
const checks = []
for (const [id, st] of this.status) {
const cfg = this.configs.get(id)
checks.push({
id,
ok: st === 'CLEAR',
detail: `${cfg?.info || id}: ${st}`,
})
if (st === 'CRITICAL') critical++
else if (st === 'WARNING') warning++
}
const status = critical ? 'critical' : warning ? 'degraded' : 'ok'
const score = critical ? 0.2 : warning ? 0.7 : 1
return { status, score, checks, ts: Date.now() }
}
}
function round2(n) {
return Math.round(Number(n) * 100) / 100
}
/** @type {AnomalyEngine|null} */
let singleton = null
export function getAnomalyEngine(cpuCount) {
if (!singleton) singleton = new AnomalyEngine({ cpuCount })
else if (cpuCount) singleton.cpuCount = cpuCount
return singleton
}
+344
View File
@@ -0,0 +1,344 @@
/**
* Lightweight system metrics collector (1s target).
*
* Uses Node `os` + platform-specific best-effort reads (/proc on Linux).
* Designed for low overhead — no shelling out on the hot path.
*/
import os from 'os'
import fs from 'fs'
import { EventEmitter } from 'events'
import { SAMPLE_INTERVAL_MS, CHART_DEFS } from '../../shared/metrics.js'
import logger from '../utils/logger.js'
const log = logger.child('collector')
function bytesToMiB(n) {
return n / (1024 * 1024)
}
function readProc(path) {
try {
return fs.readFileSync(path, 'utf8')
} catch {
return null
}
}
function parseMeminfo() {
const raw = readProc('/proc/meminfo')
if (!raw) return null
/** @type {Record<string, number>} */
const out = {}
for (const line of raw.split('\n')) {
const m = line.match(/^(\w+):\s+(\d+)/)
if (m) out[m[1]] = Number(m[2]) * 1024 // kB → bytes
}
return out
}
function parseLoadavg() {
const raw = readProc('/proc/loadavg')
if (!raw) {
const l = os.loadavg()
return { load1: l[0], load5: l[1], load15: l[2], running: 0, total: 0 }
}
const parts = raw.trim().split(/\s+/)
const [running, total] = (parts[3] || '0/0').split('/').map(Number)
return {
load1: Number(parts[0]),
load5: Number(parts[1]),
load15: Number(parts[2]),
running: running || 0,
total: total || 0,
}
}
function cpuTimes() {
const cpus = os.cpus()
const agg = { user: 0, nice: 0, sys: 0, idle: 0, irq: 0 }
for (const c of cpus) {
agg.user += c.times.user
agg.nice += c.times.nice
agg.sys += c.times.sys
agg.idle += c.times.idle
agg.irq += c.times.irq
}
return agg
}
function netCounters() {
const ni = os.networkInterfaces()
// os does not expose byte counters — use /proc/net/dev on Linux
const raw = readProc('/proc/net/dev')
if (!raw) {
return { rx: 0, tx: 0, ifaces: Object.keys(ni || {}).length }
}
let rx = 0
let tx = 0
for (const line of raw.split('\n').slice(2)) {
const parts = line.trim().split(/\s+/)
if (parts.length < 10) continue
const name = parts[0].replace(':', '')
if (name === 'lo') continue
rx += Number(parts[1]) || 0
tx += Number(parts[9]) || 0
}
return { rx, tx, ifaces: Object.keys(ni || {}).length }
}
function diskCounters() {
const raw = readProc('/proc/diskstats')
if (!raw) return { reads: 0, writes: 0 }
let reads = 0
let writes = 0
for (const line of raw.split('\n')) {
const p = line.trim().split(/\s+/)
if (p.length < 14) continue
const name = p[2]
// skip partitions (sda1) and ram/loop
if (/^(loop|ram|fd)/.test(name)) continue
if (/\d+$/.test(name) && !/^nvme/.test(name)) continue
if (/^nvme.+p\d+$/.test(name)) continue
reads += (Number(p[5]) || 0) * 512 // sectors → bytes
writes += (Number(p[9]) || 0) * 512
}
return { reads, writes }
}
/**
* Compute rate from two counter snapshots.
* @param {number} prev
* @param {number} cur
* @param {number} dtSec
*/
function rate(prev, cur, dtSec) {
if (dtSec <= 0 || cur < prev) return 0
return (cur - prev) / dtSec
}
export class MetricsCollector extends EventEmitter {
/**
* @param {{ intervalMs?: number }} [opts]
*/
constructor(opts = {}) {
super()
this.intervalMs = opts.intervalMs ?? Number(process.env.PEARDATA_SAMPLE_MS) || SAMPLE_INTERVAL_MS
this.timer = null
this.running = false
this.sampleCount = 0
this.lastCpu = null
this.lastNet = null
this.lastDisk = null
this.lastTs = 0
/** @type {Map<string, Record<string, number|null>>} */
this.latest = new Map()
}
start() {
if (this.running) return
this.running = true
this._tick()
this.timer = setInterval(() => this._tick(), this.intervalMs)
if (this.timer.unref) this.timer.unref()
log.info('Collector started', { intervalMs: this.intervalMs })
}
stop() {
this.running = false
if (this.timer) clearInterval(this.timer)
this.timer = null
}
_tick() {
try {
const ts = Date.now()
const dtSec = this.lastTs ? (ts - this.lastTs) / 1000 : this.intervalMs / 1000
this.lastTs = ts
/** @type {Array<{ chart: string, context: string, ts: number, values: Record<string, number|null> }>} */
const batch = []
// CPU %
const cpu = cpuTimes()
let cpuVals = {
user: 0,
system: 0,
nice: 0,
iowait: 0,
irq: 0,
softirq: 0,
idle: 100,
}
if (this.lastCpu) {
const dUser = cpu.user - this.lastCpu.user
const dNice = cpu.nice - this.lastCpu.nice
const dSys = cpu.sys - this.lastCpu.sys
const dIdle = cpu.idle - this.lastCpu.idle
const dIrq = cpu.irq - this.lastCpu.irq
const total = dUser + dNice + dSys + dIdle + dIrq
if (total > 0) {
cpuVals = {
user: (dUser / total) * 100,
system: (dSys / total) * 100,
nice: (dNice / total) * 100,
iowait: 0,
irq: (dIrq / total) * 100,
softirq: 0,
idle: (dIdle / total) * 100,
}
}
}
this.lastCpu = cpu
batch.push({ chart: 'system.cpu', context: 'system.cpu', ts, values: cpuVals })
// RAM
const mem = parseMeminfo()
const total = os.totalmem()
const free = os.freemem()
let used = total - free
let cached = 0
let buffers = 0
let avail = free
if (mem) {
const totalB = mem.MemTotal || total
const freeB = mem.MemFree || free
buffers = mem.Buffers || 0
cached = (mem.Cached || 0) + (mem.SReclaimable || 0)
avail = mem.MemAvailable || freeB
used = Math.max(0, totalB - freeB - buffers - cached)
batch.push({
chart: 'system.ram',
context: 'system.ram',
ts,
values: {
used: bytesToMiB(used),
cached: bytesToMiB(cached),
buffers: bytesToMiB(buffers),
free: bytesToMiB(freeB),
},
})
batch.push({
chart: 'mem.available',
context: 'mem.available',
ts,
values: { avail: bytesToMiB(avail) },
})
} else {
batch.push({
chart: 'system.ram',
context: 'system.ram',
ts,
values: {
used: bytesToMiB(used),
cached: 0,
buffers: 0,
free: bytesToMiB(free),
},
})
batch.push({
chart: 'mem.available',
context: 'mem.available',
ts,
values: { avail: bytesToMiB(free) },
})
}
const load = parseLoadavg()
batch.push({
chart: 'system.load',
context: 'system.load',
ts,
values: { load1: load.load1, load5: load.load5, load15: load.load15 },
})
batch.push({
chart: 'system.processes',
context: 'system.processes',
ts,
values: {
running: load.running,
blocked: 0,
total: load.total || Object.keys(process || {}).length,
},
})
const net = netCounters()
let rxRate = 0
let txRate = 0
if (this.lastNet) {
// bytes/s → kilobits/s
rxRate = (rate(this.lastNet.rx, net.rx, dtSec) * 8) / 1000
txRate = (rate(this.lastNet.tx, net.tx, dtSec) * 8) / 1000
}
this.lastNet = net
batch.push({
chart: 'system.net',
context: 'system.net',
ts,
values: { received: rxRate, sent: txRate },
})
const disk = diskCounters()
let readRate = 0
let writeRate = 0
if (this.lastDisk) {
readRate = rate(this.lastDisk.reads, disk.reads, dtSec) / 1024 // KiB/s
writeRate = rate(this.lastDisk.writes, disk.writes, dtSec) / 1024
}
this.lastDisk = disk
batch.push({
chart: 'system.io',
context: 'system.io',
ts,
values: { reads: readRate, writes: writeRate },
})
batch.push({
chart: 'system.uptime',
context: 'system.uptime',
ts,
values: { uptime: os.uptime() },
})
for (const s of batch) {
this.latest.set(s.chart, s.values)
}
this.sampleCount++
this.emit('samples', batch)
} catch (err) {
log.error('Collector tick failed', { error: err.message })
}
}
getLatest() {
/** @type {Record<string, Record<string, number|null>>} */
const out = {}
for (const [k, v] of this.latest) out[k] = v
return out
}
getNodeInfo(publicKeyHex, agentVersion) {
return {
nodeId: publicKeyHex?.slice(0, 16) || os.hostname(),
hostname: os.hostname(),
publicKeyHex: publicKeyHex || null,
platform: os.platform(),
arch: os.arch(),
release: os.release(),
cpus: os.cpus().length,
totalMemMiB: bytesToMiB(os.totalmem()),
agentVersion,
startedAt: Date.now() - Math.floor(process.uptime() * 1000),
charts: CHART_DEFS.map((c) => c.id),
sampleIntervalMs: this.intervalMs,
sampleCount: this.sampleCount,
}
}
}
/** @type {MetricsCollector|null} */
let singleton = null
export function getCollector() {
if (!singleton) singleton = new MetricsCollector()
return singleton
}
+103
View File
@@ -0,0 +1,103 @@
/**
* Lightweight job tray for on-demand agent operations.
*/
import { EventEmitter } from 'events'
import { randomUUID } from 'crypto'
import { Pushes } from '../../shared/protocol.js'
import { peers } from '../core/peer-registry.js'
import { getCollector } from './collector.js'
import { getStore } from './store.js'
const JOB_HANDLERS = {
collectOnce: async () => {
const c = getCollector()
c._tick()
return { ok: true, sampleCount: c.sampleCount }
},
snapshot: async () => {
return { ok: true, latest: getStore().latestValues() }
},
gcBuffers: async () => {
// ring buffers self-trim; placeholder for future disk GC
return { ok: true }
},
}
export class JobService extends EventEmitter {
constructor() {
super()
/** @type {Map<string, import('../../shared/data-model.js').JobRecord>} */
this.jobs = new Map()
}
list() {
return [...this.jobs.values()].sort((a, b) => (b.startedAt || 0) - (a.startedAt || 0))
}
/**
* @param {string} name
* @param {object} [args]
*/
async run(name, args = {}) {
const handler = JOB_HANDLERS[name]
if (!handler) {
return { success: false, error: `unknown job: ${name}`, known: Object.keys(JOB_HANDLERS) }
}
const id = randomUUID()
/** @type {import('../../shared/data-model.js').JobRecord} */
const rec = {
id,
name,
status: 'running',
startedAt: Date.now(),
finishedAt: null,
}
this.jobs.set(id, rec)
this._push(rec)
try {
rec.result = await handler(args)
rec.status = 'done'
rec.finishedAt = Date.now()
} catch (err) {
rec.status = 'failed'
rec.error = err.message
rec.finishedAt = Date.now()
}
this._push(rec)
return { success: rec.status === 'done', job: rec }
}
cancel(id) {
const rec = this.jobs.get(id)
if (!rec) return { success: false, error: 'unknown job' }
if (rec.status === 'running') {
rec.status = 'cancelled'
rec.finishedAt = Date.now()
this._push(rec)
}
return { success: true, job: rec }
}
_push(rec) {
for (const session of peers.list()) {
if (session.closed) continue
try {
session.push(Pushes.job, rec)
} catch {
// ignore
}
}
}
}
/** @type {JobService|null} */
let singleton = null
export function getJobs() {
if (!singleton) singleton = new JobService()
return singleton
}
export function knownJobNames() {
return Object.keys(JOB_HANDLERS)
}
+214
View File
@@ -0,0 +1,214 @@
/**
* In-memory tiered metric ring buffers.
*
* Tier 0: high-res (1s) short retention
* Tier 1: downsampled (avg over window) longer retention
*
* Future: Hypercore / disk-backed persistence.
*/
import { SAMPLE_INTERVAL_MS, CHART_BY_ID, chartSummary } from '../../shared/metrics.js'
function envInt(name, fallback) {
const n = Number(process.env[name])
return Number.isFinite(n) && n > 0 ? n : fallback
}
export class MetricStore {
constructor() {
this.tier0Max = envInt('PEARDATA_TIER0_POINTS', 3600) // 1h @ 1s
this.tier1Max = envInt('PEARDATA_TIER1_POINTS', 1440) // 24h @ 1m
this.tier1Every = envInt('PEARDATA_TIER1_EVERY', 60) // downsample every N samples
/** @type {Map<string, { points: Array<{ts:number, values: Record<string, number|null>}>, tier1: Array<{ts:number, values: Record<string, number|null>}>, acc: object|null, accCount: number }>} */
this.series = new Map()
}
/**
* @param {Array<{ chart: string, context: string, ts: number, values: Record<string, number|null> }>} batch
*/
ingest(batch) {
for (const s of batch) {
let entry = this.series.get(s.chart)
if (!entry) {
entry = { points: [], tier1: [], acc: null, accCount: 0 }
this.series.set(s.chart, entry)
}
entry.points.push({ ts: s.ts, values: s.values })
if (entry.points.length > this.tier0Max) {
entry.points.splice(0, entry.points.length - this.tier0Max)
}
// accumulate for tier1
if (!entry.acc) {
entry.acc = { ...s.values }
entry.accCount = 1
} else {
for (const [k, v] of Object.entries(s.values)) {
if (v == null || Number.isNaN(v)) continue
entry.acc[k] = (entry.acc[k] || 0) + v
}
entry.accCount++
}
if (entry.accCount >= this.tier1Every) {
/** @type {Record<string, number|null>} */
const avg = {}
for (const [k, v] of Object.entries(entry.acc)) {
avg[k] = entry.accCount ? v / entry.accCount : null
}
entry.tier1.push({ ts: s.ts, values: avg })
if (entry.tier1.length > this.tier1Max) {
entry.tier1.splice(0, entry.tier1.length - this.tier1Max)
}
entry.acc = null
entry.accCount = 0
}
}
}
/**
* @param {string} chart
*/
getMeta(chart) {
const def = CHART_BY_ID.get(chart)
const entry = this.series.get(chart)
const first = entry?.points[0]?.ts
const last = entry?.points[entry.points.length - 1]?.ts
if (!def) return null
return chartSummary(def, {
firstEntry: first ? Math.floor(first / 1000) : 0,
lastEntry: last ? Math.floor(last / 1000) : 0,
updateEvery: SAMPLE_INTERVAL_MS / 1000,
})
}
listChartSummaries() {
/** @type {Record<string, any>} */
const charts = {}
for (const id of CHART_BY_ID.keys()) {
const meta = this.getMeta(id)
if (meta) charts[id] = meta
}
return charts
}
/**
* Query points for a chart (Netdata-like after/before/points).
*
* @param {{ chart: string, after?: number, before?: number, points?: number, group?: string, tier?: number }} opts
*/
query(opts) {
const chart = opts.chart
const entry = this.series.get(chart)
const def = CHART_BY_ID.get(chart)
if (!def) {
return { error: 'unknown chart', chart }
}
const useTier1 = opts.tier === 1
const src = entry ? (useTier1 ? entry.tier1 : entry.points) : []
const nowSec = Math.floor(Date.now() / 1000)
let before = opts.before == null || opts.before === 0 ? nowSec : Number(opts.before)
let after = opts.after == null ? -Math.min(opts.points || 60, src.length || 60) : Number(opts.after)
if (before <= 0) before = nowSec + before
if (after <= 0) after = before + after // relative seconds
const afterMs = after * 1000
const beforeMs = before * 1000
let windowed = src.filter((p) => p.ts >= afterMs && p.ts <= beforeMs)
if (!windowed.length && src.length) {
// fall back to latest N
const n = Math.min(opts.points || 60, src.length)
windowed = src.slice(-n)
}
const want = Math.min(opts.points || windowed.length || 60, 10_000)
const sampled = downsample(windowed, want, opts.group || 'average', def.dimensions.map((d) => d.id))
const labels = ['time', ...def.dimensions.map((d) => d.id)]
const data = sampled.map((p) => {
const row = [Math.floor(p.ts / 1000)]
for (const dim of def.dimensions) {
const v = p.values[dim.id]
row.push(v == null || Number.isNaN(v) ? null : round4(v))
}
return row
})
return {
chart,
context: def.context,
labels,
data,
view_update_every: SAMPLE_INTERVAL_MS / 1000,
after: after,
before: before,
points: data.length,
format: 'json',
}
}
latestValues() {
/** @type {Record<string, { ts: number, values: Record<string, number|null> }>} */
const out = {}
for (const [chart, entry] of this.series) {
const last = entry.points[entry.points.length - 1]
if (last) out[chart] = last
}
return out
}
}
/**
* @param {Array<{ts:number, values: object}>} points
* @param {number} want
* @param {string} group
* @param {string[]} dims
*/
function downsample(points, want, group, dims) {
if (points.length <= want) return points
const bucketSize = points.length / want
/** @type {typeof points} */
const out = []
for (let i = 0; i < want; i++) {
const start = Math.floor(i * bucketSize)
const end = Math.floor((i + 1) * bucketSize)
const slice = points.slice(start, Math.max(start + 1, end))
const acc = {}
for (const d of dims) acc[d] = []
for (const p of slice) {
for (const d of dims) {
const v = p.values[d]
if (v != null && !Number.isNaN(v)) acc[d].push(v)
}
}
/** @type {Record<string, number|null>} */
const values = {}
for (const d of dims) {
values[d] = aggregate(acc[d], group)
}
out.push({ ts: slice[slice.length - 1].ts, values })
}
return out
}
function aggregate(arr, group) {
if (!arr.length) return null
if (group === 'min') return Math.min(...arr)
if (group === 'max') return Math.max(...arr)
if (group === 'sum') return arr.reduce((a, b) => a + b, 0)
// average default
return arr.reduce((a, b) => a + b, 0) / arr.length
}
function round4(n) {
return Math.round(n * 10000) / 10000
}
/** @type {MetricStore|null} */
let singleton = null
export function getStore() {
if (!singleton) singleton = new MetricStore()
return singleton
}
+98
View File
@@ -0,0 +1,98 @@
/**
* Per-session metric / anomaly push subscriptions.
*/
import { Pushes } from '../../shared/protocol.js'
import { peers } from '../core/peer-registry.js'
/**
* @param {import('../rpc/session.js').PeerSession} session
* @param {{ charts: string[], intervalMs: number }} opts
*/
export function subscribeMetrics(session, opts) {
const charts = opts.charts?.includes('*') ? ['*'] : opts.charts || ['*']
session.state.set('metricSub', {
charts,
intervalMs: opts.intervalMs || 1000,
})
return { success: true, charts, intervalMs: opts.intervalMs || 1000 }
}
/**
* @param {import('../rpc/session.js').PeerSession} session
*/
export function unsubscribeMetrics(session) {
session.state.delete('metricSub')
return { success: true }
}
/**
* @param {import('../rpc/session.js').PeerSession} session
*/
export function subscribeAnomalies(session) {
session.state.set('anomalySub', true)
return { success: true }
}
/**
* @param {import('../rpc/session.js').PeerSession} session
*/
export function unsubscribeAnomalies(session) {
session.state.delete('anomalySub')
return { success: true }
}
/**
* Fan-out a metric batch to subscribed peers (throttled per session preference).
* @param {Array<{ chart: string, context: string, ts: number, values: object }>} batch
*/
export function broadcastMetrics(batch) {
for (const session of peers.list()) {
const sub = session.state.get('metricSub')
if (!sub || session.closed) continue
const filtered =
sub.charts.includes('*')
? batch
: batch.filter((s) => sub.charts.includes(s.chart) || sub.charts.includes(s.context))
if (!filtered.length) continue
// simple throttle
const last = session.state.get('metricSubLast') || 0
const now = Date.now()
if (now - last < (sub.intervalMs || 1000) - 50) continue
session.state.set('metricSubLast', now)
try {
session.push(Pushes.metrics, { samples: filtered })
} catch {
// ignore dead peers
}
}
}
/**
* @param {object} anomaly
*/
export function broadcastAnomaly(anomaly) {
for (const session of peers.list()) {
if (!session.state.get('anomalySub') || session.closed) continue
try {
session.push(Pushes.anomaly, anomaly)
} catch {
// ignore
}
}
}
/**
* @param {object} payload
*/
export function broadcastHealth(payload) {
for (const session of peers.list()) {
if (session.closed) continue
try {
session.push(Pushes.health, payload)
} catch {
// ignore
}
}
}
+56
View File
@@ -0,0 +1,56 @@
/**
* Tiny structured logger (JSON lines optional).
*/
const LEVELS = { debug: 10, info: 20, warn: 30, error: 40 }
const minLevel =
LEVELS[String(process.env.LOG_LEVEL || 'info').toLowerCase()] ?? LEVELS.info
function emit(level, scope, message, fields) {
if ((LEVELS[level] ?? 99) < minLevel) return
const line = {
ts: new Date().toISOString(),
level,
scope,
msg: message,
...(fields && typeof fields === 'object' ? fields : {}),
}
const text = process.env.LOG_JSON === '1' ? JSON.stringify(line) : formatPretty(line)
if (level === 'error') console.error(text)
else if (level === 'warn') console.warn(text)
else console.log(text)
}
function formatPretty(line) {
const extra = { ...line }
delete extra.ts
delete extra.level
delete extra.scope
delete extra.msg
const keys = Object.keys(extra)
const tail = keys.length ? ' ' + JSON.stringify(extra) : ''
return `${line.ts} [${line.level}] ${line.scope}: ${line.msg}${tail}`
}
function child(scope) {
return {
debug: (msg, fields) => emit('debug', scope, msg, fields),
info: (msg, fields) => emit('info', scope, msg, fields),
warn: (msg, fields) => emit('warn', scope, msg, fields),
error: (msg, fields) => emit('error', scope, msg, fields),
child: (sub) => child(`${scope}:${sub}`),
banner(fields = {}) {
const bar = '═'.repeat(56)
console.log(bar)
console.log(` ${fields.title || 'Pear App Server'}`)
for (const [k, v] of Object.entries(fields)) {
if (k === 'title') continue
console.log(` ${k}: ${v}`)
}
console.log(bar)
},
}
}
const logger = child('app')
export default logger
+43
View File
@@ -0,0 +1,43 @@
/**
* Simple per-peer sliding window rate limiter.
*/
const WINDOW_MS = 60_000
const DEFAULT_RPM = Number(process.env.PEARDATA_RATE_LIMIT_RPM) || 120
/** @type {Map<string, number[]>} */
const hits = new Map()
const STREAM_METHODS = new Set([
'ping',
'queryData',
'subscribeMetrics',
'unsubscribeMetrics',
'getAllMetrics',
])
/**
* @param {{ id: string }} session
* @param {string} method
*/
export function isAllowed(session, method) {
if (STREAM_METHODS.has(method)) return true
const id = session?.id || 'anon'
const now = Date.now()
let list = hits.get(id)
if (!list) {
list = []
hits.set(id, list)
}
const cutoff = now - WINDOW_MS
while (list.length && list[0] < cutoff) list.shift()
if (list.length >= DEFAULT_RPM) return false
list.push(now)
return true
}
export function isStreamMethod(method) {
return STREAM_METHODS.has(method)
}
export default { isAllowed, isStreamMethod }
+348
View File
@@ -0,0 +1,348 @@
/**
* HMAC capability grants + admin seed proof for handshake auth.
*
* Pure helpers shared by server and client. Never logs secrets.
*
* Capability token format:
* base64url(JSON payload) + "." + base64url(HMAC-SHA256(macKey, payloadBytes))
*
* Admin proof (single-round):
* mac = HMAC-SHA256(macKey, "peardata-admin-v1" || nonce || peerId || serverPubKey)
*
* Invite envelope:
* pd1.<base64url JSON { publicKeyHex, capability, role, ... }>
*/
import crypto from 'crypto'
import { Roles } from './protocol.js'
const SALT = Buffer.from('peardata-hmac-v1', 'utf8')
const INFO_CAPABILITY = Buffer.from('capability', 'utf8')
const ADMIN_PREFIX = Buffer.from('peardata-admin-v1', 'utf8')
const VALID_ROLES = new Set([Roles.viewer, Roles.operator, Roles.admin])
export const INVITE_PREFIX = 'pd1.'
/**
* @param {string|Uint8Array|Buffer} seedHexOrBuf
* @returns {Buffer}
*/
export function deriveMacKey(seedHexOrBuf) {
const ikm = toSeedBuffer(seedHexOrBuf)
if (typeof crypto.hkdfSync === 'function') {
return Buffer.from(crypto.hkdfSync('sha256', ikm, SALT, INFO_CAPABILITY, 32))
}
const prk = crypto.createHmac('sha256', SALT).update(ikm).digest()
const info = Buffer.concat([INFO_CAPABILITY, Buffer.from([0x01])])
return crypto.createHmac('sha256', prk).update(info).digest()
}
function toSeedBuffer(seedHexOrBuf) {
if (Buffer.isBuffer(seedHexOrBuf) || seedHexOrBuf instanceof Uint8Array) {
const buf = Buffer.from(seedHexOrBuf)
if (buf.length !== 32) throw new Error('Seed must be 32 bytes')
return buf
}
const hex = String(seedHexOrBuf || '')
.trim()
.toLowerCase()
if (!/^[0-9a-f]{64}$/.test(hex)) {
throw new Error('Seed must be 64 hex characters (32 bytes)')
}
return Buffer.from(hex, 'hex')
}
export function b64url(buf) {
return Buffer.from(buf)
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
}
export function b64urlDecode(s) {
const str = String(s || '').replace(/-/g, '+').replace(/_/g, '/')
const pad = str.length % 4 === 0 ? '' : '='.repeat(4 - (str.length % 4))
return Buffer.from(str + pad, 'base64')
}
export function canonicalizePayload(payload) {
const ordered = {
v: payload.v,
role: payload.role,
peerId: payload.peerId ?? null,
exp: payload.exp,
jti: payload.jti,
iat: payload.iat,
}
return Buffer.from(JSON.stringify(ordered), 'utf8')
}
function resolveMacKey(macKeyOrSeed) {
if (Buffer.isBuffer(macKeyOrSeed) || macKeyOrSeed instanceof Uint8Array) {
const buf = Buffer.from(macKeyOrSeed)
if (buf.length === 32) return buf
}
if (typeof macKeyOrSeed === 'string' && /^[0-9a-fA-F]{64}$/.test(macKeyOrSeed.trim())) {
return deriveMacKey(macKeyOrSeed.trim())
}
if (Buffer.isBuffer(macKeyOrSeed) || macKeyOrSeed instanceof Uint8Array) {
return deriveMacKey(macKeyOrSeed)
}
throw new Error('Invalid mac key or seed')
}
export function safeEqual(a, b) {
if (!Buffer.isBuffer(a)) a = Buffer.from(a)
if (!Buffer.isBuffer(b)) b = Buffer.from(b)
if (a.length !== b.length) return false
return crypto.timingSafeEqual(a, b)
}
/**
* @param {Buffer|string} macKeyOrSeed
* @param {{ role?: string, ttlMs?: number|null, peerId?: string|null, jti?: string, forever?: boolean }} opts
*/
export function signCapability(macKeyOrSeed, opts = {}) {
const macKey = resolveMacKey(macKeyOrSeed)
const role = String(opts.role || Roles.operator).toLowerCase()
if (!VALID_ROLES.has(role)) throw new Error(`Invalid capability role: ${role}`)
const now = Date.now()
const rawTtl = opts.ttlMs
let exp = null
if (opts.forever === true || rawTtl === 0 || rawTtl === null || rawTtl === undefined) {
exp = null
} else {
const ttlMs = Math.min(
Math.max(Number(rawTtl) || 72 * 3600 * 1000, 60_000),
100 * 365 * 24 * 3600 * 1000
)
exp = now + ttlMs
}
const payload = {
v: 1,
role,
peerId: opts.peerId ? String(opts.peerId).toLowerCase() : null,
exp,
jti: opts.jti || crypto.randomBytes(16).toString('hex'),
iat: now,
}
const body = canonicalizePayload(payload)
const mac = crypto.createHmac('sha256', macKey).update(body).digest()
return { token: `${b64url(body)}.${b64url(mac)}`, payload }
}
/**
* @param {Buffer|string} macKeyOrSeed
* @param {string} token
* @param {{ peerId?: string, now?: number, allowSpentCheck?: (jti: string) => boolean }} [opts]
*/
export function verifyCapability(macKeyOrSeed, token, opts = {}) {
if (!token || typeof token !== 'string') {
return { ok: false, error: 'Missing capability token', code: 'CAPABILITY_INVALID' }
}
const parts = token.split('.')
if (parts.length !== 2) {
return { ok: false, error: 'Malformed capability token', code: 'CAPABILITY_INVALID' }
}
let body
let mac
try {
body = b64urlDecode(parts[0])
mac = b64urlDecode(parts[1])
} catch {
return { ok: false, error: 'Malformed capability encoding', code: 'CAPABILITY_INVALID' }
}
if (mac.length !== 32) {
return { ok: false, error: 'Invalid capability MAC length', code: 'CAPABILITY_INVALID' }
}
const macKey = resolveMacKey(macKeyOrSeed)
const expected = crypto.createHmac('sha256', macKey).update(body).digest()
if (!safeEqual(mac, expected)) {
return { ok: false, error: 'Capability MAC verification failed', code: 'CAPABILITY_INVALID' }
}
let payload
try {
payload = JSON.parse(body.toString('utf8'))
} catch {
return { ok: false, error: 'Capability payload not JSON', code: 'CAPABILITY_INVALID' }
}
if (payload.v !== 1) {
return { ok: false, error: 'Unsupported capability version', code: 'CAPABILITY_INVALID' }
}
if (!VALID_ROLES.has(payload.role)) {
return { ok: false, error: 'Invalid capability role', code: 'CAPABILITY_INVALID' }
}
const now = opts.now ?? Date.now()
if (payload.exp != null) {
if (typeof payload.exp !== 'number' || payload.exp < now) {
return { ok: false, error: 'Capability expired', code: 'CAPABILITY_EXPIRED' }
}
}
if (payload.peerId) {
const want = String(payload.peerId).toLowerCase()
const have = String(opts.peerId || '').toLowerCase()
if (!have || want !== have) {
return {
ok: false,
error: 'Capability bound to a different peer identity',
code: 'CAPABILITY_PEER_MISMATCH',
}
}
}
if (typeof opts.allowSpentCheck === 'function' && !opts.allowSpentCheck(payload.jti)) {
return { ok: false, error: 'Capability already used or revoked', code: 'CAPABILITY_SPENT' }
}
return {
ok: true,
payload: {
v: 1,
role: payload.role,
peerId: payload.peerId || null,
exp: payload.exp,
jti: payload.jti,
iat: payload.iat,
},
}
}
function hmacAdmin(macKey, nonce, peerId, serverPk) {
return crypto
.createHmac('sha256', macKey)
.update(ADMIN_PREFIX)
.update(Buffer.from(nonce, 'utf8'))
.update(Buffer.from(peerId, 'utf8'))
.update(Buffer.from(serverPk, 'utf8'))
.digest()
}
/**
* @param {Buffer|string} macKeyOrSeed
* @param {{ nonce?: string, peerId: string, serverPublicKeyHex: string }} opts
*/
export function createAdminProof(macKeyOrSeed, opts) {
const nonce = String(opts.nonce || crypto.randomBytes(16).toString('hex'))
if (!/^[0-9a-fA-F]{16,64}$/.test(nonce)) {
throw new Error('Admin proof nonce must be 16-64 hex characters')
}
const peerId = String(opts.peerId || '').toLowerCase()
const serverPk = String(opts.serverPublicKeyHex || '').toLowerCase()
if (!/^[0-9a-f]{64}$/.test(peerId)) throw new Error('peerId required for admin proof')
if (!/^[0-9a-f]{64}$/.test(serverPk)) throw new Error('serverPublicKeyHex required for admin proof')
const macKey = resolveMacKey(macKeyOrSeed)
const mac = hmacAdmin(macKey, nonce, peerId, serverPk)
return { nonce: nonce.toLowerCase(), mac: mac.toString('hex') }
}
/**
* @param {Buffer|string} macKeyOrSeed
* @param {{ nonce?: string, mac?: string }|null} proof
* @param {{ peerId: string, serverPublicKeyHex: string }} ctx
*/
export function verifyAdminProof(macKeyOrSeed, proof, ctx) {
if (!proof || !proof.nonce || !proof.mac) {
return { ok: false, error: 'Missing admin proof', code: 'ADMIN_PROOF_FAILED' }
}
const nonce = String(proof.nonce).toLowerCase()
const macHex = String(proof.mac).toLowerCase()
if (!/^[0-9a-f]{16,64}$/.test(nonce) || !/^[0-9a-f]{64}$/.test(macHex)) {
return { ok: false, error: 'Malformed admin proof', code: 'ADMIN_PROOF_FAILED' }
}
const peerId = String(ctx.peerId || '').toLowerCase()
const serverPk = String(ctx.serverPublicKeyHex || '').toLowerCase()
if (!/^[0-9a-f]{64}$/.test(peerId) || !/^[0-9a-f]{64}$/.test(serverPk)) {
return { ok: false, error: 'Invalid proof context', code: 'ADMIN_PROOF_FAILED' }
}
const macKey = resolveMacKey(macKeyOrSeed)
const expected = hmacAdmin(macKey, nonce, peerId, serverPk)
const got = Buffer.from(macHex, 'hex')
if (!safeEqual(got, expected)) {
return { ok: false, error: 'Admin proof verification failed', code: 'ADMIN_PROOF_FAILED' }
}
return { ok: true }
}
/**
* @param {object} pkg
* @returns {string}
*/
export function encodeInvite(pkg) {
const body = {
v: 1,
publicKeyHex: String(pkg.publicKeyHex || '').toLowerCase(),
capability: String(pkg.capability || ''),
role: pkg.role || null,
jti: pkg.jti || null,
alias: pkg.alias || null,
expiresAt: pkg.expiresAt ?? null,
}
if (!/^[0-9a-f]{64}$/.test(body.publicKeyHex)) {
throw new Error('encodeInvite: invalid publicKeyHex')
}
if (!body.capability || !body.capability.includes('.')) {
throw new Error('encodeInvite: invalid capability')
}
return `${INVITE_PREFIX}${b64url(Buffer.from(JSON.stringify(body), 'utf8'))}`
}
/**
* @param {string} invite
* @returns {{ ok: true, package: object } | { ok: false, error: string, code: string }}
*/
export function decodeInvite(invite) {
const s = String(invite || '').trim()
if (!s.startsWith(INVITE_PREFIX)) {
return { ok: false, error: 'Not a pa1 invite', code: 'INVITE_INVALID' }
}
try {
const json = b64urlDecode(s.slice(INVITE_PREFIX.length)).toString('utf8')
const pkg = JSON.parse(json)
if (!pkg?.publicKeyHex || !pkg?.capability) {
return { ok: false, error: 'Invite missing fields', code: 'INVITE_INVALID' }
}
return { ok: true, package: pkg }
} catch {
return { ok: false, error: 'Malformed invite', code: 'INVITE_INVALID' }
}
}
/**
* Classify free-form connection input (public key, invite, capability).
* @param {string} input
*/
export function classifyConnectionInput(input) {
const s = String(input || '').trim()
if (!s) return { kind: 'empty' }
if (s.startsWith(INVITE_PREFIX)) {
const dec = decodeInvite(s)
if (!dec.ok) return { kind: 'invalid', error: dec.error, code: dec.code }
return {
kind: 'invite',
publicKeyHex: dec.package.publicKeyHex,
capability: dec.package.capability,
role: dec.package.role,
alias: dec.package.alias,
}
}
if (/^[0-9a-fA-F]{64}$/.test(s)) {
return { kind: 'publicKey', publicKeyHex: s.toLowerCase() }
}
if (s.includes('.') && s.split('.').length === 2) {
return { kind: 'capability', capability: s }
}
return { kind: 'unknown', error: 'Expected 64-hex public key or pd1. invite' }
}
export function isInsecureOpenAdmin() {
const v = String(process.env.PEARDATA_INSECURE_OPEN_ADMIN || '').toLowerCase()
return v === '1' || v === 'true' || v === 'yes'
}
+128
View File
@@ -0,0 +1,128 @@
/**
* Canonical data-model helpers + JSDoc typedefs for metrics, anomalies, health.
*
* Wire shapes are JSON over protomux-rpc compact-encoding and REST /api/v*.
*/
/**
* @typedef {{
* nodeId: string,
* hostname: string,
* publicKeyHex: string,
* platform: string,
* arch: string,
* release: string,
* cpus: number,
* totalMemMiB: number,
* agentVersion: string,
* protocolVersion: number,
* startedAt: number,
* labels?: Record<string, string>,
* }} NodeInfo
*
* @typedef {{
* chart: string,
* context: string,
* ts: number,
* values: Record<string, number|null>,
* }} MetricSample
*
* @typedef {{
* chart: string,
* context: string,
* labels: string[],
* data: Array<[number, ...(number|null)[]]>,
* view_update_every?: number,
* }} QueryResult
*
* @typedef {{
* id: string,
* chart: string,
* context: string,
* dimension: string,
* severity: 'warning'|'critical',
* score: number,
* value: number,
* threshold: number,
* comparator: string,
* message: string,
* ts: number,
* cleared?: boolean,
* }} AnomalyEvent
*
* @typedef {{
* id: string,
* name: string,
* chart: string,
* dimension: string,
* status: 'CLEAR'|'WARNING'|'CRITICAL'|'UNDEFINED',
* value: number|null,
* units: string,
* info: string,
* lastStatusChange: number,
* config: AlertConfig,
* }} AlertState
*
* @typedef {{
* id: string,
* chart: string,
* dimension: string,
* warn?: number|null,
* crit?: number|null,
* comparator?: '>'|'<'|'>='|'<=',
* lookbackSec?: number,
* enabled?: boolean,
* info?: string,
* }} AlertConfig
*
* @typedef {{
* status: 'ok'|'degraded'|'critical',
* score: number,
* checks: Array<{ id: string, ok: boolean, detail: string }>,
* ts: number,
* }} HealthSnapshot
*
* @typedef {{
* id: string,
* name: string,
* status: 'queued'|'running'|'done'|'failed'|'cancelled',
* startedAt: number|null,
* finishedAt: number|null,
* result?: any,
* error?: string,
* }} JobRecord
*/
/**
* @param {Partial<MetricSample>} sample
* @returns {MetricSample}
*/
export function normalizeSample(sample) {
return {
chart: String(sample.chart || ''),
context: String(sample.context || sample.chart || ''),
ts: Number(sample.ts) || Date.now(),
values: sample.values && typeof sample.values === 'object' ? sample.values : {},
}
}
/**
* @param {Partial<AnomalyEvent>} ev
* @returns {AnomalyEvent}
*/
export function normalizeAnomaly(ev) {
return {
id: String(ev.id || `${ev.chart}:${ev.dimension}:${ev.ts}`),
chart: String(ev.chart || ''),
context: String(ev.context || ev.chart || ''),
dimension: String(ev.dimension || ''),
severity: ev.severity === 'critical' ? 'critical' : 'warning',
score: Number(ev.score) || 0,
value: Number(ev.value),
threshold: Number(ev.threshold),
comparator: String(ev.comparator || '>'),
message: String(ev.message || ''),
ts: Number(ev.ts) || Date.now(),
cleared: Boolean(ev.cleared),
}
}
+27
View File
@@ -0,0 +1,27 @@
/**
* Value encodings for protomux-rpc.
*
* Default: compact-encoding JSON (stable, human-debuggable).
* Future: swap valueEncoding per-method to hyperschema structs while
* keeping method names stable (PROTOCOL_VERSION bump when required).
*/
import c from 'compact-encoding'
import { SCHEMA_VERSION } from './schema.js'
export const json = c.json
export const raw = c.raw
export const none = c.none
export const encodings = {
valueEncoding: json,
requestEncoding: json,
responseEncoding: json,
}
export const ENCODING_PROFILE = {
name: 'json',
schemaVersion: SCHEMA_VERSION,
future: 'hyperschema per-method valueEncoding',
}
export { SCHEMA_VERSION }
+187
View File
@@ -0,0 +1,187 @@
/**
* Metric contexts, charts, and dimension catalog (Netdata-inspired).
*
* Context IDs follow Netdata style: family.metric (e.g. system.cpu).
* Chart IDs are unique per agent instance.
*/
export const SAMPLE_INTERVAL_MS = 1000
/**
* @typedef {{ id: string, name: string, algorithm: 'absolute'|'incremental', multiplier?: number, divisor?: number }} DimensionDef
* @typedef {{
* id: string,
* name: string,
* context: string,
* title: string,
* units: string,
* family: string,
* chartType: 'line'|'area'|'stacked',
* priority: number,
* dimensions: DimensionDef[],
* }} ChartDef
*/
/** @type {ChartDef[]} */
export const CHART_DEFS = [
{
id: 'system.cpu',
name: 'system.cpu',
context: 'system.cpu',
title: 'Total CPU utilization',
units: 'percentage',
family: 'cpu',
chartType: 'stacked',
priority: 100,
dimensions: [
{ id: 'user', name: 'user', algorithm: 'absolute' },
{ id: 'system', name: 'system', algorithm: 'absolute' },
{ id: 'nice', name: 'nice', algorithm: 'absolute' },
{ id: 'iowait', name: 'iowait', algorithm: 'absolute' },
{ id: 'irq', name: 'irq', algorithm: 'absolute' },
{ id: 'softirq', name: 'softirq', algorithm: 'absolute' },
{ id: 'idle', name: 'idle', algorithm: 'absolute' },
],
},
{
id: 'system.ram',
name: 'system.ram',
context: 'system.ram',
title: 'System RAM',
units: 'MiB',
family: 'memory',
chartType: 'stacked',
priority: 200,
dimensions: [
{ id: 'used', name: 'used', algorithm: 'absolute' },
{ id: 'cached', name: 'cached', algorithm: 'absolute' },
{ id: 'buffers', name: 'buffers', algorithm: 'absolute' },
{ id: 'free', name: 'free', algorithm: 'absolute' },
],
},
{
id: 'system.load',
name: 'system.load',
context: 'system.load',
title: 'System Load Average',
units: 'load',
family: 'load',
chartType: 'line',
priority: 300,
dimensions: [
{ id: 'load1', name: 'load1', algorithm: 'absolute' },
{ id: 'load5', name: 'load5', algorithm: 'absolute' },
{ id: 'load15', name: 'load15', algorithm: 'absolute' },
],
},
{
id: 'system.io',
name: 'system.io',
context: 'system.io',
title: 'Disk I/O',
units: 'KiB/s',
family: 'disk',
chartType: 'area',
priority: 400,
dimensions: [
{ id: 'reads', name: 'reads', algorithm: 'incremental' },
{ id: 'writes', name: 'writes', algorithm: 'incremental' },
],
},
{
id: 'system.net',
name: 'system.net',
context: 'system.net',
title: 'Bandwidth',
units: 'kilobits/s',
family: 'network',
chartType: 'area',
priority: 500,
dimensions: [
{ id: 'received', name: 'received', algorithm: 'incremental' },
{ id: 'sent', name: 'sent', algorithm: 'incremental' },
],
},
{
id: 'system.processes',
name: 'system.processes',
context: 'system.processes',
title: 'System Processes',
units: 'processes',
family: 'processes',
chartType: 'line',
priority: 600,
dimensions: [
{ id: 'running', name: 'running', algorithm: 'absolute' },
{ id: 'blocked', name: 'blocked', algorithm: 'absolute' },
{ id: 'total', name: 'total', algorithm: 'absolute' },
],
},
{
id: 'system.uptime',
name: 'system.uptime',
context: 'system.uptime',
title: 'System Uptime',
units: 'seconds',
family: 'uptime',
chartType: 'line',
priority: 700,
dimensions: [{ id: 'uptime', name: 'uptime', algorithm: 'absolute' }],
},
{
id: 'mem.available',
name: 'mem.available',
context: 'mem.available',
title: 'Available RAM',
units: 'MiB',
family: 'memory',
chartType: 'area',
priority: 210,
dimensions: [{ id: 'avail', name: 'avail', algorithm: 'absolute' }],
},
]
/** @type {Map<string, ChartDef>} */
export const CHART_BY_ID = new Map(CHART_DEFS.map((c) => [c.id, c]))
/** Unique context ids */
export const CONTEXT_IDS = [...new Set(CHART_DEFS.map((c) => c.context))]
/**
* @param {string} context
*/
export function chartsForContext(context) {
return CHART_DEFS.filter((c) => c.context === context)
}
/**
* Build a Netdata-ish chart summary object.
* @param {ChartDef} def
* @param {{ firstEntry?: number, lastEntry?: number, updateEvery?: number }} [meta]
*/
export function chartSummary(def, meta = {}) {
const dimensions = {}
for (const d of def.dimensions) {
dimensions[d.id] = {
name: d.name,
algorithm: d.algorithm,
multiplier: d.multiplier ?? 1,
divisor: d.divisor ?? 1,
}
}
return {
id: def.id,
name: def.name,
type: 'system',
family: def.family,
context: def.context,
title: def.title,
units: def.units,
chart_type: def.chartType,
priority: def.priority,
update_every: meta.updateEvery ?? SAMPLE_INTERVAL_MS / 1000,
first_entry: meta.firstEntry ?? 0,
last_entry: meta.lastEntry ?? 0,
dimensions,
}
}
+132
View File
@@ -0,0 +1,132 @@
/**
* PearData P2P protocol — control plane + metric streaming.
*
* PROTOCOL_VERSION is negotiated via `handshake`. Bump on breaking RPC shapes.
* Additive methods may land without a bump when clients tolerate unknown methods.
*
* Planes:
* - Control/metadata: handshake, info, ACL, invites, jobs, alerts config
* - Metrics query: contexts, charts, historical data (hot path)
* - Push streams: live samples, anomalies, health events
*/
export const PROTOCOL = 'peardata/rpc'
export const PROTOCOL_VERSION = 1
export const APP_NAME = 'peardata'
export const APP_VERSION = '0.1.0'
/** Roles for capability ACL. Secure default peer role is viewer. */
export const Roles = Object.freeze({
viewer: 'viewer',
operator: 'operator',
admin: 'admin',
})
const ROLE_RANK = {
[Roles.viewer]: 1,
[Roles.operator]: 2,
[Roles.admin]: 3,
}
/**
* @param {string} role
* @param {string} required
*/
export function roleAllows(role, required) {
const have = ROLE_RANK[role] || 0
const need = ROLE_RANK[required] || ROLE_RANK[Roles.admin]
return have >= need
}
/**
* Minimum role required per method (default admin for unknown).
*/
export const MethodRoles = Object.freeze({
// session
handshake: Roles.viewer,
ping: Roles.viewer,
getServerInfo: Roles.viewer,
getAuthStatus: Roles.viewer,
setDisplayName: Roles.viewer,
// agent / node metadata
getNodeInfo: Roles.viewer,
getHealth: Roles.viewer,
// metrics discovery + query
listContexts: Roles.viewer,
getContext: Roles.viewer,
listCharts: Roles.viewer,
getChart: Roles.viewer,
queryData: Roles.viewer,
getAllMetrics: Roles.viewer,
// live subscription control
subscribeMetrics: Roles.viewer,
unsubscribeMetrics: Roles.viewer,
subscribeAnomalies: Roles.viewer,
unsubscribeAnomalies: Roles.viewer,
// anomalies + alerts (read)
listAnomalies: Roles.viewer,
listAlerts: Roles.viewer,
getAlert: Roles.viewer,
// alerts / thresholds (write)
setAlertConfig: Roles.operator,
ackAlert: Roles.operator,
silenceAlert: Roles.operator,
// jobs (on-demand collection / maintenance)
listJobs: Roles.viewer,
runJob: Roles.operator,
cancelJob: Roles.operator,
// admin / fleet
mintInvite: Roles.admin,
listPeers: Roles.admin,
revokePeer: Roles.admin,
exportSnapshot: Roles.admin,
})
/**
* String method names for typed client calls.
*/
export const Methods = Object.freeze(
Object.fromEntries(Object.keys(MethodRoles).map((k) => [k, k]))
)
/**
* Server → client push event names (protomux-rpc events).
*/
export const Pushes = Object.freeze({
metrics: 'push:metrics',
anomaly: 'push:anomaly',
alert: 'push:alert',
health: 'push:health',
job: 'push:job',
system: 'push:system',
})
/**
* Map push channel → UI event type.
*/
export const PushToType = Object.freeze({
[Pushes.metrics]: 'metrics',
[Pushes.anomaly]: 'anomaly',
[Pushes.alert]: 'alert',
[Pushes.health]: 'health',
[Pushes.job]: 'job',
[Pushes.system]: 'system',
})
/** Methods treated as hot-path (skip heavy audit / schema). */
export const HotMethods = Object.freeze(
new Set([
Methods.ping,
Methods.queryData,
Methods.subscribeMetrics,
Methods.unsubscribeMetrics,
Methods.getAllMetrics,
])
)
+144
View File
@@ -0,0 +1,144 @@
/**
* Lightweight arg validation for PearData RPCs.
*/
export const SCHEMA_VERSION = 1
/**
* @typedef {{ ok: true, args: object } | { ok: false, error: string }} ValidateResult
*/
/**
* @param {string} method
* @param {object} args
* @returns {ValidateResult}
*/
export function validateMethodArgs(method, args = {}) {
if (args == null || typeof args !== 'object' || Array.isArray(args)) {
return { ok: false, error: 'Arguments must be a plain object' }
}
switch (method) {
case 'handshake':
case 'ping':
case 'getServerInfo':
case 'getAuthStatus':
case 'getNodeInfo':
case 'getHealth':
case 'listContexts':
case 'listCharts':
case 'listAnomalies':
case 'listAlerts':
case 'listJobs':
case 'listPeers':
return { ok: true, args }
case 'setDisplayName': {
const name = String(args.name ?? '').trim()
if (!name) return { ok: false, error: 'name is required' }
if (name.length > 40) return { ok: false, error: 'name must be ≤ 40 characters' }
return { ok: true, args: { ...args, name } }
}
case 'getContext':
case 'getChart':
case 'getAlert': {
const id = String(args.id || args.chart || args.context || '').trim()
if (!id) return { ok: false, error: 'id is required' }
return { ok: true, args: { ...args, id } }
}
case 'queryData': {
const chart = String(args.chart || args.context || '').trim()
if (!chart) return { ok: false, error: 'chart or context is required' }
const points = args.points == null ? 60 : Number(args.points)
if (!Number.isFinite(points) || points < 1 || points > 10_000) {
return { ok: false, error: 'points must be 1..10000' }
}
return {
ok: true,
args: {
...args,
chart,
points,
after: args.after != null ? Number(args.after) : -points,
before: args.before != null ? Number(args.before) : 0,
group: String(args.group || 'average'),
format: String(args.format || 'json'),
},
}
}
case 'subscribeMetrics': {
const charts = Array.isArray(args.charts)
? args.charts.map(String)
: args.chart
? [String(args.chart)]
: ['*']
const intervalMs = Math.max(500, Number(args.intervalMs) || 1000)
return { ok: true, args: { ...args, charts, intervalMs } }
}
case 'unsubscribeMetrics':
case 'unsubscribeAnomalies':
return { ok: true, args }
case 'subscribeAnomalies':
return { ok: true, args }
case 'setAlertConfig': {
const id = String(args.id || '').trim()
if (!id) return { ok: false, error: 'id is required' }
return { ok: true, args: { ...args, id } }
}
case 'ackAlert':
case 'silenceAlert': {
const id = String(args.id || '').trim()
if (!id) return { ok: false, error: 'id is required' }
return { ok: true, args: { ...args, id } }
}
case 'runJob': {
const name = String(args.name || args.job || '').trim()
if (!name) return { ok: false, error: 'name is required' }
return { ok: true, args: { ...args, name } }
}
case 'cancelJob': {
const id = String(args.id || '').trim()
if (!id) return { ok: false, error: 'id is required' }
return { ok: true, args: { ...args, id } }
}
case 'mintInvite': {
const role = String(args.role || 'operator').toLowerCase()
if (!['viewer', 'operator', 'admin'].includes(role)) {
return { ok: false, error: 'role must be viewer|operator|admin' }
}
return { ok: true, args: { ...args, role } }
}
case 'revokePeer': {
const peerId = String(args.peerId || '').toLowerCase()
if (!/^[0-9a-f]{64}$/.test(peerId)) {
return { ok: false, error: 'peerId must be 64 hex characters' }
}
return { ok: true, args: { ...args, peerId } }
}
case 'getAllMetrics': {
const format = String(args.format || 'json').toLowerCase()
if (!['json', 'prometheus', 'shell'].includes(format)) {
return { ok: false, error: 'format must be json|prometheus|shell' }
}
return { ok: true, args: { ...args, format } }
}
case 'exportSnapshot':
return { ok: true, args }
default:
return { ok: true, args }
}
}
+20
View File
@@ -0,0 +1,20 @@
import test from 'brittle'
import { Roles, roleAllows, MethodRoles } from '../shared/protocol.js'
import { assertAllowed, maxRole } from '../server/core/acl.js'
test('assertAllowed allows operator postMessage', (t) => {
t.exception(() => assertAllowed(Roles.viewer, 'postMessage'))
assertAllowed(Roles.operator, 'postMessage')
assertAllowed(Roles.admin, 'clearMessages')
t.pass()
})
test('maxRole elevates', (t) => {
t.is(maxRole(Roles.viewer, Roles.operator), Roles.operator)
t.is(maxRole(Roles.admin, Roles.operator), Roles.admin)
})
test('viewer can read', (t) => {
t.ok(roleAllows(Roles.viewer, MethodRoles.listMessages))
t.ok(roleAllows(Roles.viewer, MethodRoles.handshake))
})
+101
View File
@@ -0,0 +1,101 @@
import test from 'brittle'
import {
deriveMacKey,
signCapability,
verifyCapability,
createAdminProof,
verifyAdminProof,
encodeInvite,
decodeInvite,
classifyConnectionInput,
safeEqual,
} from '../shared/crypto-auth.js'
import { Roles } from '../shared/protocol.js'
const SEED = 'ab'.repeat(32)
const SEED2 = 'cd'.repeat(32)
const PEER = '11'.repeat(32)
const SERVER_PK = '22'.repeat(32)
test('deriveMacKey is deterministic and differs by seed', (t) => {
const a = deriveMacKey(SEED)
const b = deriveMacKey(SEED)
const c = deriveMacKey(SEED2)
t.is(a.length, 32)
t.ok(safeEqual(a, b))
t.absent(safeEqual(a, c))
})
test('sign and verify capability', (t) => {
const { token, payload } = signCapability(SEED, {
role: Roles.operator,
ttlMs: 3600_000,
})
t.ok(token.includes('.'))
t.is(payload.role, Roles.operator)
const res = verifyCapability(SEED, token, { peerId: PEER })
t.ok(res.ok)
t.is(res.payload.role, Roles.operator)
})
test('tampered capability fails', (t) => {
const { token } = signCapability(SEED, { role: Roles.admin, ttlMs: 3600_000 })
const [body, mac] = token.split('.')
const flipped = body.slice(0, -1) + (body.endsWith('A') ? 'B' : 'A') + '.' + mac
const res = verifyCapability(SEED, flipped)
t.absent(res.ok)
t.is(res.code, 'CAPABILITY_INVALID')
})
test('expired capability fails', (t) => {
const { token } = signCapability(SEED, { role: Roles.viewer, ttlMs: 60_000 })
const res = verifyCapability(SEED, token, { now: Date.now() + 120_000 })
t.absent(res.ok)
t.is(res.code, 'CAPABILITY_EXPIRED')
})
test('persistent capability never expires', (t) => {
const { token, payload } = signCapability(SEED, {
role: Roles.operator,
forever: true,
})
t.is(payload.exp, null)
const res = verifyCapability(SEED, token, {
now: Date.now() + 100 * 365 * 24 * 3600 * 1000,
})
t.ok(res.ok)
})
test('admin proof round-trip', (t) => {
const proof = createAdminProof(SEED, {
peerId: PEER,
serverPublicKeyHex: SERVER_PK,
})
const ok = verifyAdminProof(SEED, proof, {
peerId: PEER,
serverPublicKeyHex: SERVER_PK,
})
t.ok(ok.ok)
const bad = verifyAdminProof(SEED2, proof, {
peerId: PEER,
serverPublicKeyHex: SERVER_PK,
})
t.absent(bad.ok)
})
test('invite encode/decode + classify', (t) => {
const { token } = signCapability(SEED, { role: Roles.operator, forever: true })
const invite = encodeInvite({
publicKeyHex: SERVER_PK,
capability: token,
role: Roles.operator,
})
t.ok(invite.startsWith('pd1.'))
const dec = decodeInvite(invite)
t.ok(dec.ok)
t.is(dec.package.publicKeyHex, SERVER_PK)
const cls = classifyConnectionInput(invite)
t.is(cls.kind, 'invite')
t.is(cls.publicKeyHex, SERVER_PK)
t.is(classifyConnectionInput(SERVER_PK).kind, 'publicKey')
})
+92
View File
@@ -0,0 +1,92 @@
/**
* End-to-end: ephemeral HyperDHT server + client RPC + push.
* Runs against real DHT (local, no bootstrap dependency for same-process? )
*
* hyperdht connect needs the DHT network; same-machine servers work via
* the default bootstrap / local discovery. May be slow on restricted networks.
*
* Skip with: SKIP_INTEGRATION=1 npm test
*/
import test from 'brittle'
import DHT from 'hyperdht'
import b4a from 'b4a'
import crypto from 'hypercore-crypto'
import { PeerSession } from '../server/rpc/session.js'
import { registerAllHandlers, cleanupSession } from '../server/rpc/register.js'
import { peers } from '../server/core/peer-registry.js'
import { initAuthKeys } from '../server/core/auth-keys.js'
import { PearDataConnection } from '../client/connection.js'
import { Methods, Pushes } from '../shared/protocol.js'
import { signCapability } from '../shared/crypto-auth.js'
const skip = process.env.SKIP_INTEGRATION === '1'
test('integration: dial, handshake, post, push', { skip, timeout: 60_000 }, async (t) => {
const seed = crypto.randomBytes(32)
const keyPair = DHT.keyPair(seed)
const publicKeyHex = b4a.toString(keyPair.publicKey, 'hex')
const seedHex = b4a.toString(seed, 'hex')
initAuthKeys({ seedHex, publicKeyHex })
// Open admin for this test process
process.env.PEARDATA_INSECURE_OPEN_ADMIN = '1'
const dht = new DHT()
const server = dht.createServer()
server.on('connection', (socket) => {
const session = new PeerSession(socket, {
serverPublicKey: keyPair.publicKey,
onClose: (s) => {
cleanupSession(s)
peers.remove(s.id)
},
})
registerAllHandlers(session)
peers.add(session)
})
await server.listen(keyPair)
t.teardown(async () => {
for (const s of peers.list()) s.destroy()
await server.close().catch(() => {})
await dht.destroy().catch(() => {})
delete process.env.PEARDATA_INSECURE_OPEN_ADMIN
})
const conn = new PearDataConnection(publicKeyHex, {
adminSeed: seedHex,
timeoutMs: 45_000,
})
/** @type {object[]} */
const pushes = []
conn.on(Pushes.message, (m) => pushes.push(m))
await conn.connect()
t.is(conn.role, 'admin')
t.ok(conn.connected)
const pong = await conn.request(Methods.ping, {})
t.ok(pong.ok)
await conn.request(Methods.setDisplayName, { name: 'tester' })
const post = await conn.request(Methods.postMessage, { text: 'hello p2p' })
t.ok(post.success)
t.is(post.message.text, 'hello p2p')
// Allow push delivery
await new Promise((r) => setTimeout(r, 200))
const list = await conn.request(Methods.listMessages, {})
t.ok(list.messages.some((m) => m.text === 'hello p2p'))
const invite = await conn.request(Methods.mintInvite, { role: 'operator', ttlMs: 3600_000 })
t.ok(invite.invite.startsWith('pd1.'))
// Capability can be verified offline
const cap = signCapability(seedHex, { role: 'viewer', forever: true })
t.ok(cap.token.includes('.'))
await conn.destroy()
})
+40
View File
@@ -0,0 +1,40 @@
import test from 'brittle'
import {
Roles,
roleAllows,
MethodRoles,
PROTOCOL,
PROTOCOL_VERSION,
} from '../shared/protocol.js'
import { validateMethodArgs, SCHEMA_VERSION } from '../shared/schema.js'
test('protocol constants', (t) => {
t.ok(PROTOCOL.includes('/rpc'))
t.ok(PROTOCOL_VERSION >= 1)
t.ok(SCHEMA_VERSION >= 1)
})
test('roleAllows hierarchy', (t) => {
t.ok(roleAllows(Roles.admin, Roles.viewer))
t.ok(roleAllows(Roles.operator, Roles.viewer))
t.ok(roleAllows(Roles.operator, Roles.operator))
t.absent(roleAllows(Roles.viewer, Roles.operator))
t.absent(roleAllows(Roles.viewer, Roles.admin))
})
test('method roles map covers core methods', (t) => {
for (const m of ['handshake', 'ping', 'listMessages', 'postMessage', 'mintInvite']) {
t.ok(MethodRoles[m], m)
}
})
test('validateMethodArgs postMessage', (t) => {
t.absent(validateMethodArgs('postMessage', {}).ok)
t.ok(validateMethodArgs('postMessage', { text: 'hi' }).ok)
t.absent(validateMethodArgs('postMessage', { text: 'x'.repeat(2001) }).ok)
})
test('validateMethodArgs setDisplayName', (t) => {
t.ok(validateMethodArgs('setDisplayName', { name: 'Ada' }).ok)
t.absent(validateMethodArgs('setDisplayName', { name: '' }).ok)
})
+467
View File
@@ -0,0 +1,467 @@
:root {
--bg: #0b1020;
--panel: #121a2f;
--panel-2: #18223c;
--border: #243154;
--text: #e8eefc;
--muted: #8b9bb8;
--accent: #5b8cff;
--accent-2: #3dd6c6;
--danger: #ff6b7a;
--ok: #3ecf8e;
--radius: 12px;
--font: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
/* Keep in sync with pear-ctrl / traffic-light alignment */
--titlebar-h: 42px;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
height: 100%;
overflow: hidden;
background: radial-gradient(1200px 600px at 10% -10%, #1a2748 0%, var(--bg) 55%);
color: var(--text);
font-family: var(--font);
-webkit-font-smoothing: antialiased;
}
body {
display: flex;
flex-direction: column;
}
/* ─── Pear titlebar (drag + pear-ctrl window chrome) ─── */
#titlebar {
position: fixed;
top: 0;
left: 0;
right: 0;
width: 100%;
height: var(--titlebar-h);
z-index: 1100;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 12px 0 8px;
gap: 0;
background: rgba(11, 16, 32, 0.88);
border-bottom: 1px solid var(--border);
backdrop-filter: blur(12px) saturate(1.3);
-webkit-backdrop-filter: blur(12px) saturate(1.3);
/* Entire bar is a window drag surface */
-webkit-app-region: drag;
user-select: none;
overflow: visible;
}
/* Interactive / chrome children must not start a drag */
#titlebar > *,
#titlebar .titlebar-left,
#titlebar .titlebar-right,
#titlebar pear-ctrl,
#titlebar .app-brand,
#titlebar .chip,
#titlebar button,
#titlebar a,
#titlebar input {
-webkit-app-region: no-drag;
}
.titlebar-left {
display: flex;
align-items: center;
gap: 10px;
flex: 0 1 auto;
min-width: 0;
max-width: 70%;
z-index: 2;
}
.titlebar-right {
display: flex;
align-items: center;
gap: 10px;
flex: 0 0 auto;
margin-left: auto;
z-index: 2;
}
/* pear-ctrl: runtime custom element (darwin traffic lights / win+linux buttons) */
#titlebar pear-ctrl {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: flex-start;
position: relative;
z-index: 5;
line-height: 0;
min-width: 54px;
min-height: 28px;
margin: 0;
padding: 0 4px;
overflow: visible;
-webkit-app-region: no-drag;
}
#titlebar pear-ctrl[data-platform='darwin'] {
min-width: 78px;
margin: 0 8px 0 8px;
}
#titlebar pear-ctrl[data-platform='linux'],
#titlebar pear-ctrl[data-platform='win32'] {
min-width: 110px;
gap: 2px;
}
/* Standalone selectors (matches pear-bridge / pearcord conventions) */
pear-ctrl[data-platform='darwin'] {
margin-top: 0;
}
.no-drag {
-webkit-app-region: no-drag;
}
.app-brand {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
/* Brand is visual only; keep drag on the bar around it */
pointer-events: none;
-webkit-app-region: drag;
}
.app-brand .logo {
width: 26px;
height: 26px;
display: grid;
place-items: center;
border-radius: 8px;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
color: #061018;
font-weight: 700;
font-size: 13px;
flex-shrink: 0;
}
.app-brand-text {
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.app-brand-text strong {
display: block;
font-size: 13px;
font-weight: 650;
letter-spacing: -0.02em;
line-height: 1.15;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.app-brand-text .muted {
font-size: 11px;
line-height: 1.15;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Main app fills remaining viewport under fixed titlebar */
#app {
margin-top: var(--titlebar-h);
height: calc(100vh - var(--titlebar-h));
min-height: 0;
display: flex;
flex-direction: column;
overflow: auto;
/* Content must never steal window drag from the titlebar */
-webkit-app-region: no-drag;
}
.muted {
color: var(--muted);
font-size: 12px;
}
.chip {
font-size: 11px;
padding: 5px 10px;
border-radius: 999px;
border: 1px solid var(--border);
text-transform: uppercase;
letter-spacing: 0.04em;
-webkit-app-region: no-drag;
flex-shrink: 0;
}
.chip.offline {
color: var(--danger);
border-color: rgba(255, 107, 122, 0.35);
}
.chip.online {
color: var(--ok);
border-color: rgba(62, 207, 142, 0.35);
}
.layout {
display: grid;
grid-template-columns: 300px 1fr 280px;
gap: 16px;
padding: 16px;
flex: 1;
min-height: 0;
}
@media (max-width: 1000px) {
.layout {
grid-template-columns: 1fr;
}
}
.panel {
background: linear-gradient(180deg, var(--panel) 0%, var(--panel-2) 100%);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px;
min-height: 0;
}
.panel h2 {
margin: 0 0 10px;
font-size: 14px;
letter-spacing: 0.02em;
text-transform: uppercase;
color: #c5d4f5;
}
.hint {
font-size: 12px;
color: var(--muted);
line-height: 1.45;
}
label {
display: block;
font-size: 12px;
color: var(--muted);
margin: 10px 0;
}
input,
textarea,
button {
font: inherit;
}
input,
textarea {
width: 100%;
margin-top: 6px;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--border);
background: #0d1426;
color: var(--text);
outline: none;
-webkit-app-region: no-drag;
}
input:focus,
textarea:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(91, 140, 255, 0.15);
}
textarea {
resize: vertical;
font-family: var(--mono);
font-size: 12px;
}
.row {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 10px;
}
button {
border: 1px solid var(--border);
background: #1a2540;
color: var(--text);
border-radius: 10px;
padding: 10px 14px;
cursor: pointer;
-webkit-app-region: no-drag;
}
button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
button.primary {
background: linear-gradient(135deg, #4d7fff, #3aa9ff);
border-color: transparent;
color: #061018;
font-weight: 600;
}
button.ghost {
background: transparent;
}
button.danger {
color: var(--danger);
border-color: rgba(255, 107, 122, 0.35);
}
.meta {
margin-top: 12px;
font-family: var(--mono);
font-size: 11px;
word-break: break-all;
}
.room-panel {
display: flex;
flex-direction: column;
min-height: 320px;
}
.room-head {
display: flex;
justify-content: space-between;
align-items: center;
}
.badge {
font-size: 11px;
padding: 4px 8px;
border-radius: 999px;
border: 1px solid var(--border);
color: var(--accent-2);
}
.messages {
flex: 1;
overflow: auto;
border: 1px solid var(--border);
border-radius: 10px;
background: #0a1224;
padding: 12px;
margin: 8px 0 12px;
display: flex;
flex-direction: column;
gap: 10px;
min-height: 160px;
}
.msg {
padding: 8px 10px;
border-radius: 10px;
background: #121c34;
border: 1px solid rgba(36, 49, 84, 0.8);
}
.msg .who {
font-size: 11px;
color: var(--accent-2);
margin-bottom: 4px;
}
.msg .body {
font-size: 14px;
line-height: 1.4;
white-space: pre-wrap;
word-break: break-word;
}
.msg .when {
margin-top: 4px;
font-size: 10px;
color: var(--muted);
}
.composer {
display: flex;
gap: 8px;
}
.composer input {
margin: 0;
}
.admin-actions {
margin-top: 10px;
}
.invite-out {
margin-top: 10px;
padding: 10px;
border-radius: 10px;
border: 1px dashed var(--border);
background: #0a1224;
font-family: var(--mono);
font-size: 11px;
white-space: pre-wrap;
word-break: break-all;
max-height: 120px;
overflow: auto;
}
.invite-out.hidden {
display: none;
}
.presence {
list-style: none;
padding: 0;
margin: 0 0 16px;
}
.presence li {
display: flex;
justify-content: space-between;
gap: 8px;
padding: 8px 0;
border-bottom: 1px solid rgba(36, 49, 84, 0.7);
font-size: 12px;
}
.server-info,
.log {
font-family: var(--mono);
font-size: 11px;
background: #0a1224;
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px;
max-height: 160px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
}
.log {
max-height: 200px;
}
code {
font-family: var(--mono);
font-size: 11px;
color: #b7c9f5;
}