OverviewFilesCommitsIssuesDocsReleasesPeers

p2pbuilders commits / e41f0ac

Add browser build under web/ (PearBrowser P2P site)
Ports the terminal P2P Hacker News to a browser P2P site reusing peerit's gossip/crypto engine. Proof-of-work spam gating, reputation-weighted votes, boards, threaded comments, follow/block, subscribable blocklists. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
e41f0ac85c259b51a42ef23e0171428b6300afb9
by bigdestiny2 <defidon@protonmail.com> · 2026-06-23T16:13:28.000Z

Changed files (25) +2756 -0

README.md +14 -0
……@@ -191,3 +191,17 @@ node scripts/publish.js # publish ./public as a hyperdrive
191191## License
192192
193193Apache 2.0. See [LICENSE](./LICENSE).
194
195---
196
197## `web/` — browser build (PearBrowser P2P site)
198
199A full browser port of p2pbuilders now lives in [`web/`](web/) — it runs as a
200**PearBrowser P2P site** (no terminal required), reusing peerit's gossip/crypto
201engine. Same permissionless model: **proof-of-work** spam gating,
202**reputation-weighted** votes, boards, threaded comments, follow/block, and
203subscribable blocklists. See [`web/README.md`](web/README.md).
204
205- Run locally: `cd web && node dev-server.mjs` → http://localhost:8778
206- Tests: `cd web && node test/engine.mjs`
207- Live: `hyper://ac1977a75cc84b46af0af8bb559cd4ebbe10507eb0f51d863e289d09635f6d74/` (open in PearBrowser)
web/.gitignore +7 -0
……@@ -0,0 +1,7 @@
1.DS_Store
2.claude/
3.hiverelay-local/
4.hiverelay-seed/
5.catalog-publish-*/
6node_modules/
7*.log
web/README.md +91 -0
……@@ -0,0 +1,91 @@
1# p2pbuilders (browser)
2
3Permissionless peer-to-peer **Hacker News** for anons who build P2P — ported from
4the terminal app to a **PearBrowser P2P site**. No servers, no accounts. Posts are
5**proof-of-work** gated, votes are **reputation-weighted**, and boards, threaded
6comments, follows and subscribable blocklists replicate directly between peers.
7
8```
9hyper://<driveKey>/ ← open in PearBrowser
10```
11
12It reuses **peerit's gossip engine verbatim** — the signature-authority + per-user-
13outbox + gossip-merge layer is app-agnostic. p2pbuilders only adds an HN schema,
14PoW, reputation weighting, the social graph, and a Hacker-News UI.
15
16## What's shared vs. new
17
18| Reused from peerit (unchanged) | New for p2pbuilders |
19|---|---|
20| `crypto.js` (Ed25519), `verify.js`, `sync.js`, `gossip.js`, `identity.js`, `markdown.js`, `util.js` | `canon.js`/`model.js` (HN schema), `pow.js`, `reputation.js`, `ranking.js`, `data.js`, `prefs.js`, `app.js` |
21
22The engine gained one generic extension — a `validate` admit hook — so PoW can
23gate ingestion without making the engine app-specific.
24
25## How it works
26
27- **Identity / authenticity:** every record is Ed25519-signed; the gossip merge
28 honors a record only if its signature verifies, its signer is its claimed author,
29 and its storage key matches its fields. The transport carries no authority. (Same
30 model peerit was hardened to after a multi-agent audit.)
31- **Proof-of-work (`pow.js`):** submitting a post/comment/board requires finding a
32 SHA-256 nonce with N leading zero bits (post 16, comment 14, board 18). The proof
33 binds to the op's immutable identity (cid/author/createdAt), is part of the signed
34 record, and is **re-verified by every peer on ingest** via the engine's `validate`
35 hook — so unworked posts never enter the network. Cheap once (~0.2s), costly to spam.
36- **Reputation-weighted votes (`reputation.js`):** a vote's weight scales with the
37 voter's age + received upvotes (`clamp(log2(1+ageDays)·√(1+received)/50, 0.02, 1)`),
38 so fresh keys count but barely move ranking. Displayed "points" are raw net votes;
39 ranking uses the weighted score.
40- **HN ranking (`ranking.js`):** `(score+1)^0.8 / (hoursOld+2)^1.8`.
41- **Boards** are first-creator-sticky names (can't be hijacked once established).
42- **Social graph:** published `follow`, public `block`, and subscribable `blocklist`
43 records; subscribing to a curator's blocklist hides those keys for you.
44
45## Schema (keys for the gossip generic reducer)
46
47```
48board board!<name> (sticky; creator owns it)
49post post!<board>!<cid>
50comment comment!<postCid>!<cid> (parentCid threads it)
51vote vote!<targetCid>!<voter> (one per voter, LWW)
52profile profile!<author>
53follow follow!<author>!<target>
54block block!<author>!<target>
55blocklist blocklist!<author>
56```
57
58## Run it
59
60```bash
61cd 02-apps/p2pbuilders
62node dev-server.mjs # serves public files on 127.0.0.1:8778 (no-store)
63# open http://localhost:8778 — click "load demo", use the account menu → switch user to simulate peers
64```
65
66In PearBrowser (real bridge): `node publish.mjs --local` prints a `hyper://` key and
67hosts the drive; open it in PearBrowser. The same code runs on `window.pear.sync/
68identity/swarm` (the `BridgeGossipSync` path) — the status chip shows `gossip-bridge`.
69
70## Test
71
72```bash
73node test/engine.mjs # 24 checks: PoW gate, reputation, HN ranking, gossip data flow,
74 # sticky boards, follow/blocklist, profiles
75```
76
77## Publish (outward-facing — run deliberately)
78
79```bash
80node publish.mjs # publish + seed to live HiveRelay + register in catalog
81KEEP=1 node publish.mjs # stay online so relays anchor the drive
82```
83
84## Honest limitations
85- **Sybil:** identities are free, so reputation weighting *blunts* but doesn't
86 eliminate ballot-stuffing. PoW raises the cost of mass posting; neither is a wall.
87- **Board-name squatting:** an established board can't be hijacked, but the first
88 claimant of a brand-new name wins it (no global naming authority in pure gossip).
89- **Dev fallback:** a browser without SubtleCrypto Ed25519 runs an insecure
90 cooperative mode (status chip shows it). PearBrowser / modern browsers / Node 20+
91 all enforce signatures.
web/dev-server.mjs +30 -0
……@@ -0,0 +1,30 @@
1// dev-server.mjs — tiny static server for local preview ONLY. Serves just the
2// public app files with correct JS MIME (ES modules) and `Cache-Control: no-store`
3// so edits always reload. Default port 8778 (peerit uses 8777). Not published.
4import http from 'node:http'
5import { readFile } from 'node:fs/promises'
6import { extname, join, normalize, relative, isAbsolute } from 'node:path'
7import { fileURLToPath } from 'node:url'
8
9const ROOT = fileURLToPath(new URL('.', import.meta.url)).replace(/\/$/, '')
10const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.json': 'application/json' }
11const PORT = Number(process.env.PORT) || 8778
12const HOST = process.env.HOST || '127.0.0.1'
13const PUBLIC_FILES = new Set(['/index.html', '/styles.css', '/icon.svg', '/manifest.json'])
14
15const isPublicPath = (p) => PUBLIC_FILES.has(p) || /^\/js\/[a-z0-9-]+\.js$/i.test(p)
16const isInsideRoot = (file) => { const rel = relative(ROOT, file); return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)) }
17
18http.createServer(async (req, res) => {
19 let p
20 try { p = decodeURIComponent((req.url || '/').split('?')[0]) } catch { res.writeHead(400); return res.end('bad request') }
21 if (p === '/' || p === '') p = '/index.html'
22 if (!isPublicPath(p) || p.includes('\0')) { res.writeHead(404); return res.end('not found') }
23 const file = normalize(join(ROOT, p.replace(/^\/+/, '')))
24 if (!isInsideRoot(file)) { res.writeHead(403); return res.end('forbidden') }
25 try {
26 const data = await readFile(file)
27 res.writeHead(200, { 'Content-Type': MIME[extname(file)] || 'application/octet-stream', 'Cache-Control': 'no-store, max-age=0', 'X-Content-Type-Options': 'nosniff' })
28 res.end(data)
29 } catch { res.writeHead(404); res.end('not found') }
30}).listen(PORT, HOST, () => console.log(`p2pbuilders dev server on http://${HOST}:${PORT} (public files only, no-store)`))
web/icon.svg +11 -0
……@@ -0,0 +1,11 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
2 <rect width="64" height="64" rx="14" fill="#ff6a1a"/>
3 <!-- three connected builder-nodes + an upvote, terminal-style -->
4 <g fill="none" stroke="#0b0d10" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
5 <path d="M18 42 L46 42 M18 42 L32 18 M46 42 L32 18" opacity="0.45"/>
6 </g>
7 <circle cx="32" cy="18" r="5.5" fill="#0b0d10"/>
8 <circle cx="18" cy="42" r="5.5" fill="#0b0d10"/>
9 <circle cx="46" cy="42" r="5.5" fill="#0b0d10"/>
10 <path d="M32 52 L32 34 M32 34 L26 40 M32 34 L38 40" stroke="#fff" stroke-width="3.4" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
11</svg>
web/index.html +19 -0
……@@ -0,0 +1,19 @@
1<!DOCTYPE html>
2<html lang="en">
3<head>
4 <meta charset="UTF-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 <title>p2pbuilders — peer-to-peer hacker news</title>
7 <meta name="description" content="Permissionless peer-to-peer Hacker News for anons who build P2P. No servers, no accounts. Posts are proof-of-work gated and reputation-weighted; communities, comments and votes replicate directly between peers. A PearBrowser P2P site.">
8 <link rel="icon" type="image/svg+xml" href="icon.svg">
9 <link rel="stylesheet" href="styles.css">
10</head>
11<body>
12 <div class="boot">
13 <div class="boot-mark">Y</div>
14 <div class="boot-name">p2pbuilders</div>
15 <div class="boot-sub">connecting to peers…</div>
16 </div>
17 <script type="module" src="js/app.js"></script>
18</body>
19</html>
web/js/app.js +462 -0
……@@ -0,0 +1,462 @@
1// app.js — p2pbuilders UI. A Hacker-News-style front end over the reused gossip
2// engine. Hash router + event delegation; PoW minting shows live progress; votes
3// are reputation-weighted; boards, profiles, follow/block, and subscribable
4// blocklists are all here. Runs identically on the PearBrowser bridge and the
5// localStorage dev-fallback.
6
7import { createSync } from './sync.js'
8import { createIdentity } from './identity.js'
9import { createData } from './data.js'
10import { Prefs } from './prefs.js'
11import { makeValidator, MIN_BITS } from './pow.js'
12import { renderMarkdown } from './markdown.js'
13import { sortPosts, sortComments, POST_SORTS, COMMENT_SORTS } from './ranking.js'
14import { buildCommentTree, sortCommentTree, countDescendants, DEFAULT_BOARD, normalizeBoard } from './model.js'
15import { escapeHtml as esc, timeAgo, fmtCount, parseRoute, buildRoute, shortKey, colorFor, debounce } from './util.js'
16
17let sync, identity, data, prefs
18let renderToken = 0
19let _lastHash = ''
20const openReplies = new Set()
21const nameCache = new Map()
22
23const $ = (s, r = document) => r.querySelector(s)
24const app = () => $('#app')
25const nameOf = (pub) => nameCache.get(pub) || ('anon-' + String(pub || '?').slice(0, 6))
26async function primeNames (pubs) {
27 await Promise.all([...new Set(pubs)].filter(p => p && !nameCache.has(p)).map(async p => nameCache.set(p, await data.nickOf(p))))
28}
29function domainOf (url) { try { return new URL(url).hostname.replace(/^www\./, '') } catch { return null } }
30function safeUrl (u) { return /^(https?:\/\/|hyper:\/\/|pear:\/\/)/i.test(String(u || '')) ? u : null }
31
32// ---- boot -------------------------------------------------------------------
33async function boot () {
34 identity = createIdentity()
35 await identity.ready()
36 sync = createSync({ getMe: () => identity.me().pubkey, identity, validate: makeValidator(MIN_BITS) })
37 await sync.ready()
38 data = createData(sync, identity, { minBits: MIN_BITS })
39 refreshPrefs()
40
41 const soft = debounce(() => {
42 const a = document.activeElement
43 if (a && (/^(INPUT|TEXTAREA|SELECT)$/.test(a.tagName) || a.isContentEditable)) return
44 if (a && a !== document.body && app() && app().contains(a)) return
45 route()
46 }, 350)
47 sync.onChange(soft)
48 sync.onChange(() => updateNetStatus())
49 setInterval(updateNetStatus, 3000)
50
51 window.addEventListener('hashchange', route)
52 document.addEventListener('click', onClick)
53 document.addEventListener('submit', onSubmit)
54 document.addEventListener('change', onChange)
55 document.addEventListener('keydown', e => {
56 if (e.key === 'Escape') { const d = $('#userdrop'); if (d && !d.hidden) { d.hidden = true; const b = $('[data-act="toggle-usermenu"]'); if (b) b.setAttribute('aria-expanded', 'false') } }
57 })
58
59 renderChrome()
60 if (!location.hash) location.hash = '#/'
61 route()
62}
63function refreshPrefs () { prefs = new Prefs(typeof localStorage !== 'undefined' ? localStorage : null, identity.me().pubkey) }
64
65// ---- chrome -----------------------------------------------------------------
66function renderChrome () {
67 document.body.innerHTML = `
68 <header class="topbar">
69 <a class="brand" href="#/"><span class="brand-mark">Y</span><span class="brand-name">p2pbuilders</span></a>
70 <nav class="topnav">
71 <a href="#/">new</a><a href="#/?sort=hot">hot</a><a href="#/?sort=top">top</a>
72 <a href="#/boards">boards</a><a href="#/submit">submit</a>
73 </nav>
74 <form class="search" data-form="search"><input name="q" placeholder="search" autocomplete="off"></form>
75 <div class="usermenu" id="usermenu"></div>
76 </header>
77 <main class="wrap"><section id="app"></section></main>
78 <div id="toasts" class="toasts"></div>
79 <button id="netstatus" class="netstatus" data-act="netstatus" title="P2P sync status">…</button>`
80 renderUserMenu(); updateNetStatus()
81}
82
83async function renderUserMenu () {
84 const me = identity.me(); await primeNames([me.pubkey])
85 const el = $('#usermenu'); if (!el) return
86 el.innerHTML = `
87 <button class="user-pill" data-act="toggle-usermenu" aria-haspopup="menu" aria-label="Account menu">
88 <span class="avatar" style="background:${colorFor(me.pubkey)}"></span><span class="uname">${esc(nameOf(me.pubkey))}</span>
89 </button>
90 <div class="dropdown" id="userdrop" role="menu" hidden>
91 <a role="menuitem" href="#/u/${esc(me.pubkey)}">profile</a>
92 <a role="menuitem" href="#/submit">submit</a>
93 <a role="menuitem" href="#/boards">boards</a>
94 <a role="menuitem" href="#/blocklists">blocklists</a>
95 <a role="menuitem" href="#/settings">settings</a>
96 ${identity.isDev ? '<div class="dd-sep"></div>' + devSwitcher() : ''}
97 </div>`
98}
99function devSwitcher () {
100 const me = identity.me().pubkey
101 return '<div class="dd-label">dev: switch user</div>' + identity.listUsers().map(u =>
102 `<button class="dd-user ${u.pubkey === me ? 'active' : ''}" data-act="switch-user" data-pub="${esc(u.pubkey)}"><span class="avatar sm" style="background:${colorFor(u.pubkey)}"></span>${esc(u.label || ('anon-' + u.pubkey.slice(0, 6)))}</button>`
103 ).join('') + '<button class="dd-user new" data-act="new-user">+ new dev user</button>'
104}
105
106async function updateNetStatus () {
107 const el = $('#netstatus'); if (!el || !sync) return
108 try {
109 const s = await sync.status(); const me = identity.me(); const secure = s.secure !== false
110 el.className = 'netstatus ' + (String(s.mode || '').includes('bridge') ? 'bridge' : (secure ? 'ok' : 'warn'))
111 el.innerHTML = `<b>${esc(s.mode || 'sync')}</b> · ${s.peers != null ? s.peers : 1}p · ${s.viewLength || 0} recs · <span class="mono">${esc((me.pubkey || '').slice(0, 6))}</span>${secure ? '' : ' ⚠'}`
112 } catch (e) { el.textContent = 'sync error' }
113}
114
115// ---- router -----------------------------------------------------------------
116function route () {
117 const { path, query } = parseRoute(location.hash)
118 const token = ++renderToken
119 const guard = h => { if (token === renderToken) app().innerHTML = h }
120 if (location.hash !== _lastHash) { _lastHash = location.hash; try { window.scrollTo(0, 0) } catch {} }
121 if (path.length === 0) return viewFeed({ board: query.board || 'all', query, guard, token })
122 switch (path[0]) {
123 case 'b':
124 if (path[2] === 'item' && path[3]) return viewItem({ board: path[1], cid: path[3], query, guard, token })
125 return viewFeed({ board: path[1], query, guard, token })
126 case 'boards': return viewBoards({ guard, token })
127 case 'submit': return viewSubmit({ query, guard, token })
128 case 'u': return viewProfile({ pub: path[1], guard, token })
129 case 'settings': return viewSettings({ guard, token })
130 case 'blocklists': return viewBlocklists({ guard, token })
131 case 'search': return viewSearch({ query, guard, token })
132 default: return guard(notFound())
133 }
134}
135
136// ---- feed -------------------------------------------------------------------
137async function viewFeed ({ board, query, guard, token }) {
138 const sort = query.sort || prefs.sort || 'hot'
139 guard(skeleton())
140 let posts = board === 'all' ? await data.listAllPosts() : await data.listPostsIn(board)
141 const blocked = await data.blockedSet([], prefs.blocklistSubs())
142 posts = posts.filter(p => !p.deleted && !blocked.has(p.author) && !prefs.isHidden(p.board + '/' + p.cid))
143 posts = await data.withTallies(posts)
144 await primeNames(posts.map(p => p.author))
145 const counts = await commentCounts(posts)
146 if (token !== renderToken) return
147 prefs.setSort(sort)
148 const ranked = sortPosts(posts, sort)
149 const tabs = `<div class="sorttabs">` + POST_SORTS.map(s => `<a class="${s === sort ? 'active' : ''}" href="${buildRoute(board === 'all' ? [] : ['b', board], { ...query, sort: s, board: board === 'all' ? 'all' : undefined })}">${s}</a>`).join('') + `</div>`
150 const head = `<div class="feed-head"><h1>${board === 'all' ? 'all builders' : 'b/' + esc(board)}</h1>${tabs}</div>`
151 let body
152 if (!ranked.length) {
153 body = `<div class="empty"><p>No posts yet.</p><a class="btn" href="#/submit">Submit the first one</a> <button class="btn ghost" data-act="seed-demo">load demo</button></div>`
154 } else {
155 body = `<ol class="feed" start="1">` + ranked.map((p, i) => postRow(p, i + 1, counts)).join('') + `</ol>`
156 }
157 guard(head + body)
158}
159
160function postRow (p, rank, counts) {
161 const ref = p.board + '/' + p.cid
162 const link = safeUrl(p.url)
163 const permalink = buildRoute(['b', p.board, 'item', p.cid])
164 const dom = link ? domainOf(link) : null
165 const cc = counts ? (counts.get(p.cid) || 0) : 0
166 const t = p.tally || { score: 0, myVote: 0 }
167 return `<li class="post" data-cid="${esc(p.cid)}" data-board="${esc(p.board)}" data-type="post">
168 <span class="rank">${rank}</span>
169 <button class="arrow ${t.myVote === 1 ? 'on' : ''}" data-act="vote" data-dir="1" aria-label="upvote">▲</button>
170 <div class="post-main">
171 <div class="title-line">
172 <a class="post-title" href="${link ? esc(link) : permalink}"${link ? ' target="_blank" rel="noopener noreferrer nofollow"' : ''}>${esc(p.title)}</a>
173 ${dom ? `<span class="domain">(${esc(dom)})</span>` : ''}
174 </div>
175 <div class="subline">
176 <span class="score">${fmtCount(t.score)} ${Math.abs(t.score) === 1 ? 'point' : 'points'}</span>
177 by <a href="#/u/${esc(p.author)}">${esc(nameOf(p.author))}</a>
178 <span class="dim">${timeAgo(p.createdAt)}</span>
179 <span class="sep">|</span> <a href="#/b/${esc(p.board)}">b/${esc(p.board)}</a>
180 <span class="sep">|</span> <a href="${permalink}">${cc} comment${cc === 1 ? '' : 's'}</a>
181 <span class="sep">|</span> <button class="linkact" data-act="hide" data-ref="${esc(ref)}">${prefs.isHidden(ref) ? 'unhide' : 'hide'}</button>
182 ${p.author === identity.me().pubkey ? `<span class="sep">|</span> <button class="linkact danger" data-act="delete-post">delete</button>` : ''}
183 </div>
184 </div>
185 </li>`
186}
187
188async function commentCounts (posts) {
189 const m = new Map()
190 await Promise.all(posts.map(async p => m.set(p.cid, await sync.count('comment!' + p.cid + '!'))))
191 return m
192}
193
194// ---- item (post + comments) -------------------------------------------------
195async function viewItem ({ board, cid, query, guard, token }) {
196 guard(skeleton())
197 const post = await data.getPost(board, cid)
198 if (!post) return guard(notFound('That post is gone.'))
199 const csort = query.csort || 'best'
200 const [pw] = await data.withTallies([post])
201 let comments = (await data.listComments(cid)).filter(Boolean)
202 comments = await data.withTallies(comments)
203 await primeNames([post.author, ...comments.map(c => c.author)])
204 const { roots } = buildCommentTree(comments)
205 const sorted = sortCommentTree(roots, ns => sortComments(ns, csort))
206 if (token !== renderToken) return
207 const link = safeUrl(post.url); const dom = link ? domainOf(link) : null
208 const t = pw.tally
209 const mine = post.author === identity.me().pubkey
210 const total = roots.reduce((n, r) => n + 1 + countDescendants(r), 0)
211 const csortTabs = COMMENT_SORTS.map(s => `<a class="${s === csort ? 'active' : ''}" href="${buildRoute(['b', board, 'item', cid], { csort: s })}">${s}</a>`).join(' ')
212
213 guard(`<article class="item">
214 <div class="item-head">
215 <button class="arrow ${t.myVote === 1 ? 'on' : ''}" data-cid="${esc(cid)}" data-board="${esc(board)}" data-type="post" data-act="vote" data-dir="1" aria-label="upvote">▲</button>
216 <div>
217 <h1 class="item-title">${link ? `<a href="${esc(link)}" target="_blank" rel="noopener noreferrer nofollow">${esc(post.title)}</a>` : esc(post.title)} ${dom ? `<span class="domain">(${esc(dom)})</span>` : ''}</h1>
218 <div class="subline"><span class="score">${fmtCount(t.score)} points</span> by <a href="#/u/${esc(post.author)}">${esc(nameOf(post.author))}</a> <span class="dim">${timeAgo(post.createdAt)}${post.editedAt ? ' · edited' : ''}</span> <span class="sep">|</span> <a href="#/b/${esc(board)}">b/${esc(board)}</a>
219 ${mine ? `<span class="sep">|</span> <button class="linkact danger" data-act="delete-post">delete</button>` : ''}</div>
220 </div>
221 </div>
222 ${post.deleted ? '<p class="removed">[deleted]</p>' : (post.text ? `<div class="item-text md">${renderMarkdown(post.text)}</div>` : '')}
223 <form class="composer" data-form="comment" data-board="${esc(board)}" data-post="${esc(cid)}" data-parent="">
224 <textarea name="body" rows="4" placeholder="add a comment (markdown) — costs a small proof-of-work"></textarea>
225 <div class="composer-actions"><button class="btn" type="submit">comment</button></div>
226 </form>
227 <div class="comment-bar">${total} comments <span class="csort">sort: ${csortTabs}</span></div>
228 <div class="comments">${sorted.length ? sorted.map(n => commentNode(n, post, 0)).join('') : '<p class="dim">No comments yet.</p>'}</div>
229 </article>`)
230}
231
232function commentNode (node, post, depth) {
233 const mine = node.author === identity.me().pubkey
234 const t = node.tally || { score: 0, myVote: 0 }
235 const cidDom = 'c_' + node.cid
236 const body = node.deleted ? '<div class="removed">[deleted]</div>' : `<div class="md">${renderMarkdown(node.body)}</div>`
237 const kids = countDescendants(node)
238 const replyForm = openReplies.has(node.cid)
239 ? `<form class="composer reply" data-form="comment" data-board="${esc(node.board || post.board)}" data-post="${esc(post.cid)}" data-parent="${esc(node.cid)}"><textarea name="body" rows="3" placeholder="reply… (proof-of-work)"></textarea><div class="composer-actions"><button class="btn" type="submit">reply</button><button class="btn ghost" type="button" data-act="cancel-reply" data-cid="${esc(node.cid)}">cancel</button></div></form>`
240 : ''
241 const children = node.children.length ? `<div class="children">${node.children.map(c => commentNode(c, post, depth + 1)).join('')}</div>` : ''
242 return `<div class="comment" id="${cidDom}" data-cid="${esc(node.cid)}" data-board="${esc(node.board || post.board)}" data-type="comment">
243 <div class="comment-row">
244 <button class="collapse" data-act="collapse" data-target="${cidDom}" aria-label="collapse">[–]</button>
245 <button class="arrow sm ${t.myVote === 1 ? 'on' : ''}" data-act="vote" data-dir="1" aria-label="upvote">▲</button>
246 <div class="comment-body">
247 <div class="chead"><a href="#/u/${esc(node.author)}">${esc(nameOf(node.author))}</a> <span class="dim">${fmtCount(t.score)} pts · ${timeAgo(node.createdAt)}${kids ? ' · ' + kids + ' repl' + (kids === 1 ? 'y' : 'ies') : ''}</span></div>
248 ${body}
249 <div class="cactions">
250 ${!node.deleted ? `<button class="linkact" data-act="reply" data-cid="${esc(node.cid)}">reply</button>` : ''}
251 ${mine && !node.deleted ? `<button class="linkact danger" data-act="delete-comment" data-cid="${esc(node.cid)}">delete</button>` : ''}
252 </div>
253 ${replyForm}
254 </div>
255 </div>${children}
256 </div>`
257}
258
259// ---- submit -----------------------------------------------------------------
260async function viewSubmit ({ query, guard, token }) {
261 const boards = await data.listBoards()
262 if (token !== renderToken) return
263 const to = normalizeBoard(query.to || DEFAULT_BOARD) || DEFAULT_BOARD
264 guard(`<div class="panel"><h1>submit</h1>
265 <form data-form="submit-post">
266 <label>title<input name="title" maxlength="300" required placeholder="Show P2PB: …"></label>
267 <label>url <span class="dim">(optional)</span><input name="url" placeholder="https:// or hyper:// or pear://"></label>
268 <label>text <span class="dim">(optional, markdown)</span><textarea name="text" rows="6"></textarea></label>
269 <label>board<select name="board">${boards.map(b => `<option value="${esc(b.name)}" ${b.name === to ? 'selected' : ''}>b/${esc(b.name)}</option>`).join('')}</select></label>
270 <div class="form-actions"><button class="btn" type="submit">submit</button> <a class="btn ghost" href="#/">cancel</a></div>
271 <p class="dim small">Submitting mints a proof-of-work (a second or two) — the permissionless spam gate. No account needed.</p>
272 </form></div>`)
273}
274
275// ---- boards -----------------------------------------------------------------
276async function viewBoards ({ guard, token }) {
277 guard(skeleton())
278 const boards = await data.listBoards()
279 await Promise.all(boards.map(async b => { b._count = await sync.count('post!' + b.name + '!') }))
280 if (token !== renderToken) return
281 boards.sort((a, b) => (b._count || 0) - (a._count || 0))
282 guard(`<div class="feed-head"><h1>boards</h1></div>
283 <div class="panel"><h2>create a board</h2>
284 <form data-form="create-board">
285 <label>name <span class="dim">b/</span><input name="name" maxlength="24" placeholder="showp2pb" required><small class="hint">2–24 chars: a–z 0–9 _ · creating a board mints a proof-of-work</small></label>
286 <label>description<input name="description" maxlength="300"></label>
287 <div class="form-actions"><button class="btn" type="submit">create</button></div>
288 </form></div>
289 <ul class="board-list">${boards.map(b => `<li><a href="#/b/${esc(b.name)}">b/${esc(b.name)}</a> <span class="dim">${fmtCount(b._count || 0)} posts${b.description ? ' · ' + esc(b.description) : ''}</span></li>`).join('')}</ul>`)
290}
291
292// ---- profile ----------------------------------------------------------------
293async function viewProfile ({ pub, guard, token }) {
294 guard(skeleton())
295 const me = identity.me(); const mine = pub === me.pubkey
296 const profile = await data.getProfile(pub)
297 const act = await data.userActivity(pub)
298 const inputs = await data.weightInputsFor(pub)
299 const followers = mine ? await data.following(me.pubkey) : []
300 await primeNames([pub])
301 if (token !== renderToken) return
302 const items = [...act.posts.map(p => ({ k: 'post', t: p.createdAt, p })), ...act.comments.map(c => ({ k: 'comment', t: c.createdAt, c }))].sort((a, b) => b.t - a.t).slice(0, 60)
303 const isFollowing = followers.includes(pub)
304 const feed = items.length ? items.map(it => it.k === 'post'
305 ? `<li class="act"><span class="atag">post</span> <a href="${buildRoute(['b', it.p.board, 'item', it.p.cid])}">${esc(it.p.title)}</a> <span class="dim">in b/${esc(it.p.board)} · ${timeAgo(it.p.createdAt)}</span></li>`
306 : `<li class="act"><span class="atag">comment</span> on <a href="${buildRoute(['b', it.c.board || 'front', 'item', it.c.postCid])}">${esc(it.c.postTitle || 'a post')}</a> <span class="dim">${timeAgo(it.c.createdAt)}</span><div class="md small">${renderMarkdown(it.c.body)}</div></li>`).join('') : '<li class="dim">No activity yet.</li>'
307 guard(`<div class="profile-head">
308 <span class="avatar lg" style="background:${colorFor(pub)}"></span>
309 <div><h1>${esc(nameOf(pub))}</h1><div class="dim mono small">${esc(shortKey(pub, 10))}</div>
310 ${profile && profile.bio ? `<p class="bio">${esc(profile.bio)}</p>` : ''}
311 <div class="rep">received upvotes: <b>${fmtCount(inputs[1])}</b> · age: <b>${Math.floor(inputs[0])}d</b> · vote weight: <b>${(Math.log2(1 + inputs[0]) * Math.sqrt(1 + inputs[1]) / 50).toFixed(2)}</b></div>
312 ${mine ? '<a class="btn ghost sm" href="#/settings">edit profile</a>' : `<button class="btn ghost sm" data-act="follow" data-pub="${esc(pub)}">${isFollowing ? 'unfollow' : 'follow'}</button> <button class="btn ghost sm" data-act="block" data-pub="${esc(pub)}">block</button>`}
313 </div></div>
314 <h2 class="section-title">activity</h2><ul class="activity">${feed}</ul>`)
315}
316
317// ---- settings ---------------------------------------------------------------
318async function viewSettings ({ guard, token }) {
319 const me = identity.me(); const p = await data.getProfile(me.pubkey); const st = await data.status()
320 if (token !== renderToken) return
321 guard(`<div class="panel"><h1>settings</h1>
322 <h2>profile</h2>
323 <form data-form="profile">
324 <label>nickname<input name="nick" maxlength="24" value="${esc((p && p.nick) || '')}" placeholder="pick a handle"></label>
325 <label>bio<textarea name="bio" rows="3" maxlength="300">${esc((p && p.bio) || '')}</textarea></label>
326 <div class="form-actions"><button class="btn" type="submit">save</button></div>
327 </form>
328 <h2>identity</h2><p class="mono small">${esc(me.pubkey)}</p>
329 <h2>network</h2><ul class="kv"><li><span>mode</span><b>${st.mode}${st.secure === false ? ' (insecure)' : ''}</b></li><li><span>records</span><b>${fmtCount(st.viewLength || 0)}</b></li><li><span>peers</span><b>${st.peers != null ? st.peers : 1}</b></li></ul>
330 ${identity.isDev ? `<h2>dev tools</h2><p class="dim small">Open multiple tabs to act as multiple peers.</p><div class="form-actions"><button class="btn ghost" data-act="seed-demo">load demo</button> <button class="btn ghost danger" data-act="wipe">wipe local data</button></div>` : ''}
331 </div>`)
332}
333
334// ---- blocklists -------------------------------------------------------------
335async function viewBlocklists ({ guard, token }) {
336 guard(skeleton())
337 const lists = await data.listBlocklists()
338 await primeNames(lists.map(l => l.author))
339 if (token !== renderToken) return
340 const me = identity.me().pubkey
341 const mine = lists.find(l => l.author === me)
342 guard(`<div class="feed-head"><h1>blocklists</h1></div>
343 <div class="panel"><h2>publish your blocklist</h2>
344 <form data-form="blocklist"><label>blocked pubkeys <span class="dim">(comma/space separated)</span><textarea name="list" rows="3" placeholder="pubkey1 pubkey2 …">${mine ? esc((mine.list || []).join(' ')) : ''}</textarea></label><div class="form-actions"><button class="btn" type="submit">publish</button></div></form>
345 <p class="dim small">Others can subscribe to your list to hide those keys. Curate responsibly.</p></div>
346 <h2 class="section-title">curators</h2>
347 <ul class="board-list">${lists.length ? lists.map(l => `<li><a href="#/u/${esc(l.author)}">${esc(nameOf(l.author))}</a> <span class="dim">${(l.list || []).length} keys · v${l.version || 1}</span> <button class="btn ghost sm" data-act="sub-blocklist" data-pub="${esc(l.author)}">${prefs.isSubscribedBlocklist(l.author) ? 'subscribed' : 'subscribe'}</button></li>`).join('') : '<li class="dim">No published blocklists yet.</li>'}</ul>`)
348}
349
350// ---- search -----------------------------------------------------------------
351async function viewSearch ({ query, guard, token }) {
352 const q = (query.q || '').trim().toLowerCase()
353 guard(skeleton())
354 if (!q) return guard('<div class="empty"><p>Type a query above.</p></div>')
355 let posts = (await data.listAllPosts()).filter(p => !p.deleted && (p.title + ' ' + (p.text || '') + ' ' + (p.url || '')).toLowerCase().includes(q))
356 posts = await data.withTallies(posts); await primeNames(posts.map(p => p.author))
357 const counts = await commentCounts(posts)
358 if (token !== renderToken) return
359 guard(`<div class="feed-head"><h1>results for "${esc(q)}"</h1></div><ol class="feed">${posts.length ? sortPosts(posts, 'top').map((p, i) => postRow(p, i + 1, counts)).join('') : '<p class="dim">No matches.</p>'}</ol>`)
360}
361
362// ---- helpers ----------------------------------------------------------------
363function skeleton () { return '<div class="feed-head"><h1>&nbsp;</h1></div><ol class="feed">' + '<li class="post skel"><span class="rank"></span><span class="sk"></span></li>'.repeat(5) + '</ol>' }
364function notFound (m) { return `<div class="empty"><h3>not found</h3><p>${esc(m || 'Nothing here.')}</p><a class="btn" href="#/">home</a></div>` }
365function toast (msg, kind = 'ok') { const r = $('#toasts'); if (!r) return; const e = document.createElement('div'); e.className = 'toast ' + kind; e.textContent = msg; r.appendChild(e); setTimeout(() => { e.classList.add('out'); setTimeout(() => e.remove(), 300) }, 2800) }
366function parseScore (s) { s = String(s).trim(); const m = parseFloat(s); return /k$/i.test(s) ? m * 1000 : (parseInt(s, 10) || 0) }
367
368// ---- events -----------------------------------------------------------------
369async function onClick (e) {
370 const t = e.target.closest('[data-act]')
371 if (!t) { const d = $('#userdrop'); if (d && !e.target.closest('#usermenu')) d.hidden = true; return }
372 const act = t.dataset.act
373 try {
374 switch (act) {
375 case 'vote': return void await onVote(t)
376 case 'hide': { prefs.toggleHidden(t.dataset.ref); route(); return }
377 case 'collapse': { const n = document.getElementById(t.dataset.target); if (n) { const c = n.classList.toggle('collapsed'); t.textContent = c ? '[+]' : '[–]' } return }
378 case 'reply': { openReplies.add(t.dataset.cid); route(); return }
379 case 'cancel-reply': { openReplies.delete(t.dataset.cid); route(); return }
380 case 'toggle-usermenu': { const d = $('#userdrop'); if (d) { d.hidden = !d.hidden; t.setAttribute('aria-expanded', String(!d.hidden)) } return }
381 case 'netstatus': return void updateNetStatus()
382 case 'switch-user': { identity.switchUser(t.dataset.pub); if (sync.announce) await sync.announce(); refreshPrefs(); nameCache.clear(); await renderUserMenu(); route(); toast('switched'); return }
383 case 'new-user': { const n = prompt('dev user name:', 'anon' + Math.floor(Math.random() * 999)); if (!n) return; await identity.createUser(n); if (sync.announce) await sync.announce(); refreshPrefs(); nameCache.clear(); await renderUserMenu(); route(); return }
384 case 'delete-post': { const el = t.closest('[data-board]'); if (confirm('Delete this post?')) { await data.deletePost(el.dataset.board, el.dataset.cid); toast('deleted'); location.hash = '#/' } return }
385 case 'delete-comment': { const el = t.closest('.comment'); const { path } = parseRoute(location.hash); if (confirm('Delete this comment?')) { await data.deleteComment(path[3], t.dataset.cid); toast('deleted'); route() } return }
386 case 'follow': { const cur = (await data.following(identity.me().pubkey)).includes(t.dataset.pub); await (cur ? data.unfollow(t.dataset.pub) : data.follow(t.dataset.pub)); toast(cur ? 'unfollowed' : 'followed'); route(); return }
387 case 'block': { if (confirm('Block ' + nameOf(t.dataset.pub) + '? (also publish publicly?)')) { await data.block(t.dataset.pub, true); toast('blocked'); route() } return }
388 case 'sub-blocklist': { const now = prefs.toggleBlocklist(t.dataset.pub); toast(now ? 'subscribed' : 'unsubscribed'); route(); return }
389 case 'seed-demo': return void await seedDemo()
390 case 'wipe': { if (confirm('Wipe ALL local data?')) { try { Object.keys(localStorage).filter(k => k.startsWith('p2pb:')).forEach(k => localStorage.removeItem(k)); sessionStorage.clear() } catch {} location.reload() } return }
391 }
392 } catch (err) { toast(err.message || String(err), 'error') }
393}
394
395function onChange (e) {
396 if (e.target.matches('.sorttabs')) {}
397}
398
399async function onVote (t) {
400 const box = t.closest('[data-cid]')
401 const cid = box.dataset.cid, type = box.dataset.type
402 const cur = t.classList.contains('on') ? 1 : 0
403 const next = cur === 1 ? 0 : 1
404 const scoreEl = box.querySelector('.score')
405 const paint = (on) => { t.classList.toggle('on', on === 1) }
406 paint(next)
407 if (scoreEl) { const base = parseScore(scoreEl.textContent) - cur; scoreEl.textContent = fmtCount(base + next) + (Math.abs(base + next) === 1 ? ' point' : ' points') }
408 try { await data.vote(cid, type, next) } catch (err) { paint(cur); throw err }
409}
410
411async function onSubmit (e) {
412 const form = e.target.closest('form[data-form]'); if (!form) return
413 e.preventDefault()
414 const f = form.dataset.form; const fd = new FormData(form)
415 if (form.dataset.busy) return
416 const btn = form.querySelector('button[type="submit"]')
417 const setBusy = (label) => { form.dataset.busy = '1'; if (btn) { btn.dataset.label = btn.dataset.label || btn.textContent; btn.disabled = true; btn.textContent = label } }
418 const clearBusy = () => { delete form.dataset.busy; if (btn && document.contains(btn)) { btn.disabled = false; btn.textContent = btn.dataset.label } }
419 const onProgress = (n) => { if (btn) btn.textContent = 'minting PoW… ' + fmtCount(n) }
420 try {
421 if (f === 'search') { const q = (fd.get('q') || '').trim(); if (q) location.hash = buildRoute(['search'], { q }); return }
422 if (f === 'submit-post') {
423 setBusy('minting PoW…')
424 const p = await data.submitPost({ board: fd.get('board'), title: fd.get('title'), url: fd.get('url'), text: fd.get('text'), onProgress })
425 toast('posted'); location.hash = buildRoute(['b', p.board, 'item', p.cid]); return
426 }
427 if (f === 'create-board') {
428 setBusy('minting PoW…')
429 const b = await data.createBoard({ name: fd.get('name'), description: fd.get('description'), onProgress })
430 toast('created b/' + b.name); location.hash = '#/b/' + b.name; return
431 }
432 if (f === 'comment') {
433 setBusy('minting PoW…')
434 const parent = form.dataset.parent || null
435 await data.addComment({ postCid: form.dataset.post, board: form.dataset.board, parentCid: parent, body: fd.get('body'), onProgress })
436 if (parent) openReplies.delete(parent)
437 toast('commented'); route(); return
438 }
439 if (f === 'profile') { await data.setProfile({ nick: fd.get('nick'), bio: fd.get('bio') }); data.invalidateProfile(identity.me().pubkey); nameCache.delete(identity.me().pubkey); await renderUserMenu(); toast('saved'); route(); return }
440 if (f === 'blocklist') { const list = (fd.get('list') || '').split(/[\s,]+/).filter(Boolean); await data.publishBlocklist(list); toast('blocklist published'); route(); return }
441 } catch (err) { toast(err.message || String(err), 'error') }
442 finally { clearBusy() }
443}
444
445async function seedDemo () {
446 toast('seeding…')
447 const d = data
448 try { await d.createBoard({ name: 'showp2pb' }) } catch {}
449 const posts = [
450 { board: 'front', title: 'peerit — a peer-to-peer Reddit running in PearBrowser', url: 'hyper://peerit' },
451 { board: 'front', title: 'Ask P2PB: how do you keep a Hyperdrive online 24/7?', text: 'HiveRelay pins it for you — dumb always-on storage. What else are people using?' },
452 { board: 'showp2pb', title: 'Show P2PB: this very app, ported to the browser', text: 'Same gossip engine as peerit. Posts are PoW-gated and reputation-weighted.' }
453 ]
454 let first = null
455 for (const p of posts) { try { const r = await d.submitPost(p); first = first || r } catch (e) { console.error(e) } }
456 if (first) { const c = await d.addComment({ postCid: first.cid, board: first.board, body: 'replicated to you from a peer — no server involved.' }); await d.vote(first.cid, 'post', 1); void c }
457 toast('demo ready'); location.hash = '#/'; route()
458}
459
460if (typeof window !== 'undefined') window.__p2pb = { get data () { return data }, get sync () { return sync }, route }
461if (typeof document !== 'undefined') { if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); else boot() }
462export { boot }
web/js/canon.js +54 -0
……@@ -0,0 +1,54 @@
1// canon.js — canonical serialization, owner binding, key binding, and sticky-name
2// classification. Shared by the signer (data.js), verifier (verify.js), gossip
3// merge (gossip.js), and proof-of-work (pow.js). One definition so a signature
4// covers exactly the bytes that get checked.
5
6import { TYPE, keys } from './model.js'
7
8// The pubkey that must have authored a record of this type (signer must match).
9export function ownerOf (type, data) {
10 return type === TYPE.BOARD ? data.creator : data.author
11}
12
13// Sticky-name key (first-creator-wins) for named records — boards. null = not sticky.
14export function stickyKey (type, data) {
15 return type === TYPE.BOARD ? data.name : null
16}
17
18// The Hyperbee key a record MUST occupy, recomputed from its own fields.
19export function expectedKey (type, data) {
20 switch (type) {
21 case TYPE.BOARD: return data.name != null ? keys.board(data.name) : null
22 case TYPE.POST: return data.board != null && data.cid != null ? keys.post(data.board, data.cid) : null
23 case TYPE.COMMENT: return data.postCid != null && data.cid != null ? keys.comment(data.postCid, data.cid) : null
24 case TYPE.VOTE: return data.targetCid != null && data.author != null ? keys.vote(data.targetCid, data.author) : null
25 case TYPE.PROFILE: return data.author != null ? keys.profile(data.author) : null
26 case TYPE.FOLLOW: return data.author != null && data.target != null ? keys.follow(data.author, data.target) : null
27 case TYPE.BLOCK: return data.author != null && data.target != null ? keys.block(data.author, data.target) : null
28 case TYPE.BLOCKLIST: return data.author != null ? keys.blocklist(data.author) : null
29 default: return null
30 }
31}
32
33const SIG_FIELDS = new Set(['_sig', '_k', '_dk', '_ns', '_alg'])
34
35// Deterministic, key-sorted JSON of a value, omitting signature metadata and any
36// extra keys requested (used by PoW to hash content WITHOUT the pow field).
37export function stableStringify (v, omit) {
38 if (v === null || typeof v !== 'object') return JSON.stringify(v === undefined ? null : v)
39 if (Array.isArray(v)) return '[' + v.map(x => stableStringify(x, omit)).join(',') + ']'
40 const ks = Object.keys(v).filter(k => !SIG_FIELDS.has(k) && !(omit && omit.has(k))).sort()
41 return '{' + ks.map(k => JSON.stringify(k) + ':' + stableStringify(v[k], omit)).join(',') + '}'
42}
43
44// Covers EVERY content field automatically (incl. the pow field), so any tamper
45// invalidates the signature.
46export function canonical (type, data) {
47 return type + '|' + stableStringify(data)
48}
49
50export function typeFromKey (key) { return String(key).split('!')[0] }
51
52export function recordTs (data) {
53 return (data && (data.editedAt || data.updatedAt || data.ts || data.createdAt)) || 0
54}
web/js/crypto.js +103 -0
……@@ -0,0 +1,103 @@
1// crypto.js — real Ed25519, the authenticity backbone of the gossip network.
2//
3// A record is trusted ONLY because it carries a valid Ed25519 signature from its
4// author's key — never because of which outbox relayed it. This module provides
5// that signing/verification over whatever platform crypto is available:
6//
7// 1. WebCrypto SubtleCrypto Ed25519 (PearBrowser / modern browsers, and Node 20+)
8// 2. node:crypto Ed25519 (test runner / older Node)
9// 3. none -> cooperative dev fallback (clearly marked insecure; see gossip.js)
10//
11// Keys are handled as a 32-byte seed (hex) so DevIdentity can persist them.
12// All ops are async (Subtle is async); callers await.
13
14const PKCS8_PREFIX = '302e020100300506032b657004220420' // Ed25519 PKCS8 header for a 32B seed
15const SPKI_PREFIX = '302a300506032b6570032100' // Ed25519 SPKI header for a 32B pubkey
16
17const toHex = (buf) => { const u = new Uint8Array(buf); let s = ''; for (let i = 0; i < u.length; i++) s += u[i].toString(16).padStart(2, '0'); return s }
18const fromHex = (h) => { const a = new Uint8Array(h.length / 2); for (let i = 0; i < a.length; i++) a[i] = parseInt(h.substr(i * 2, 2), 16); return a }
19const concat = (a, b) => { const u = new Uint8Array(a.length + b.length); u.set(a, 0); u.set(b, a.length); return u }
20const utf8 = (s) => new TextEncoder().encode(s)
21
22let backend = null // 'subtle' | 'node' | 'none'
23let nodeCrypto = null
24let _ready = null
25
26async function detect () {
27 // Prefer SubtleCrypto Ed25519.
28 try {
29 const subtle = globalThis.crypto && globalThis.crypto.subtle
30 if (subtle) {
31 const kp = await subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify'])
32 const msg = utf8('peerit-selftest')
33 const sig = await subtle.sign({ name: 'Ed25519' }, kp.privateKey, msg)
34 const okk = await subtle.verify({ name: 'Ed25519' }, kp.publicKey, sig, msg)
35 if (okk) { backend = 'subtle'; return }
36 }
37 } catch {}
38 // Fall back to node:crypto.
39 try {
40 const mod = await import('node:crypto')
41 nodeCrypto = mod.default || mod
42 const { privateKey, publicKey } = nodeCrypto.generateKeyPairSync('ed25519')
43 const sig = nodeCrypto.sign(null, utf8('peerit-selftest'), privateKey)
44 if (nodeCrypto.verify(null, utf8('peerit-selftest'), publicKey, sig)) { backend = 'node'; return }
45 } catch {}
46 backend = 'none'
47}
48
49export function ready () { if (!_ready) _ready = detect(); return _ready }
50export function isSecure () { return backend === 'subtle' || backend === 'node' }
51export function backendName () { return backend }
52
53// --- node helpers ---
54function nodePriv (seedHex) { return nodeCrypto.createPrivateKey({ key: Buffer.from(PKCS8_PREFIX + seedHex, 'hex'), format: 'der', type: 'pkcs8' }) }
55function nodePub (pubHex) { return nodeCrypto.createPublicKey({ key: Buffer.from(SPKI_PREFIX + pubHex, 'hex'), format: 'der', type: 'spki' }) }
56
57// --- subtle helpers ---
58async function subtlePriv (seedHex) {
59 return globalThis.crypto.subtle.importKey('pkcs8', fromHex(PKCS8_PREFIX + seedHex), { name: 'Ed25519' }, false, ['sign'])
60}
61async function subtlePub (pubHex) {
62 return globalThis.crypto.subtle.importKey('raw', fromHex(pubHex), { name: 'Ed25519' }, false, ['verify'])
63}
64
65// Generate a keypair. Returns { seedHex (private, 32B), pubHex (public, 32B) }.
66export async function genKeyPair () {
67 await ready()
68 if (backend === 'subtle') {
69 const kp = await globalThis.crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify'])
70 const pkcs8 = new Uint8Array(await globalThis.crypto.subtle.exportKey('pkcs8', kp.privateKey))
71 const raw = new Uint8Array(await globalThis.crypto.subtle.exportKey('raw', kp.publicKey))
72 return { seedHex: toHex(pkcs8.slice(-32)), pubHex: toHex(raw) }
73 }
74 if (backend === 'node') {
75 const { privateKey } = nodeCrypto.generateKeyPairSync('ed25519')
76 const pkcs8 = privateKey.export({ type: 'pkcs8', format: 'der' })
77 const seedHex = toHex(new Uint8Array(pkcs8).slice(-32))
78 const spki = nodeCrypto.createPublicKey(privateKey).export({ type: 'spki', format: 'der' })
79 return { seedHex, pubHex: toHex(new Uint8Array(spki).slice(-32)) }
80 }
81 // insecure cooperative fallback — random ids, no real keys
82 const rnd = () => { let s = ''; for (let i = 0; i < 64; i++) s += Math.floor(Math.random() * 16).toString(16); return s }
83 return { seedHex: rnd(), pubHex: rnd() }
84}
85
86// Sign a string message with a seed. Returns hex signature ('' in fallback).
87export async function sign (seedHex, message) {
88 await ready()
89 if (backend === 'subtle') return toHex(await globalThis.crypto.subtle.sign({ name: 'Ed25519' }, await subtlePriv(seedHex), utf8(message)))
90 if (backend === 'node') return toHex(nodeCrypto.sign(null, utf8(message), nodePriv(seedHex)))
91 return '' // fallback: no signature
92}
93
94// Verify a hex signature of a string message against a public key. async -> bool.
95export async function verify (pubHex, message, sigHex) {
96 await ready()
97 if (!sigHex || !pubHex) return false
98 try {
99 if (backend === 'subtle') return await globalThis.crypto.subtle.verify({ name: 'Ed25519' }, await subtlePub(pubHex), fromHex(sigHex), utf8(message))
100 if (backend === 'node') return nodeCrypto.verify(null, utf8(message), nodePub(pubHex), Buffer.from(sigHex, 'hex'))
101 } catch { return false }
102 return false
103}
web/js/data.js +292 -0
……@@ -0,0 +1,292 @@
1// data.js — the p2pbuilders domain API. Turns intents (submit, comment, vote,
2// follow, block, create board…) into PoW-gated, signed ops on the gossip layer,
3// and reads the merged view back into typed records with reputation-weighted
4// tallies. Reuses peerit's sync/gossip/crypto engine; the schema + PoW +
5// reputation are p2pbuilders-specific.
6
7import { keys, id as mkid, TYPE, DEFAULT_BOARD, isValidBoard, normalizeBoard, resolveBlocked } from './model.js'
8import { canonical } from './canon.js'
9import { mint, MIN_BITS } from './pow.js'
10import { weight as repWeight, weightedTally } from './reputation.js'
11import { uid } from './util.js'
12
13const DAY = 86400000
14
15export class Data {
16 constructor (sync, identity, opts = {}) {
17 this.sync = sync
18 this.id = identity
19 this.minBits = opts.minBits || MIN_BITS
20 this._profileCache = new Map()
21 this._repIndex = null // { at, earliest:Map, received:Map }
22 }
23
24 me () { return this.id.me() }
25
26 async _sign (type, data) {
27 const s = await this.id.sign(canonical(type, data))
28 return { _sig: s.signature, _k: s.publicKey, _dk: s.driveKey, _ns: s.namespace, _alg: s.algorithm }
29 }
30
31 // Mint PoW for an op then sign it (PoW first so it's covered by the signature).
32 async _powSign (type, data, onProgress) {
33 data.pow = await mint(type, data, this.minBits[type] || 0, { onProgress })
34 Object.assign(data, await this._sign(type, data))
35 return data
36 }
37
38 // ---- Boards ---------------------------------------------------------------
39 async createBoard ({ name, description, onProgress }) {
40 name = normalizeBoard(name)
41 if (!isValidBoard(name)) throw new Error('Board name must be 2–24 chars: a–z, 0–9, _')
42 if (await this.getBoard(name)) throw new Error('b/' + name + ' already exists')
43 const me = this.me()
44 const data = { id: mkid.board(name), name, description: (description || '').slice(0, 300), creator: me.pubkey, createdAt: Date.now() }
45 await this._powSign(TYPE.BOARD, data, onProgress)
46 await this.sync.append({ type: TYPE.BOARD, data })
47 return data
48 }
49 async getBoard (name) { return this.sync.get(keys.board(name)) }
50 async listBoards () {
51 const rows = await this.sync.list(keys.boardPrefix(), { limit: 1000 })
52 const list = rows.map(r => r.value).filter(Boolean)
53 if (!list.find(b => b.name === DEFAULT_BOARD)) list.unshift({ name: DEFAULT_BOARD, description: 'The front page', synthetic: true })
54 return list
55 }
56
57 // ---- Posts ----------------------------------------------------------------
58 async submitPost ({ board, title, url, text, onProgress }) {
59 board = normalizeBoard(board || DEFAULT_BOARD) || DEFAULT_BOARD
60 if (!title || !title.trim()) throw new Error('A title is required')
61 if (board !== DEFAULT_BOARD && !(await this.getBoard(board))) throw new Error('b/' + board + " doesn't exist")
62 const me = this.me()
63 const cid = uid()
64 const data = {
65 id: mkid.post(board, cid), cid, board, title: title.trim().slice(0, 300),
66 url: (url || '').trim().slice(0, 2000), text: (text || '').slice(0, 20000),
67 author: me.pubkey, createdAt: Date.now(), editedAt: 0, deleted: false
68 }
69 await this._powSign(TYPE.POST, data, onProgress)
70 await this.sync.append({ type: TYPE.POST, data })
71 return data
72 }
73 async getPost (board, cid) { return this.sync.get(keys.post(board, cid)) }
74 async listPostsIn (board) {
75 const rows = await this.sync.list(keys.postsIn(board), { limit: 1000 })
76 return rows.map(r => r.value).filter(Boolean)
77 }
78 async listAllPosts (boards) {
79 let names = boards
80 if (!names) names = (await this.listBoards()).map(b => b.name)
81 const lists = await Promise.all([...new Set(names)].map(b => this.listPostsIn(b).catch(() => [])))
82 return lists.flat()
83 }
84 async editPost (board, cid, patch) {
85 const p = await this.getPost(board, cid)
86 if (!p) throw new Error('Post not found')
87 if (p.author !== this.me().pubkey) throw new Error('You can only edit your own post')
88 const data = { ...p, title: patch.title != null ? patch.title.slice(0, 300) : p.title, text: patch.text != null ? patch.text.slice(0, 20000) : p.text, editedAt: Date.now() }
89 Object.assign(data, await this._sign(TYPE.POST, data)) // pow identity unchanged (cid/author/createdAt) -> no re-mint
90 await this.sync.append({ type: TYPE.POST, data })
91 return data
92 }
93 async deletePost (board, cid) {
94 const p = await this.getPost(board, cid)
95 if (!p) return
96 if (p.author !== this.me().pubkey) throw new Error('You can only delete your own post')
97 const data = { ...p, deleted: true, title: p.title, url: '', text: '', editedAt: Date.now() }
98 Object.assign(data, await this._sign(TYPE.POST, data))
99 await this.sync.append({ type: TYPE.POST, data })
100 }
101
102 // ---- Comments -------------------------------------------------------------
103 async addComment ({ postCid, board, parentCid, body, onProgress }) {
104 if (!body || !body.trim()) throw new Error('Comment cannot be empty')
105 const me = this.me()
106 const cid = uid()
107 const data = {
108 id: mkid.comment(postCid, cid), cid, postCid, board: board || DEFAULT_BOARD,
109 parentCid: parentCid || null, body: body.trim().slice(0, 10000),
110 author: me.pubkey, createdAt: Date.now(), editedAt: 0, deleted: false
111 }
112 await this._powSign(TYPE.COMMENT, data, onProgress)
113 await this.sync.append({ type: TYPE.COMMENT, data })
114 return data
115 }
116 async listComments (postCid) {
117 const rows = await this.sync.list(keys.commentsOn(postCid), { limit: 1000 })
118 return rows.map(r => r.value).filter(Boolean)
119 }
120 async editComment (postCid, cid, body) {
121 const c = await this.sync.get(keys.comment(postCid, cid))
122 if (!c) throw new Error('Comment not found')
123 if (c.author !== this.me().pubkey) throw new Error('You can only edit your own comment')
124 const data = { ...c, body: String(body || '').slice(0, 10000), editedAt: Date.now() }
125 Object.assign(data, await this._sign(TYPE.COMMENT, data))
126 await this.sync.append({ type: TYPE.COMMENT, data })
127 return data
128 }
129 async deleteComment (postCid, cid) {
130 const c = await this.sync.get(keys.comment(postCid, cid))
131 if (!c) return
132 if (c.author !== this.me().pubkey) throw new Error('You can only delete your own comment')
133 const data = { ...c, deleted: true, body: '', editedAt: Date.now() }
134 Object.assign(data, await this._sign(TYPE.COMMENT, data))
135 await this.sync.append({ type: TYPE.COMMENT, data })
136 }
137
138 // ---- Votes ----------------------------------------------------------------
139 async vote (targetCid, targetType, value) {
140 const me = this.me()
141 value = value === 1 ? 1 : value === -1 ? -1 : 0
142 const data = { id: mkid.vote(targetCid, me.pubkey), targetCid, targetType, dir: value, author: me.pubkey, ts: Date.now() }
143 Object.assign(data, await this._sign(TYPE.VOTE, data))
144 await this.sync.append({ type: TYPE.VOTE, data })
145 this._repIndex = null
146 return data
147 }
148 async rawVotes (targetCid) {
149 const rows = await this.sync.list(keys.votesFor(targetCid), { limit: 1000 })
150 return rows.map(r => r.value).filter(Boolean)
151 }
152
153 // ---- Reputation -----------------------------------------------------------
154 // One pass over all posts/comments + all votes -> per-pubkey { earliest, received }.
155 // Cached briefly; rebuilt after a vote or on TTL.
156 async _reputation () {
157 if (this._repIndex && Date.now() - this._repIndex.at < 5000) return this._repIndex
158 const earliest = new Map() // pub -> earliest activity ms
159 const received = new Map() // pub -> upvotes received
160 const cidAuthor = new Map() // cid -> author
161 const seen = (pub, ts) => { const e = earliest.get(pub); if (e == null || ts < e) earliest.set(pub, ts) }
162 const posts = await this.listAllPosts()
163 for (const p of posts) { cidAuthor.set(p.cid, p.author); seen(p.author, p.createdAt) }
164 await Promise.all(posts.map(async p => {
165 for (const c of await this.listComments(p.cid)) { cidAuthor.set(c.cid, c.author); seen(c.author, c.createdAt) }
166 }))
167 const allVotes = await this.sync.list(keys.voteAll(), { limit: 1000 })
168 for (const { value: v } of allVotes) {
169 if (v && v.dir === 1) { const a = cidAuthor.get(v.targetCid); if (a) received.set(a, (received.get(a) || 0) + 1) }
170 }
171 this._repIndex = { at: Date.now(), earliest, received }
172 return this._repIndex
173 }
174
175 async weightInputsFor (pub, idx) {
176 idx = idx || await this._reputation()
177 const earliest = idx.earliest.get(pub)
178 const ageDays = earliest ? Math.max(0, (Date.now() - earliest) / DAY) : 0
179 return [ageDays, idx.received.get(pub) || 0]
180 }
181
182 // Attach reputation-weighted tally to each record: .tally {up,down,score(raw),
183 // weighted, myVote} and .score = weighted (for ranking).
184 async withTallies (records) {
185 const idx = await this._reputation()
186 const me = this.me().pubkey
187 const weightOf = (pub) => {
188 const e = idx.earliest.get(pub)
189 const ageDays = e ? Math.max(0, (Date.now() - e) / DAY) : 0
190 return [ageDays, idx.received.get(pub) || 0]
191 }
192 return Promise.all(records.map(async r => {
193 const votes = await this.rawVotes(r.cid)
194 const t = weightedTally(votes, weightOf, me)
195 return { ...r, tally: t, score: t.weighted }
196 }))
197 }
198
199 async tallyFor (targetCid) {
200 const idx = await this._reputation()
201 const me = this.me().pubkey
202 const weightOf = (pub) => { const e = idx.earliest.get(pub); return [e ? Math.max(0, (Date.now() - e) / DAY) : 0, idx.received.get(pub) || 0] }
203 return weightedTally(await this.rawVotes(targetCid), weightOf, me)
204 }
205
206 // ---- Profiles -------------------------------------------------------------
207 async setProfile ({ nick, bio }) {
208 const me = this.me()
209 const prev = await this.getProfile(me.pubkey)
210 const data = {
211 id: mkid.profile(me.pubkey), author: me.pubkey,
212 nick: (nick != null ? nick : (prev && prev.nick) || '').slice(0, 24),
213 bio: (bio != null ? bio : (prev && prev.bio) || '').slice(0, 300),
214 createdAt: prev ? prev.createdAt : Date.now(), updatedAt: Date.now()
215 }
216 Object.assign(data, await this._sign(TYPE.PROFILE, data))
217 await this.sync.append({ type: TYPE.PROFILE, data })
218 this._profileCache.set(me.pubkey, { rec: data, at: Date.now() })
219 return data
220 }
221 async getProfile (pub) {
222 const c = this._profileCache.get(pub)
223 if (c && Date.now() - c.at < 15000) return c.rec
224 const rec = await this.sync.get(keys.profile(pub))
225 this._profileCache.set(pub, { rec, at: Date.now() })
226 return rec
227 }
228 async nickOf (pub) {
229 if (!pub) return 'unknown'
230 const p = await this.getProfile(pub)
231 return (p && p.nick) ? p.nick : 'anon-' + pub.slice(0, 6)
232 }
233 invalidateProfile (pub) { this._profileCache.delete(pub) }
234
235 // ---- Follow / block / blocklist ------------------------------------------
236 async follow (target, on = true) {
237 const me = this.me()
238 const data = { id: mkid.follow(me.pubkey, target), author: me.pubkey, target, active: on, createdAt: Date.now(), updatedAt: Date.now() }
239 Object.assign(data, await this._sign(TYPE.FOLLOW, data))
240 await this.sync.append({ type: TYPE.FOLLOW, data })
241 return data
242 }
243 unfollow (target) { return this.follow(target, false) }
244 async following (pub) {
245 const rows = await this.sync.list(keys.followsBy(pub), { limit: 1000 })
246 return rows.map(r => r.value).filter(v => v && v.active).map(v => v.target)
247 }
248
249 async block (target, isPublic = false, on = true) {
250 const me = this.me()
251 const data = { id: mkid.block(me.pubkey, target), author: me.pubkey, target, public: !!isPublic, active: on, createdAt: Date.now(), updatedAt: Date.now() }
252 Object.assign(data, await this._sign(TYPE.BLOCK, data))
253 await this.sync.append({ type: TYPE.BLOCK, data })
254 return data
255 }
256 unblock (target) { return this.block(target, false, false) }
257
258 async publishBlocklist (list) {
259 const me = this.me()
260 const prev = await this.sync.get(keys.blocklist(me.pubkey))
261 const data = { id: mkid.blocklist(me.pubkey), author: me.pubkey, list: (list || []).slice(0, 1000), version: ((prev && prev.version) || 0) + 1, updatedAt: Date.now() }
262 Object.assign(data, await this._sign(TYPE.BLOCKLIST, data))
263 await this.sync.append({ type: TYPE.BLOCKLIST, data })
264 return data
265 }
266 async getBlocklist (author) { return this.sync.get(keys.blocklist(author)) }
267 async listBlocklists () {
268 const rows = await this.sync.list(keys.blocklistPrefix(), { limit: 1000 })
269 return rows.map(r => r.value).filter(Boolean)
270 }
271 // Effective blocked set = local blocks + subscribed published blocklists.
272 async blockedSet (localBlocks, subscribed) {
273 return resolveBlocked(localBlocks, subscribed, await this.listBlocklists())
274 }
275
276 // ---- user activity (profile page) ----------------------------------------
277 async userActivity (pub) {
278 const posts = []
279 const comments = []
280 for (const p of await this.listAllPosts()) {
281 if (p.author === pub && !p.deleted) posts.push(p)
282 for (const c of await this.listComments(p.cid)) {
283 if (c.author === pub && !c.deleted) comments.push({ ...c, postTitle: p.title, board: p.board })
284 }
285 }
286 return { posts, comments }
287 }
288
289 async status () { return this.sync.status() }
290}
291
292export function createData (sync, identity, opts) { return new Data(sync, identity, opts) }
web/js/gossip.js +379 -0
……@@ -0,0 +1,379 @@
1// gossip.js — the multi-writer layer. Each user writes ONLY their own outbox;
2// peers replicate each other's outboxes and merge them into one view.
3//
4// SECURITY MODEL (post-audit): authenticity comes from the Ed25519 SIGNATURE,
5// never from which outbox relayed a record. A record is admitted iff:
6// 1. its storage key === the key recomputed from its own fields (key binding),
7// 2. its signer (_k) === its claimed author (ownerOf), and
8// 3. in secure mode its Ed25519 signature verifies.
9// So relaying a victim-labelled outbox full of fabricated records gains nothing.
10// Records are verified at INGEST too, so a forgery can't evict a real record.
11//
12// Community names: ownership is sticky — once a replica has admitted r/<slug>
13// for some creator, a different creator can never replace it (no hijack of an
14// established community). Genesis races for a brand-new slug resolve
15// deterministically; pure-gossip unique naming can still be squatted at genesis
16// (see README) — that is a naming limitation, NOT a content-forgery one.
17//
18// Without a crypto backend (a browser lacking SubtleCrypto Ed25519) this degrades
19// to cooperative owner-binding (NOT secure — local simulation only).
20
21import { ownerOf, expectedKey, typeFromKey, recordTs, canonical, stickyKey } from './canon.js'
22import { verifyRecord } from './verify.js'
23import { verify as edVerify, isSecure, ready as cryptoReady } from './crypto.js'
24
25const PEERS_KEY = 'p2pb:peers'
26const CLAIMED_KEY = 'p2pb:claimed'
27const outboxKey = (pub) => 'p2pb:outbox:' + pub
28const TOPIC = 'p2pb-gossip-v1'
29const PROTO_KEYS = new Set(['__proto__', 'prototype', 'constructor'])
30const MAX_PEERS = 4096
31
32// ---- local reducer + range over a {key:value} view --------------------------
33function applyOp (view, op) {
34 if (!op || typeof op !== 'object' || !op.type) return
35 if (op.data && op.data.id != null) view[op.type.replace(':', '!') + '!' + op.data.id] = op.data
36}
37function rangeFromView (view, opts) {
38 let ks = Object.keys(view).sort()
39 if (opts.gte != null) ks = ks.filter(k => k >= opts.gte)
40 if (opts.gt != null) ks = ks.filter(k => k > opts.gt)
41 if (opts.lte != null) ks = ks.filter(k => k <= opts.lte)
42 if (opts.lt != null) ks = ks.filter(k => k < opts.lt)
43 if (opts.reverse) ks.reverse()
44 let limit = Number(opts.limit) || 100
45 if (limit < 1) limit = 100
46 if (limit > 1000) limit = 1000
47 const out = []
48 for (const k of ks) { if (out.length >= limit) break; out.push({ key: k, value: view[k] }) }
49 return out
50}
51
52// ---- authenticity (cache only positive verdicts; key binds sig TO content) --
53const _verdict = new Map()
54async function honored (type, val) {
55 if (!val || !val._sig) return verifyRecord(type, val)
56 const ck = JSON.stringify([val._sig, val._k || '', val._dk || '', val._ns || '', canonical(type, val)])
57 if (_verdict.has(ck)) return _verdict.get(ck)
58 const v = await verifyRecord(type, val)
59 if (v === 'ok') _verdict.set(ck, v) // never cache 'bad' (cheap to recompute; avoids unbounded growth from rejected forgeries)
60 return v
61}
62
63async function admit (type, val, key, pub, secure, validate) {
64 if (!val || typeof val !== 'object') return false
65 if (!type || expectedKey(type, val) !== key) return false // key binding
66 const owner = ownerOf(type, val)
67 if (!owner) return false
68 const v = await honored(type, val)
69 if (secure) { if (v !== 'ok') return false } // signature is the authority
70 else if (!(owner === pub && v !== 'bad')) return false // cooperative dev fallback only
71 // App-supplied gate (e.g. proof-of-work). Reject on failure or throw.
72 if (validate) { try { if (!(await validate(type, val))) return false } catch { return false } }
73 return true
74}
75
76// deterministic, order-independent conflict winners
77function laterRecord (a, b) {
78 const ta = recordTs(a), tb = recordTs(b)
79 if (ta !== tb) return ta > tb
80 const da = a.deleted ? 1 : 0, db = b.deleted ? 1 : 0
81 if (da !== db) return da > db // a tombstone wins ties (no resurrection)
82 return String(a._sig || '') > String(b._sig || '') // total order
83}
84// earliest-creator-wins ordering for sticky-named records (boards): createdAt,
85// then creator pubkey, then signature — deterministic + order-independent.
86function nameWins (a, b) {
87 const ca = a.createdAt || 0, cb = b.createdAt || 0
88 if (ca !== cb) return ca < cb
89 const ka = a.creator || '', kb = b.creator || ''
90 if (ka !== kb) return ka < kb
91 return String(a._sig || '') < String(b._sig || '')
92}
93
94// THE merge. `claimed` (sticky-name -> creator) makes named records (boards)
95// first-creator-sticky across calls. `validate` is an optional app gate (PoW).
96// Which types are sticky-named is decided by canon.stickyKey(). async (verifies sigs).
97export async function mergeOutboxes (boxes, claimed, validate) {
98 await cryptoReady()
99 const secure = isSecure()
100 claimed = claimed || {}
101 const out = Object.create(null)
102 for (const { pub, view } of boxes) {
103 if (!view || typeof view !== 'object') continue
104 for (const key in view) {
105 if (PROTO_KEYS.has(key)) continue
106 const val = view[key]
107 const type = typeFromKey(key)
108 if (!(await admit(type, val, key, pub, secure, validate))) continue
109 const sk = stickyKey(type, val)
110 if (sk != null && claimed[sk] && claimed[sk] !== val.creator) continue // name owned by another creator
111 const ex = out[key]
112 if (!ex || (sk != null ? nameWins(val, ex) : laterRecord(val, ex))) out[key] = val
113 }
114 }
115 // Lock sticky names to the resolved creator so a later different creator can't take them.
116 for (const key in out) {
117 const sk = stickyKey(typeFromKey(key), out[key])
118 if (sk != null && !claimed[sk]) claimed[sk] = out[key].creator
119 }
120 return out
121}
122
123// ---- dev / Node gossip ------------------------------------------------------
124class GossipSync {
125 constructor ({ storage, bus, getMe, validate }) {
126 this.mode = 'gossip-dev'
127 this.storage = storage
128 this.bus = bus
129 this.getMe = getMe
130 this.validate = validate || null
131 this._listeners = new Set()
132 this._cache = null
133 this._inflight = null
134 this._epoch = 0
135 }
136
137 async ready () {
138 await cryptoReady()
139 this.mode = isSecure() ? 'gossip-dev' : 'gossip-dev-insecure'
140 this._addPeer(this.getMe())
141 if (this.bus) {
142 this.bus.onMessage((m) => this._onBus(m))
143 await this.bus.send({ t: 'hello', pub: this.getMe() })
144 await this._broadcastMine()
145 this._helloRetry()
146 }
147 return this
148 }
149
150 _read (k) { try { const s = this.storage.getItem(k); return s ? JSON.parse(s) : null } catch { return null } }
151 _write (k, v) { this.storage.setItem(k, JSON.stringify(v)) }
152 _outbox (pub) { return this._read(outboxKey(pub)) || {} }
153 _peers () { return this._read(PEERS_KEY) || [] }
154 _addPeer (pub) { if (!pub) return; const p = this._peers(); if (!p.includes(pub) && p.length < MAX_PEERS) { p.push(pub); this._write(PEERS_KEY, p) } }
155
156 _helloRetry () {
157 let n = 0
158 const tick = () => {
159 if (n++ >= 3 || !this.bus) return
160 try { this.bus.send({ t: 'hello', pub: this.getMe() }) } catch {}
161 const t = setTimeout(tick, 300); if (t && t.unref) t.unref()
162 }
163 const t = setTimeout(tick, 300); if (t && t.unref) t.unref()
164 }
165
166 async _onBus (m) {
167 if (!m || !m.t) return
168 const me = this.getMe()
169 if (m.t === 'hello' && m.pub && m.pub !== me) {
170 this._addPeer(m.pub); await this._broadcastMine(); this._invalidate(); this._emit()
171 } else if (m.t === 'outbox' && m.pub && m.pub !== me) {
172 this._addPeer(m.pub)
173 const incoming = m.view || {}
174 const secure = isSecure()
175 // Verify everything FIRST (await), into a clean admitted map — so a forged
176 // record can never be written into the replica (no eviction of real data).
177 const admitted = {}
178 for (const k in incoming) {
179 if (PROTO_KEYS.has(k)) continue
180 const iv = incoming[k]
181 if (await admit(typeFromKey(k), iv, k, m.pub, secure, this.validate)) admitted[k] = iv
182 }
183 // Re-read AFTER the awaits, then a single write — minimises the RMW window.
184 const cur = this._outbox(m.pub)
185 let changed = false
186 for (const k in admitted) {
187 const iv = admitted[k]
188 const sk = stickyKey(typeFromKey(k), iv)
189 if (!cur[k] || (sk != null ? nameWins(iv, cur[k]) : laterRecord(iv, cur[k]))) { cur[k] = iv; changed = true }
190 }
191 if (changed) { this._write(outboxKey(m.pub), cur); this._invalidate() }
192 this._emit()
193 }
194 }
195
196 _broadcastMine () { return this.bus ? this.bus.send({ t: 'outbox', pub: this.getMe(), view: this._outbox(this.getMe()) }) : undefined }
197 async announce () { this._addPeer(this.getMe()); await this._broadcastMine(); this._invalidate(); this._emit() }
198
199 async append (op) {
200 const me = this.getMe()
201 this._addPeer(me)
202 const box = this._outbox(me)
203 applyOp(box, { type: op.type, data: op.data })
204 this._write(outboxKey(me), box)
205 this._invalidate()
206 await this._broadcastMine()
207 this._emit()
208 return { ok: true }
209 }
210
211 async _merged () {
212 if (this._cache) return this._cache
213 if (this._inflight) return this._inflight
214 const epoch = this._epoch
215 this._inflight = (async () => {
216 const boxes = this._peers().map(pub => ({ pub, view: this._outbox(pub) }))
217 const claimed = this._read(CLAIMED_KEY) || {}
218 const merged = await mergeOutboxes(boxes, claimed, this.validate)
219 this._write(CLAIMED_KEY, claimed)
220 if (this._epoch === epoch) this._cache = merged // discard if invalidated mid-flight
221 this._inflight = null
222 return merged
223 })()
224 return this._inflight
225 }
226 _invalidate () { this._cache = null; this._inflight = null; this._epoch++ }
227
228 async get (key) { const v = await this._merged(); return Object.prototype.hasOwnProperty.call(v, key) ? v[key] : null }
229 async list (prefix, opts = {}) { return rangeFromView(await this._merged(), prefix ? { gte: prefix, lt: prefix + '\xff', limit: opts.limit } : { limit: opts.limit }) }
230 async range (opts = {}) { return rangeFromView(await this._merged(), opts) }
231 async count (prefix) { const v = await this._merged(); if (!prefix) return Object.keys(v).length; let n = 0; for (const k in v) if (k >= prefix && k < prefix + '\xff') n++; return n }
232 async status () { const v = await this._merged(); return { appId: 'p2pbuilders', mode: this.mode, secure: isSecure(), peers: this._peers().length, viewLength: Object.keys(v).length } }
233
234 onChange (fn) { this._listeners.add(fn); return () => this._listeners.delete(fn) }
235 _emit () { for (const fn of this._listeners) { try { fn() } catch (e) { console.error(e) } } }
236}
237
238// ---- real PearBrowser gossip ------------------------------------------------
239class BridgeGossipSync {
240 constructor ({ pear, getMe, identity, validate }) {
241 this.mode = 'gossip-bridge'
242 this.pear = pear
243 this.getMe = getMe
244 this.identity = identity
245 this.validate = validate || null
246 this._listeners = new Set()
247 this._peers = new Map() // pub -> { appId, inviteKey }
248 this._cache = null
249 this._inflight = null
250 this._epoch = 0
251 this._poll = null
252 }
253
254 // Full pubkey as appId (64 hex == bridge's max appId length) so two distinct
255 // users can never collide onto the same sync group.
256 _myAppId () { return this.getMe() }
257
258 async ready () {
259 await cryptoReady()
260 let key = null
261 try { key = localStorage.getItem('p2pb:my-outbox-key') } catch {}
262 try {
263 const r = key ? await this.pear.sync.join(this._myAppId(), key) : await this.pear.sync.create(this._myAppId())
264 this._myInvite = r.inviteKey
265 try { localStorage.setItem('p2pb:my-outbox-key', r.inviteKey) } catch {}
266 } catch {
267 const r = await this.pear.sync.create(this._myAppId()); this._myInvite = r.inviteKey
268 }
269 this._peers.set(this.getMe(), { appId: this._myAppId(), inviteKey: this._myInvite, self: true })
270 try {
271 this._channel = await this.pear.swarm.v1.join(TOPIC, { server: true, client: true, appName: 'p2pbuilders', reason: 'Discover other p2pbuilders peers' })
272 this._channel.on('peer', () => this._announce())
273 this._channel.on('message', (peer, data) => this._onDescriptor(data))
274 await this._announce()
275 } catch (e) { console.warn('[gossip] swarm unavailable:', e && e.message) }
276 this._poll = setInterval(() => { this._invalidate(); this._emit() }, 4000)
277 if (this._poll && this._poll.unref) this._poll.unref()
278 return this
279 }
280
281 async _announce () {
282 if (!this._channel) return
283 const pub = this.getMe(), appId = this._myAppId(), inviteKey = this._myInvite
284 let sig = null
285 try { sig = await this.identity.sign(`peerit-desc|${pub}|${appId}|${inviteKey}`) } catch {}
286 const desc = JSON.stringify({ t: 'outbox-desc', pub, appId, inviteKey, sig: sig && sig.signature, dk: sig && sig.driveKey, ns: sig && sig.namespace })
287 const bytes = new TextEncoder().encode(desc)
288 for (const p of this._channel.peers) { try { p.send(bytes) } catch {} }
289 }
290
291 async _onDescriptor (data) {
292 let d; try { d = JSON.parse(new TextDecoder().decode(data)) } catch { return }
293 if (!d || d.t !== 'outbox-desc' || !d.pub || d.pub === this.getMe()) return
294 if (this._peers.size >= MAX_PEERS) return
295 if (d.appId !== d.pub) return // appId must be the pubkey itself
296 if (d.ns !== 'peerit' || !d.sig || !d.dk) return
297 // The descriptor (pub, appId, inviteKey) must be signed by `pub`, binding the
298 // invite key to the identity — a peer can't redirect a victim's pub to a
299 // Hyperbee it controls.
300 const ok = await edVerify(d.pub, `pear.app.${d.dk}:peerit:peerit-desc|${d.pub}|${d.appId}|${d.inviteKey}`, d.sig).catch(() => false)
301 if (!ok) return
302 if (this._peers.has(d.pub)) return
303 try {
304 await this.pear.sync.join(d.appId, d.inviteKey) // only commit the peer AFTER a successful join
305 this._peers.set(d.pub, { appId: d.appId, inviteKey: d.inviteKey })
306 this._invalidate(); this._emit(); this._announce()
307 } catch (e) { console.warn('[gossip] join failed', e && e.message) }
308 }
309
310 announce () { return this._announce() }
311
312 async append (op) {
313 const r = await this.pear.sync.append(this._myAppId(), { type: op.type, data: op.data, timestamp: new Date().toISOString() })
314 this._invalidate(); this._emit()
315 return r
316 }
317
318 async _mergedAsync () {
319 const boxes = []
320 for (const [pub, info] of this._peers) {
321 try {
322 const rows = await this.pear.sync.list(info.appId, '', { limit: 1000 })
323 const view = {}
324 for (const r of rows) view[r.key] = r.value
325 boxes.push({ pub, view })
326 } catch {}
327 }
328 let claimed = {}
329 try { claimed = JSON.parse(localStorage.getItem(CLAIMED_KEY) || '{}') } catch {}
330 const merged = await mergeOutboxes(boxes, claimed)
331 try { localStorage.setItem(CLAIMED_KEY, JSON.stringify(claimed)) } catch {}
332 return merged
333 }
334 async _merged () {
335 if (this._cache) return this._cache
336 if (this._inflight) return this._inflight
337 const epoch = this._epoch
338 this._inflight = (async () => { const m = await this._mergedAsync(); if (this._epoch === epoch) this._cache = m; this._inflight = null; return m })()
339 return this._inflight
340 }
341 _invalidate () { this._cache = null; this._inflight = null; this._epoch++ }
342
343 async get (key) { const v = await this._merged(); return Object.prototype.hasOwnProperty.call(v, key) ? v[key] : null }
344 async list (prefix, opts = {}) { return rangeFromView(await this._merged(), prefix ? { gte: prefix, lt: prefix + '\xff', limit: opts.limit } : { limit: opts.limit }) }
345 async range (opts = {}) { return rangeFromView(await this._merged(), opts) }
346 async count (prefix) { const v = await this._merged(); if (!prefix) return Object.keys(v).length; let n = 0; for (const k in v) if (k >= prefix && k < prefix + '\xff') n++; return n }
347 async status () { const v = await this._merged(); return { appId: 'p2pbuilders', mode: this.mode, secure: isSecure(), peers: this._peers.size, viewLength: Object.keys(v).length, inviteKey: this._myInvite } }
348
349 onChange (fn) { this._listeners.add(fn); return () => this._listeners.delete(fn) }
350 _emit () { for (const fn of this._listeners) { try { fn() } catch (e) { console.error(e) } } }
351}
352
353// ---- bus adapters -----------------------------------------------------------
354export function makeHub () {
355 const peers = []
356 return {
357 connect () {
358 const self = { fn: null }
359 peers.push(self)
360 return {
361 send: async (m) => { const c = JSON.parse(JSON.stringify(m)); for (const p of peers) if (p !== self && p.fn) await p.fn(c) },
362 onMessage: (fn) => { self.fn = fn }
363 }
364 }
365 }
366}
367
368function browserBus (name) {
369 const bc = new BroadcastChannel(name)
370 return { send: (m) => { bc.postMessage(m) }, onMessage: (fn) => { bc.onmessage = (e) => fn(e.data) } }
371}
372
373export function createGossip ({ storage, pear, getMe, identity, channelName, forceDev, bus, validate } = {}) {
374 if (pear && pear.sync && pear.swarm && !forceDev) return new BridgeGossipSync({ pear, getMe, identity, validate })
375 const theBus = bus || (typeof BroadcastChannel !== 'undefined' ? browserBus(channelName || 'p2pb-gossip') : null)
376 return new GossipSync({ storage, bus: theBus, getMe, validate })
377}
378
379export { GossipSync, BridgeGossipSync, applyOp as gossipApplyOp }
web/js/identity.js +109 -0
……@@ -0,0 +1,109 @@
1// identity.js — who the user is and how they sign.
2//
3// BridgeIdentity — window.pear.identity: a stable per-app ed25519 sub-key and
4// real signatures over pear.app.<driveKey>:<ns>:<payload>.
5// DevIdentity — a roster of simulated users, each with a REAL ed25519 keypair
6// (via crypto.js). The active user is per-tab (sessionStorage)
7// so two tabs are two people. Seeds are kept in localStorage —
8// fine for local simulation; they are the user's secret key.
9//
10// ready(), me() -> { pubkey, driveKey, label },
11// sign(payload, namespace) -> { signature, publicKey, driveKey, namespace, algorithm },
12// isDev, listUsers(), switchUser(pub), createUser(name) (createUser is async)
13
14import { genKeyPair, sign as edSign } from './crypto.js'
15
16const NS = 'peerit'
17
18class DevIdentity {
19 constructor (storage, session) {
20 this.isDev = true
21 this.storage = storage
22 this.session = session
23 this.ROSTER = 'p2pb:dev:users'
24 }
25
26 async ready () {
27 let roster = this._roster()
28 if (!roster.length) { roster = [await this._mint('anon')]; this._saveRoster(roster) }
29 let active = this.session && this.session.getItem('p2pb:dev:active')
30 if (!active || !roster.find(u => u.pubkey === active)) {
31 active = roster[0].pubkey
32 if (this.session) this.session.setItem('p2pb:dev:active', active)
33 }
34 this._active = active
35 return this
36 }
37
38 _roster () { try { const s = this.storage.getItem(this.ROSTER); return s ? JSON.parse(s) : [] } catch { return [] } }
39 _saveRoster (r) { this.storage.setItem(this.ROSTER, JSON.stringify(r)) }
40
41 async _mint (name) {
42 const { seedHex, pubHex } = await genKeyPair()
43 return { pubkey: pubHex, seed: seedHex, driveKey: pubHex, label: name }
44 }
45
46 _meEntry () { return this._roster().find(x => x.pubkey === this._active) || null }
47 me () {
48 const u = this._meEntry() || { pubkey: this._active, driveKey: this._active, label: 'anon' }
49 return { pubkey: u.pubkey, driveKey: u.driveKey, label: u.label }
50 }
51
52 async sign (payload, namespace = NS) {
53 const u = this._meEntry()
54 if (!u) throw new Error('no active identity')
55 const tag = `pear.app.${u.driveKey}:${namespace}:`
56 const signature = await edSign(u.seed, tag + payload)
57 return { signature, publicKey: u.pubkey, driveKey: u.driveKey, namespace, algorithm: 'ed25519' }
58 }
59
60 listUsers () { return this._roster().map(u => ({ pubkey: u.pubkey, driveKey: u.driveKey, label: u.label })) }
61 switchUser (pub) {
62 if (this._roster().find(u => u.pubkey === pub)) {
63 this._active = pub
64 if (this.session) this.session.setItem('p2pb:dev:active', pub)
65 return true
66 }
67 return false
68 }
69 async createUser (name) {
70 const u = await this._mint(name || 'anon')
71 const roster = this._roster(); roster.push(u); this._saveRoster(roster)
72 this._active = u.pubkey
73 if (this.session) this.session.setItem('p2pb:dev:active', u.pubkey)
74 return { pubkey: u.pubkey, driveKey: u.driveKey, label: u.label }
75 }
76}
77
78class BridgeIdentity {
79 constructor (pearIdentity) { this.isDev = false; this.identity = pearIdentity }
80 async ready () {
81 const r = await this.identity.getPublicKey()
82 this._pub = r.publicKey
83 this._driveKey = r.driveKey
84 return this
85 }
86 me () { return { pubkey: this._pub, driveKey: this._driveKey, label: null } }
87 async sign (payload, namespace = NS) {
88 const r = await this.identity.sign(String(payload), namespace)
89 return { signature: r.signature, publicKey: r.publicKey || this._pub, driveKey: this._driveKey, namespace, algorithm: r.algorithm || 'ed25519' }
90 }
91 listUsers () { return [this.me()] }
92 switchUser () { return false }
93 async createUser () { return this.me() }
94}
95
96export function createIdentity (opts = {}) {
97 const pear = opts.pear || (typeof window !== 'undefined' ? window.pear : null)
98 if (pear && pear.identity && !opts.forceDev) return new BridgeIdentity(pear.identity)
99 const storage = opts.storage || (typeof localStorage !== 'undefined' ? localStorage : memShim())
100 const session = opts.session || (typeof sessionStorage !== 'undefined' ? sessionStorage : memShim())
101 return new DevIdentity(storage, session)
102}
103
104function memShim () {
105 const m = new Map()
106 return { getItem: k => (m.has(k) ? m.get(k) : null), setItem: (k, v) => m.set(k, String(v)), removeItem: k => m.delete(k) }
107}
108
109export { DevIdentity, BridgeIdentity }
web/js/markdown.js +138 -0
……@@ -0,0 +1,138 @@
1// markdown.js — a small, SAFE markdown renderer for user-generated post and
2// comment bodies. Everything is HTML-escaped first; we never emit raw user
3// HTML. Only http(s)://, hyper://, pear:// and #/ (in-app) links are allowed.
4
5import { escapeHtml } from './util.js'
6
7const SAFE_LINK = /^(https?:\/\/|hyper:\/\/|pear:\/\/|#\/|\/)/i
8
9function safeHref (url) {
10 const u = String(url || '').trim()
11 if (SAFE_LINK.test(u)) return u
12 return null
13}
14
15// Inline: code spans, bold, italic, strikethrough, explicit links, autolinks.
16function inline (text) {
17 let s = escapeHtml(text)
18
19 // `code`
20 s = s.replace(/`([^`]+)`/g, (_, c) => '<code>' + c + '</code>')
21
22 // [label](url)
23 s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (m, label, url) => {
24 const href = safeHref(url)
25 if (!href) return label
26 const ext = /^https?:/i.test(href)
27 return '<a href="' + href + '"' + (ext ? ' target="_blank" rel="noopener noreferrer nofollow"' : '') + '>' + label + '</a>'
28 })
29
30 // bare autolinks (avoid touching ones already inside an href="...")
31 s = s.replace(/(^|[\s(])((?:https?|hyper|pear):\/\/[^\s<)]+)/g, (m, pre, url) => {
32 const href = safeHref(url)
33 if (!href) return m
34 const ext = /^https?:/i.test(href)
35 return pre + '<a href="' + href + '"' + (ext ? ' target="_blank" rel="noopener noreferrer nofollow"' : '') + '>' + url + '</a>'
36 })
37
38 // **bold**, *italic*/_italic_, ~~strike~~
39 s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
40 s = s.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em>$2</em>')
41 s = s.replace(/(^|[^_])_([^_\n]+)_/g, '$1<em>$2</em>')
42 s = s.replace(/~~([^~]+)~~/g, '<del>$1</del>')
43
44 return s
45}
46
47export function renderMarkdown (src) {
48 const text = String(src == null ? '' : src).replace(/\r\n?/g, '\n')
49 const lines = text.split('\n')
50 const out = []
51 let i = 0
52
53 const flushParagraph = (buf) => {
54 if (buf.length) out.push('<p>' + inline(buf.join('\n')).replace(/\n/g, '<br>') + '</p>')
55 buf.length = 0
56 }
57 const para = []
58
59 while (i < lines.length) {
60 const line = lines[i]
61
62 // fenced code block ```
63 if (/^```/.test(line)) {
64 flushParagraph(para)
65 const code = []
66 i++
67 while (i < lines.length && !/^```/.test(lines[i])) { code.push(lines[i]); i++ }
68 i++ // skip closing fence
69 out.push('<pre><code>' + escapeHtml(code.join('\n')) + '</code></pre>')
70 continue
71 }
72
73 // headings #..######
74 const h = /^(#{1,6})\s+(.*)$/.exec(line)
75 if (h) {
76 flushParagraph(para)
77 const lvl = h[1].length
78 out.push('<h' + lvl + '>' + inline(h[2]) + '</h' + lvl + '>')
79 i++; continue
80 }
81
82 // horizontal rule
83 if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) {
84 flushParagraph(para)
85 out.push('<hr>')
86 i++; continue
87 }
88
89 // blockquote (collapse consecutive > lines)
90 if (/^>\s?/.test(line)) {
91 flushParagraph(para)
92 const quote = []
93 while (i < lines.length && /^>\s?/.test(lines[i])) { quote.push(lines[i].replace(/^>\s?/, '')); i++ }
94 out.push('<blockquote>' + renderMarkdown(quote.join('\n')) + '</blockquote>')
95 continue
96 }
97
98 // unordered list
99 if (/^\s*[-*+]\s+/.test(line)) {
100 flushParagraph(para)
101 const items = []
102 while (i < lines.length && /^\s*[-*+]\s+/.test(lines[i])) {
103 items.push('<li>' + inline(lines[i].replace(/^\s*[-*+]\s+/, '')) + '</li>'); i++
104 }
105 out.push('<ul>' + items.join('') + '</ul>')
106 continue
107 }
108
109 // ordered list
110 if (/^\s*\d+\.\s+/.test(line)) {
111 flushParagraph(para)
112 const items = []
113 while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) {
114 items.push('<li>' + inline(lines[i].replace(/^\s*\d+\.\s+/, '')) + '</li>'); i++
115 }
116 out.push('<ol>' + items.join('') + '</ol>')
117 continue
118 }
119
120 // blank line ends a paragraph
121 if (/^\s*$/.test(line)) { flushParagraph(para); i++; continue }
122
123 para.push(line)
124 i++
125 }
126 flushParagraph(para)
127 return out.join('\n')
128}
129
130// Plain-text excerpt (for feed previews / meta descriptions).
131export function excerpt (src, n = 220) {
132 const s = String(src || '')
133 .replace(/[`*_~#>\-]/g, '')
134 .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
135 .replace(/\s+/g, ' ')
136 .trim()
137 return s.length > n ? s.slice(0, n).trimEnd() + '…' : s
138}
web/js/model.js +95 -0
……@@ -0,0 +1,95 @@
1// model.js — the p2pbuilders data model (HN-shaped) expressed for the gossip
2// engine's generic reducer: an op { type, data } is stored at Hyperbee key
3// `type!data.id` (last-write-wins). Scope + identity are encoded into data.id so
4// prefix scans give cheap feeds, threads, vote tallies, and social-graph reads.
5//
6// board board!<name> (sticky: first creator owns the name)
7// post post!<board>!<cid>
8// comment comment!<postCid>!<cid> (parentCid links the thread)
9// vote vote!<targetCid>!<authorPub> (one per voter -> LWW)
10// profile profile!<authorPub>
11// follow follow!<authorPub>!<targetPub> (published social graph)
12// block block!<authorPub>!<targetPub> (public:true => advertised)
13// blocklist blocklist!<authorPub> (subscribable curated list)
14
15export const TYPE = {
16 BOARD: 'board', POST: 'post', COMMENT: 'comment', VOTE: 'vote',
17 PROFILE: 'profile', FOLLOW: 'follow', BLOCK: 'block', BLOCKLIST: 'blocklist'
18}
19
20export const DEFAULT_BOARD = 'front'
21
22export const keys = {
23 board: (name) => `${TYPE.BOARD}!${name}`,
24 boardPrefix: () => `${TYPE.BOARD}!`,
25 post: (board, cid) => `${TYPE.POST}!${board}!${cid}`,
26 postsIn: (board) => `${TYPE.POST}!${board}!`,
27 comment: (postCid, cid) => `${TYPE.COMMENT}!${postCid}!${cid}`,
28 commentsOn: (postCid) => `${TYPE.COMMENT}!${postCid}!`,
29 vote: (targetCid, author) => `${TYPE.VOTE}!${targetCid}!${author}`,
30 votesFor: (targetCid) => `${TYPE.VOTE}!${targetCid}!`,
31 voteAll: () => `${TYPE.VOTE}!`,
32 profile: (author) => `${TYPE.PROFILE}!${author}`,
33 follow: (author, target) => `${TYPE.FOLLOW}!${author}!${target}`,
34 followsBy: (author) => `${TYPE.FOLLOW}!${author}!`,
35 block: (author, target) => `${TYPE.BLOCK}!${author}!${target}`,
36 blocksBy: (author) => `${TYPE.BLOCK}!${author}!`,
37 blocklist: (author) => `${TYPE.BLOCKLIST}!${author}`,
38 blocklistPrefix: () => `${TYPE.BLOCKLIST}!`
39}
40
41// data.id builders (the part after `type!` — determines the storage key).
42export const id = {
43 board: (name) => name,
44 post: (board, cid) => `${board}!${cid}`,
45 comment: (postCid, cid) => `${postCid}!${cid}`,
46 vote: (targetCid, author) => `${targetCid}!${author}`,
47 profile: (author) => author,
48 follow: (author, target) => `${author}!${target}`,
49 block: (author, target) => `${author}!${target}`,
50 blocklist: (author) => author
51}
52
53// Board name rules: lowercase letters/digits/underscore, 2–24 chars.
54export function isValidBoard (s) { return typeof s === 'string' && /^[a-z0-9_]{2,24}$/.test(s) }
55export function normalizeBoard (s) { return String(s || '').toLowerCase().replace(/[^a-z0-9_]/g, '').slice(0, 24) }
56
57// Build a threaded comment tree from a flat list (parentCid -> children).
58// Orphans (missing parent) attach at root so nothing is lost.
59export function buildCommentTree (comments) {
60 const byCid = new Map()
61 for (const c of comments) byCid.set(c.cid, { ...c, children: [] })
62 const roots = []
63 for (const node of byCid.values()) {
64 const parent = node.parentCid && byCid.get(node.parentCid)
65 if (parent && parent !== node) parent.children.push(node)
66 else roots.push(node)
67 }
68 return { roots, index: byCid }
69}
70
71export function sortCommentTree (roots, sorter) {
72 const sorted = sorter(roots)
73 for (const n of sorted) if (n.children.length) n.children = sortCommentTree(n.children, sorter)
74 return sorted
75}
76
77export function countDescendants (node) {
78 let n = 0
79 for (const c of node.children) n += 1 + countDescendants(c)
80 return n
81}
82
83// Resolve the effective set of blocked pubkeys for the local viewer:
84// localBlocks — pubkeys you blocked on this device
85// subscribed — authors whose published blocklists you subscribe to
86// blocklistRecs — all `blocklist` records seen (author -> { list:[pubkey] })
87export function resolveBlocked (localBlocks, subscribed, blocklistRecs) {
88 const set = new Set(localBlocks || [])
89 const subs = new Set(subscribed || [])
90 for (const rec of blocklistRecs || []) {
91 if (!rec || !subs.has(rec.author)) continue
92 for (const k of rec.list || []) set.add(k)
93 }
94 return set
95}
web/js/pow.js +80 -0
……@@ -0,0 +1,80 @@
1// pow.js — proof-of-work spam gate (hashcash). The terminal app uses blake2b;
2// the browser port uses SHA-256 (SubtleCrypto, available everywhere). To submit
3// a post/comment/board you must find a nonce whose SHA-256(content|nonce) has at
4// least N leading zero bits — cheap once, expensive to spam. The pow {bits,nonce}
5// is part of the signed record, and is re-verified by every peer on ingest (via
6// the gossip `validate` hook), so unworked posts never enter the network.
7
8// Minimum difficulty per record type. Tuned for the browser (SubtleCrypto is
9// slower per hash than native blake2b, so these are lower than the terminal app's
10// 18/16/22). Empirically a few hundred ms to a couple seconds.
11export const MIN_BITS = { post: 16, comment: 14, board: 18 }
12
13// The bytes hashed for PoW: each op's IMMUTABLE identity (unique cid + author +
14// createdAt), not its mutable body. So creating an op costs work, editing/deleting
15// it reuses that work, and one op's proof can't be reused for another (unique cid).
16// The signature separately covers the whole record (incl. pow), so pow can't be
17// swapped or altered after the fact.
18export function powTarget (type, data) {
19 switch (type) {
20 case 'post': return `post|${data.board}|${data.cid}|${data.author}|${data.createdAt}`
21 case 'comment': return `comment|${data.postCid}|${data.cid}|${data.author}|${data.createdAt}`
22 case 'board': return `board|${data.name}|${data.creator}|${data.createdAt}`
23 default: return type + '|' + (data.author || '')
24 }
25}
26
27function leadingZeroBits (u8) {
28 let n = 0
29 for (let i = 0; i < u8.length; i++) {
30 const b = u8[i]
31 if (b === 0) { n += 8; continue }
32 n += Math.clz32(b) - 24
33 break
34 }
35 return n
36}
37
38async function sha256 (str) {
39 const buf = await globalThis.crypto.subtle.digest('SHA-256', new TextEncoder().encode(str))
40 return new Uint8Array(buf)
41}
42
43// Mint a proof for `data`. Returns { bits, nonce }. Yields to the event loop
44// periodically and reports progress so the UI stays responsive.
45export async function mint (type, data, bits, opts = {}) {
46 const target = powTarget(type, data)
47 let nonce = 0
48 for (;;) {
49 const h = await sha256(target + '|' + nonce)
50 if (leadingZeroBits(h) >= bits) return { bits, nonce }
51 nonce++
52 if ((nonce & 1023) === 0) {
53 if (opts.onProgress) opts.onProgress(nonce)
54 if (opts.signal && opts.signal.aborted) throw new Error('proof-of-work cancelled')
55 await new Promise(r => setTimeout(r, 0))
56 }
57 }
58}
59
60// Verify a record carries valid PoW of at least minBits.
61export async function verify (type, data, minBits) {
62 const pow = data && data.pow
63 if (!pow || typeof pow.bits !== 'number' || typeof pow.nonce !== 'number') return false
64 if (pow.bits < minBits) return false
65 const h = await sha256(powTarget(type, data) + '|' + pow.nonce)
66 return leadingZeroBits(h) >= pow.bits
67}
68
69// Build the gossip `validate` gate: posts/comments/boards must carry valid PoW;
70// votes/profiles/follows/blocks/blocklists are exempt (rate/reputation-gated).
71export function makeValidator (minBits = MIN_BITS) {
72 return async (type, val) => {
73 if (type === 'post') return verify(type, val, minBits.post)
74 if (type === 'comment') return verify(type, val, minBits.comment)
75 if (type === 'board') return verify(type, val, minBits.board)
76 return true
77 }
78}
79
80export { leadingZeroBits }
web/js/prefs.js +49 -0
……@@ -0,0 +1,49 @@
1// prefs.js — per-device, per-identity local state that doesn't belong in the
2// shared P2P log: which curators' blocklists you subscribe to, posts you've
3// hidden, your default sort, and your default board. Keyed by the active pubkey.
4
5export class Prefs {
6 constructor (storage, pubkey) {
7 this.storage = storage || (typeof localStorage !== 'undefined' ? localStorage : memShim())
8 this.pub = pubkey || 'anon'
9 this.key = 'p2pb:prefs:' + this.pub
10 this.data = this._load()
11 }
12
13 _load () {
14 try {
15 const d = JSON.parse(this.storage.getItem(this.key) || '{}')
16 return Object.assign({ blocklistSubs: [], hidden: [], sort: 'hot', board: 'front', seenWelcome: false }, d)
17 } catch {
18 return { blocklistSubs: [], hidden: [], sort: 'hot', board: 'front', seenWelcome: false }
19 }
20 }
21 _save () { this.storage.setItem(this.key, JSON.stringify(this.data)) }
22
23 // Subscribed blocklists (curator pubkeys)
24 isSubscribedBlocklist (author) { return this.data.blocklistSubs.includes(author) }
25 toggleBlocklist (author) {
26 if (this.isSubscribedBlocklist(author)) this.data.blocklistSubs = this.data.blocklistSubs.filter(a => a !== author)
27 else this.data.blocklistSubs.push(author)
28 this._save(); return this.isSubscribedBlocklist(author)
29 }
30 blocklistSubs () { return this.data.blocklistSubs.slice() }
31
32 // Hidden posts (ref = "board/cid")
33 isHidden (ref) { return this.data.hidden.includes(ref) }
34 toggleHidden (ref) {
35 if (this.isHidden(ref)) this.data.hidden = this.data.hidden.filter(r => r !== ref)
36 else this.data.hidden.unshift(ref)
37 this._save(); return this.isHidden(ref)
38 }
39
40 setSort (s) { this.data.sort = s; this._save() }
41 get sort () { return this.data.sort }
42 markWelcomeSeen () { this.data.seenWelcome = true; this._save() }
43 get seenWelcome () { return this.data.seenWelcome }
44}
45
46function memShim () {
47 const m = new Map()
48 return { getItem: k => (m.has(k) ? m.get(k) : null), setItem: (k, v) => m.set(k, String(v)), removeItem: k => m.delete(k) }
49}
web/js/ranking.js +33 -0
……@@ -0,0 +1,33 @@
1// ranking.js — Hacker-News-style ranking. Posts rank by reputation-weighted
2// score decayed by age; comments rank by weighted score. Pure functions over
3// records carrying { createdAt, score } where `score` is the weighted score the
4// data layer computed.
5
6// HN hot: gravity 1.8, mild score curve. Newer + higher-scored ranks higher.
7export function hotScore (score, createdAt, now = Date.now()) {
8 const hours = Math.max(0, (now - createdAt) / 3600000)
9 return Math.pow(Math.max(0, score) + 1, 0.8) / Math.pow(hours + 2, 1.8)
10}
11
12export const POST_SORTS = ['hot', 'new', 'top']
13export const COMMENT_SORTS = ['best', 'new', 'top']
14
15export function sortPosts (posts, sort = 'hot', now = Date.now()) {
16 const cmp = sort === 'new'
17 ? (a, b) => b.createdAt - a.createdAt
18 : sort === 'top'
19 ? (a, b) => (b.score - a.score) || (b.createdAt - a.createdAt)
20 : (a, b) => hotScore(b.score, b.createdAt, now) - hotScore(a.score, a.createdAt, now)
21 return posts.slice().sort((a, b) => {
22 const sa = a.stickied ? 1 : 0, sb = b.stickied ? 1 : 0
23 if (sa !== sb) return sb - sa
24 return cmp(a, b)
25 })
26}
27
28export function sortComments (nodes, sort = 'best', now = Date.now()) {
29 const cmp = sort === 'new'
30 ? (a, b) => b.createdAt - a.createdAt
31 : (a, b) => (b.score - a.score) || (b.createdAt - a.createdAt) // best/top by weighted score
32 return nodes.slice().sort(cmp)
33}
web/js/reputation.js +32 -0
……@@ -0,0 +1,32 @@
1// reputation.js — vote weighting. A vote's influence scales with the VOTER's
2// reputation, so a brand-new key can vote but barely moves the needle, while an
3// established contributor (older + upvoted) carries more weight. This blunts
4// (does not eliminate) Sybil ballot-stuffing — see README. Pure function; the
5// data layer supplies ageDays + receivedUpvotes (and caches them).
6//
7// weight = clamp( log2(1 + ageDays) * sqrt(1 + receivedUpvotes) / 50, 0.02, 1 )
8// ~0.02 floor : a fresh key still counts, just barely
9// ~0.33 : ~30 days old + 10 upvotes received
10// ~0.93 : ~90 days old + 50 upvotes received
11
12export function weight (ageDays, receivedUpvotes) {
13 const a = Math.max(0, ageDays || 0)
14 const r = Math.max(0, receivedUpvotes || 0)
15 const w = Math.log2(1 + a) * Math.sqrt(1 + r) / 50
16 return Math.max(0.02, Math.min(1, w))
17}
18
19// Weighted tally of a target's votes. `votes` = [{ author, dir }]; weightOf(pub)
20// returns that voter's reputation weight. Returns { up, down, score, weighted, myVote }.
21export function weightedTally (votes, weightOf, me) {
22 const last = new Map()
23 for (const v of votes || []) last.set(v.author, v.dir)
24 let up = 0, down = 0, weighted = 0, myVote = 0
25 for (const [author, dir] of last) {
26 if (dir === 1) up++
27 else if (dir === -1) down++
28 if (me && author === me) myVote = dir
29 weighted += (dir || 0) * weight(...(weightOf(author) || [0, 0]))
30 }
31 return { up, down, score: up - down, weighted, myVote, total: up + down }
32}
web/js/sync.js +224 -0
……@@ -0,0 +1,224 @@
1// sync.js — the data transport. Two interchangeable backends behind one API:
2//
3// BridgeSync — real PearBrowser. Wraps window.pear.sync (Autobase+Hyperbee
4// shared group). All peers replicate the same materialized view.
5//
6// DevSync — plain browser / Node. localStorage-backed store that REIMPLEMENTS
7// the bridge's generic reducer byte-for-byte, with BroadcastChannel
8// live updates so multiple tabs behave like multiple peers sharing
9// one world. Lets us build & verify the whole app without PearBrowser.
10//
11// Both expose the same async surface used by data.js:
12// ready(), append({type,data}), get(key), list(prefix,{limit}),
13// range(opts), count(prefix), status(), onChange(fn), mode
14//
15// The view key for an op is `type.replace(':','!') + '!' + data.id`, matching
16// pearbrowser-desktop/backend/pear-bridge.js _defaultApply generic fallback.
17
18import { createGossip } from './gossip.js'
19
20export const APP_ID = 'p2pbuilders'
21
22// ---- shared reducer (mirrors the bridge generic fallback) -------------------
23function applyOp (view, op) {
24 if (!op || typeof op !== 'object' || !op.type) return
25 if (op.data && op.data.id != null) {
26 const key = op.type.replace(':', '!') + '!' + op.data.id
27 view[key] = op.data
28 }
29}
30
31function rangeFromView (view, opts) {
32 let entries = Object.keys(view).sort()
33 const { gte, gt, lte, lt } = opts
34 if (gte != null) entries = entries.filter(k => k >= gte)
35 if (gt != null) entries = entries.filter(k => k > gt)
36 if (lte != null) entries = entries.filter(k => k <= lte)
37 if (lt != null) entries = entries.filter(k => k < lt)
38 if (opts.reverse) entries.reverse()
39 const limit = clampLimit(opts.limit)
40 const out = []
41 for (const k of entries) {
42 if (out.length >= limit) break
43 out.push({ key: k, value: view[k] })
44 }
45 return out
46}
47
48function clampLimit (n) {
49 n = Number(n) || 100
50 if (n < 1) n = 100
51 if (n > 1000) n = 1000
52 return n
53}
54
55// ---- DevSync ----------------------------------------------------------------
56class DevSync {
57 constructor (storage, channelName) {
58 this.mode = 'dev'
59 this.storage = storage
60 this.VIEW_KEY = 'p2pb:view'
61 this.META_KEY = 'p2pb:meta'
62 this._listeners = new Set()
63 this._channel = null
64 this._channelName = channelName || 'peerit'
65 }
66
67 async ready () {
68 if (!this._read(this.VIEW_KEY)) this._write(this.VIEW_KEY, {})
69 if (!this._read(this.META_KEY)) this._write(this.META_KEY, { createdAt: Date.now(), len: 0 })
70 // Live updates across tabs.
71 if (typeof BroadcastChannel !== 'undefined') {
72 this._channel = new BroadcastChannel(this._channelName)
73 this._channel.onmessage = (e) => { if (e && e.data === 'changed') this._emit() }
74 }
75 if (typeof addEventListener !== 'undefined') {
76 addEventListener('storage', (e) => { if (e && e.key === this.VIEW_KEY) this._emit() })
77 }
78 return this
79 }
80
81 _read (k) {
82 try { const s = this.storage.getItem(k); return s ? JSON.parse(s) : null } catch { return null }
83 }
84 _write (k, v) { this.storage.setItem(k, JSON.stringify(v)) }
85
86 _view () { return this._read(this.VIEW_KEY) || {} }
87
88 async append (op) {
89 const view = this._view()
90 applyOp(view, { type: op.type, data: op.data })
91 this._write(this.VIEW_KEY, view)
92 const meta = this._read(this.META_KEY) || { len: 0 }
93 meta.len = (meta.len || 0) + 1
94 this._write(this.META_KEY, meta)
95 this._broadcast()
96 return { ok: true }
97 }
98
99 async get (key) {
100 const view = this._view()
101 return Object.prototype.hasOwnProperty.call(view, key) ? view[key] : null
102 }
103
104 async list (prefix, opts = {}) {
105 const view = this._view()
106 return rangeFromView(view, prefix
107 ? { gte: prefix, lt: prefix + '\xff', limit: opts.limit }
108 : { limit: opts.limit })
109 }
110
111 async range (opts = {}) { return rangeFromView(this._view(), opts) }
112
113 async count (prefix) {
114 const view = this._view()
115 if (!prefix) return Object.keys(view).length
116 let n = 0
117 for (const k of Object.keys(view)) if (k >= prefix && k < prefix + '\xff') n++
118 return n
119 }
120
121 async status () {
122 const meta = this._read(this.META_KEY) || {}
123 return { appId: APP_ID, mode: 'dev', inviteKey: 'dev-local', writerCount: 1, viewLength: meta.len || 0 }
124 }
125
126 onChange (fn) { this._listeners.add(fn); return () => this._listeners.delete(fn) }
127 _emit () { for (const fn of this._listeners) { try { fn() } catch (e) { console.error(e) } } }
128 _broadcast () { this._emit(); if (this._channel) try { this._channel.postMessage('changed') } catch {} }
129}
130
131// ---- BridgeSync (real PearBrowser) ------------------------------------------
132class BridgeSync {
133 constructor (pearSync, storage) {
134 this.mode = 'bridge'
135 this.sync = pearSync
136 this.storage = storage
137 this._listeners = new Set()
138 this._poll = null
139 this._lastLen = -1
140 }
141
142 async ready () {
143 // Shared-group MVP: join a well-known origin group if we have its key,
144 // otherwise create one (this device becomes the origin writer) and remember
145 // the key locally so we rejoin the same world next launch. The created key
146 // is surfaced in Settings so it can be baked into a published build.
147 let key = null
148 try { key = this.storage && this.storage.getItem('p2pb:groupKey') } catch {}
149 key = key || (typeof GLOBAL_GROUP_KEY === 'string' ? GLOBAL_GROUP_KEY : null)
150 try {
151 if (key) {
152 const r = await this.sync.join(APP_ID, key)
153 this.writerPublicKey = r.writerPublicKey
154 this.inviteKey = r.inviteKey
155 } else {
156 const r = await this.sync.create(APP_ID)
157 this.writerPublicKey = r.writerPublicKey
158 this.inviteKey = r.inviteKey
159 try { this.storage && this.storage.setItem('p2pb:groupKey', r.inviteKey) } catch {}
160 }
161 } catch (err) {
162 // join failed (bad/absent group) -> fall back to creating our own.
163 const r = await this.sync.create(APP_ID)
164 this.writerPublicKey = r.writerPublicKey
165 this.inviteKey = r.inviteKey
166 try { this.storage && this.storage.setItem('p2pb:groupKey', r.inviteKey) } catch {}
167 }
168 // The bridge has no push channel; poll the view length to detect peer writes.
169 this._startPolling()
170 return this
171 }
172
173 _startPolling () {
174 if (this._poll) return
175 this._poll = setInterval(async () => {
176 try {
177 const s = await this.sync.status(APP_ID)
178 const len = s && s.viewLength
179 if (len !== this._lastLen) { this._lastLen = len; this._emit() }
180 } catch {}
181 }, 4000)
182 }
183
184 async append (op) {
185 return this.sync.append(APP_ID, { type: op.type, data: op.data, timestamp: new Date().toISOString() })
186 }
187 async get (key) { return this.sync.get(APP_ID, key) }
188 async list (prefix, opts = {}) { return this.sync.list(APP_ID, prefix, opts) }
189 async range (opts = {}) { return this.sync.range(APP_ID, opts) }
190 async count (prefix) { const r = await this.sync.count(APP_ID, prefix); return typeof r === 'object' ? r.count : r }
191 async status () { const s = await this.sync.status(APP_ID); return { ...s, mode: 'bridge', inviteKey: this.inviteKey } }
192 onChange (fn) { this._listeners.add(fn); return () => this._listeners.delete(fn) }
193 _emit () { for (const fn of this._listeners) { try { fn() } catch (e) { console.error(e) } } }
194}
195
196// Placeholder for a baked-in origin group key (set when publishing a shared world).
197const GLOBAL_GROUP_KEY = null
198
199// Factory: pick the backend for the current environment.
200// default -> gossip multi-writer (per-user outbox + aggregation) — see gossip.js
201// mode:'shared' -> single shared group (BridgeSync / DevSync). Kept for the
202// simple case and the original tests.
203export function createSync (opts = {}) {
204 const storage = opts.storage || (typeof localStorage !== 'undefined' ? localStorage : memoryStorage())
205 const pear = opts.pear || (typeof window !== 'undefined' ? window.pear : null)
206 if ((opts.mode || 'gossip') === 'gossip') {
207 return createGossip({ storage, pear, getMe: opts.getMe, identity: opts.identity, channelName: opts.channelName, forceDev: opts.forceDev, bus: opts.bus, validate: opts.validate })
208 }
209 if (pear && pear.sync && !opts.forceDev) return new BridgeSync(pear.sync, storage)
210 return new DevSync(storage, opts.channelName)
211}
212
213// In-memory localStorage shim (Node tests / SSR safety).
214export function memoryStorage () {
215 const m = new Map()
216 return {
217 getItem: (k) => (m.has(k) ? m.get(k) : null),
218 setItem: (k, v) => m.set(k, String(v)),
219 removeItem: (k) => m.delete(k),
220 clear: () => m.clear()
221 }
222}
223
224export { DevSync, BridgeSync, applyOp }
web/js/util.js +106 -0
……@@ -0,0 +1,106 @@
1// util.js — small, dependency-free helpers shared across peerit.
2// Pure logic only (no DOM) so it can be unit-tested under Node.
3
4const B36 = '0123456789abcdefghijklmnopqrstuvwxyz'
5
6// Monotonic-ish unique id: time prefix (base36) + randomness. Sorts roughly by
7// creation time when compared lexically, which is handy for fallback ordering.
8export function uid (rand = Math.random) {
9 const t = Date.now().toString(36).padStart(9, '0')
10 let r = ''
11 for (let i = 0; i < 8; i++) r += B36[Math.floor(rand() * 36)]
12 return t + r
13}
14
15// Inverted timestamp string: newer => smaller, so ascending key scans yield
16// reverse-chronological order. 13 digits covers dates well past year 5000.
17export function invTs (ms = Date.now()) {
18 return String(9999999999999 - ms).padStart(13, '0')
19}
20
21export function clamp (n, lo, hi) { return Math.max(lo, Math.min(hi, n)) }
22
23// Community slug rules: lowercase letters, digits, underscores; 2-24 chars.
24export function normalizeSlug (s) {
25 return String(s || '').toLowerCase().replace(/[^a-z0-9_]/g, '').slice(0, 24)
26}
27export function isValidSlug (s) {
28 return typeof s === 'string' && /^[a-z0-9_]{2,24}$/.test(s)
29}
30
31export function shortKey (k, n = 6) {
32 if (!k) return '?'
33 const s = String(k)
34 return s.length <= n * 2 ? s : s.slice(0, n) + '…' + s.slice(-4)
35}
36
37// Reddit-style relative time.
38export function timeAgo (ms, now = Date.now()) {
39 const s = Math.max(0, Math.floor((now - ms) / 1000))
40 if (s < 45) return 'just now'
41 const m = Math.floor(s / 60)
42 if (m < 60) return m + (m === 1 ? ' minute ago' : ' minutes ago')
43 const h = Math.floor(m / 60)
44 if (h < 24) return h + (h === 1 ? ' hour ago' : ' hours ago')
45 const d = Math.floor(h / 24)
46 if (d < 30) return d + (d === 1 ? ' day ago' : ' days ago')
47 const mo = Math.floor(d / 30)
48 if (mo < 12) return mo + (mo === 1 ? ' month ago' : ' months ago')
49 const y = Math.floor(d / 365)
50 return y + (y === 1 ? ' year ago' : ' years ago')
51}
52
53// Compact score: 12500 -> "12.5k".
54export function fmtCount (n) {
55 n = Number(n) || 0
56 const abs = Math.abs(n)
57 if (abs < 1000) return String(n)
58 if (abs < 1000000) return (n / 1000).toFixed(n % 1000 === 0 ? 0 : 1).replace(/\.0$/, '') + 'k'
59 return (n / 1000000).toFixed(1).replace(/\.0$/, '') + 'm'
60}
61
62export function escapeHtml (s) {
63 return String(s == null ? '' : s)
64 .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
65 .replace(/"/g, '&quot;').replace(/'/g, '&#39;')
66}
67
68// Deterministic pastel color from a string (used for avatar/community tints).
69export function colorFor (str) {
70 let h = 0
71 const s = String(str || '')
72 for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0
73 return 'hsl(' + (h % 360) + ' 62% 55%)'
74}
75
76// Parse a hash route like "#/r/foo/comments/abc?sort=top" into
77// { path:['r','foo','comments','abc'], query:{sort:'top'} }.
78export function parseRoute (hash) {
79 const raw = String(hash || '').replace(/^#/, '') || '/'
80 const [p, q] = raw.split('?')
81 const path = p.split('/').filter(Boolean).map(decodeURIComponent)
82 const query = {}
83 if (q) {
84 for (const part of q.split('&')) {
85 if (!part) continue
86 const [k, v = ''] = part.split('=')
87 query[decodeURIComponent(k)] = decodeURIComponent(v)
88 }
89 }
90 return { path, query }
91}
92
93export function buildRoute (path, query) {
94 let h = '#/' + (Array.isArray(path) ? path : [path]).map(encodeURIComponent).join('/')
95 const q = query && Object.keys(query).filter(k => query[k] != null && query[k] !== '')
96 if (q && q.length) h += '?' + q.map(k => k + '=' + encodeURIComponent(query[k])).join('&')
97 return h
98}
99
100export function debounce (fn, ms) {
101 let t = null
102 return function (...args) {
103 clearTimeout(t)
104 t = setTimeout(() => fn.apply(this, args), ms)
105 }
106}
web/js/verify.js +36 -0
……@@ -0,0 +1,36 @@
1// verify.js — record authenticity. The ONLY thing that makes a record
2// trustworthy is a valid Ed25519 signature from the key it claims as author.
3// The transport (which outbox relayed it) carries no authority — so an attacker
4// who relays a victim-labelled outbox full of fabricated records gains nothing:
5// every fabricated record fails this check and is dropped by the merge.
6//
7// verifyRecord -> 'ok' signature valid, signer === claimed author
8// 'bad' forged / tampered / wrong signer / unsigned (secure mode)
9// 'unverifiable' no crypto backend at all (cooperative dev only)
10
11import { canonical, ownerOf } from './canon.js'
12import { verify as edVerify, isSecure, ready as cryptoReady } from './crypto.js'
13
14const HEX64 = /^[0-9a-f]{64}$/i
15const NS = 'peerit'
16
17function signedMessage (type, data) {
18 // Matches what identity.sign signs: pear.app.<driveKey>:<namespace>:<payload>.
19 // _ns is pinned to the constant and _dk is validated as 64-hex by the caller,
20 // so neither can contain a ':' that would shift the field boundaries.
21 return `pear.app.${data._dk}:${NS}:` + canonical(type, data)
22}
23
24export async function verifyRecord (type, data) {
25 if (!data || !data._k) return 'unverifiable'
26 // The signer must BE the claimed author — no signing as someone else.
27 if (data._k !== ownerOf(type, data)) return 'bad'
28 await cryptoReady()
29 if (!isSecure()) return 'unverifiable' // no platform crypto -> cooperative dev
30 if (!data._sig) return 'bad' // secure mode: unsigned is untrusted
31 // Domain pinning: reject anything not signed in peerit's own context, and any
32 // malformed driveKey/pubkey that could make the signed envelope ambiguous.
33 if (data._ns !== NS) return 'bad'
34 if (!HEX64.test(data._dk || '') || !HEX64.test(data._k)) return 'bad'
35 return (await edVerify(data._k, signedMessage(type, data), data._sig)) ? 'ok' : 'bad'
36}
web/manifest.json +24 -0
……@@ -0,0 +1,24 @@
1{
2 "id": "p2pbuilders-web",
3 "name": "p2pbuilders",
4 "description": "Permissionless peer-to-peer Hacker News for anons who build P2P — now in the browser. No servers, no accounts. Posts are proof-of-work gated and votes are reputation-weighted; boards, threaded comments, follows and subscribable blocklists replicate directly between peers over the same Holepunch gossip engine as peerit. A PearBrowser P2P site.",
5 "version": "1.0.0",
6 "author": "defidon",
7 "categories": [
8 "community",
9 "social",
10 "p2p",
11 "site"
12 ],
13 "publishedAt": "2026-06-23T00:00:00Z",
14 "type": "site",
15 "driveKey": "ac1977a75cc84b46af0af8bb559cd4ebbe10507eb0f51d863e289d09635f6d74",
16 "url": "hyper://ac1977a75cc84b46af0af8bb559cd4ebbe10507eb0f51d863e289d09635f6d74/",
17 "homepage": "hyper://ac1977a75cc84b46af0af8bb559cd4ebbe10507eb0f51d863e289d09635f6d74/",
18 "license": "MIT",
19 "platforms": [
20 "macos",
21 "windows",
22 "linux"
23 ]
24}
web/publish.mjs +74 -0
……@@ -0,0 +1,74 @@
1#!/usr/bin/env node
2/**
3 * publish.mjs — publish the p2pbuilders browser site to HiveRelay (and the
4 * PearBrowser catalog), or host it locally for testing.
5 *
6 * node publish.mjs --local # create the drive + host it locally for PearBrowser
7 * # (no relay seeding, not in the catalog) — keeps running
8 * node publish.mjs # publish + seed to the live fleet + register in catalog
9 * KEEP=1 node publish.mjs # stay online so relays fully anchor the drive
10 *
11 * The non-local form is OUTWARD-FACING (public network). Run deliberately.
12 */
13import { HiveRelayClient } from '/Users/localllm/Projects/pear-ecosystem/00-core/hiverelay/packages/client/index.js'
14import { readFileSync, writeFileSync } from 'fs'
15import { fileURLToPath } from 'url'
16import { dirname, join } from 'path'
17
18const __dir = dirname(fileURLToPath(import.meta.url))
19const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
20const relayCount = (s) => (s && (s.relays ? s.relays.length : s.relayCount)) || 0
21const LOCAL = process.argv.includes('--local')
22
23const SITE_FILES = [
24 'index.html', 'styles.css', 'icon.svg',
25 'js/app.js', 'js/canon.js', 'js/crypto.js', 'js/data.js', 'js/gossip.js',
26 'js/identity.js', 'js/markdown.js', 'js/model.js', 'js/pow.js', 'js/prefs.js',
27 'js/ranking.js', 'js/reputation.js', 'js/sync.js', 'js/util.js', 'js/verify.js'
28]
29
30async function main () {
31 const manifestPath = join(__dir, 'manifest.json')
32 const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
33
34 const client = new HiveRelayClient({ storage: join(__dir, LOCAL ? '.hiverelay-local' : '.hiverelay-seed') })
35 await client.start()
36 await sleep(LOCAL ? 1500 : 5000)
37 console.log('[p2pb] relays connected:', relayCount(client.getStatus && client.getStatus()))
38
39 const files = SITE_FILES.map((p) => ({ path: '/' + p, content: readFileSync(join(__dir, p)) }))
40 console.log('[p2pb] publishing site drive (' + files.length + ' files)…')
41 const drive = await client.publish(files, { appId: 'p2pbuilders-web', seed: !LOCAL, replicas: 4, ttlDays: 365 })
42 const driveKey = drive.key.toString('hex')
43 console.log('[p2pb] site drive key:', driveKey)
44
45 if (LOCAL) {
46 console.log('\n[p2pb] ── LOCAL TEST (not seeded to relays, not in catalog) ──')
47 console.log('[p2pb] Open this in PearBrowser:\n\n hyper://' + driveKey + '/\n')
48 console.log('[p2pb] Keep THIS process running so PearBrowser can replicate the drive. (Ctrl-C to stop.)')
49 setInterval(() => {}, 1 << 30)
50 return
51 }
52
53 manifest.driveKey = driveKey
54 manifest.url = 'hyper://' + driveKey + '/'
55 manifest.homepage = manifest.url
56 writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n')
57 console.log('[p2pb] manifest.json updated with driveKey')
58
59 console.log('[p2pb] publishing manifest + seeding for catalog…')
60 await client.publish([{ path: '/manifest.json', content: JSON.stringify(manifest, null, 2) }],
61 { appId: 'p2pbuilders-web-manifest', seed: true, replicas: 4, ttlDays: 365 })
62 try {
63 const res = await client.seed(Buffer.from(driveKey, 'hex'), { replicas: 4, ttlDays: 365, timeout: 30000 })
64 console.log('[p2pb] seed acceptances:', (res || []).length)
65 } catch (err) { console.log('[p2pb] seed note:', err.message) }
66
67 console.log('\n[p2pb] Live at: hyper://' + driveKey + '/\n')
68 if (process.env.KEEP === '1') { console.log('[p2pb] staying alive so relays anchor (Ctrl-C to stop)…'); setInterval(() => {}, 1 << 30); return }
69 await sleep(20000)
70 try { if (client.destroy) await client.destroy() } catch {}
71 process.exit(0)
72}
73
74main().catch((err) => { console.error('[p2pb] failed:', err.stack || err.message); process.exit(1) })
web/styles.css +178 -0
……@@ -0,0 +1,178 @@
1/* p2pbuilders — dark "terminal hacker news" theme, orange accent */
2:root{
3 --bg:#0b0d10; --surface:#13161b; --surface-2:#1b1f26; --border:#242a33; --border-2:#333b47;
4 --text:#e7ebf0; --muted:#9aa4b2; --faint:#646f7e;
5 --orange:#ff6a1a; --orange-2:#ff8c42; --up:#ff6a1a; --good:#36d399; --danger:#ff5d6c; --link:#7aa2ff;
6 --radius:8px; --mono:ui-monospace,"SF Mono",SFMono-Regular,Menlo,Consolas,monospace;
7 --sans:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
8}
9*{box-sizing:border-box}
10html,body{margin:0;padding:0}
11body{background:var(--bg);color:var(--text);font-family:var(--sans);font-size:14px;line-height:1.5;-webkit-font-smoothing:antialiased}
12a{color:inherit;text-decoration:none}
13.mono{font-family:var(--mono)} .dim{color:var(--muted)} .small{font-size:12px}
14
15/* boot */
16.boot{position:fixed;inset:0;display:grid;place-content:center;justify-items:center;gap:6px;background:var(--bg);z-index:5}
17.boot-mark{width:46px;height:46px;border-radius:8px;display:grid;place-items:center;font-weight:800;font-size:24px;color:#0b0d10;background:var(--orange)}
18.boot-name{font-weight:800;font-size:20px;font-family:var(--mono)}
19.boot-sub{color:var(--faint);font-size:12px}
20
21/* topbar */
22.topbar{position:sticky;top:0;z-index:40;display:flex;align-items:center;gap:14px;height:48px;padding:0 14px;background:var(--surface);border-bottom:2px solid var(--orange)}
23.brand{display:flex;align-items:center;gap:8px;font-family:var(--mono);font-weight:800}
24.brand-mark{width:24px;height:24px;border-radius:6px;display:grid;place-items:center;color:#0b0d10;background:var(--orange);font-weight:900}
25.brand-name{font-size:15px}
26.topnav{display:flex;gap:14px}
27.topnav a{color:var(--muted);font-size:13.5px;font-weight:600}
28.topnav a:hover{color:var(--orange)}
29.search{margin-left:auto}
30.search input{height:30px;width:160px;padding:0 10px;border-radius:6px;border:1px solid var(--border-2);background:var(--bg);color:var(--text);font-size:13px;outline:none}
31.search input:focus{border-color:var(--orange)}
32.usermenu{position:relative}
33
34/* user menu */
35.user-pill{display:flex;align-items:center;gap:7px;background:var(--surface-2);border:1px solid var(--border);border-radius:999px;padding:3px 11px 3px 4px;cursor:pointer;color:var(--text)}
36.user-pill:hover{border-color:var(--border-2)}
37.avatar{width:22px;height:22px;border-radius:50%;display:inline-block;flex-shrink:0}
38.avatar.sm{width:18px;height:18px} .avatar.lg{width:54px;height:54px;border-radius:14px}
39.uname{font-size:13px;font-weight:600;font-family:var(--mono)}
40.dropdown{position:absolute;right:0;top:40px;min-width:210px;background:var(--surface);border:1px solid var(--border-2);border-radius:var(--radius);padding:6px;box-shadow:0 16px 44px rgba(0,0,0,.6);z-index:60}
41.dropdown a,.dropdown .dd-user{display:flex;align-items:center;gap:8px;padding:8px 11px;border-radius:6px;font-size:13.5px;width:100%;background:none;border:0;color:var(--text);cursor:pointer;text-align:left}
42.dropdown a:hover,.dropdown .dd-user:hover{background:var(--surface-2)}
43.dd-sep{height:1px;background:var(--border);margin:5px 4px}
44.dd-label{font-size:10.5px;text-transform:uppercase;letter-spacing:.08em;color:var(--faint);padding:6px 11px 2px}
45.dd-user.active{color:var(--orange);font-weight:700} .dd-user.new{color:var(--good)}
46
47/* layout */
48.wrap{max-width:920px;margin:18px auto;padding:0 14px}
49.feed-head{display:flex;align-items:center;gap:14px;margin-bottom:12px;flex-wrap:wrap}
50.feed-head h1{font-size:18px;margin:0;font-family:var(--mono)}
51.sorttabs{display:flex;gap:4px}
52.sorttabs a,.csort a{padding:3px 10px;border-radius:6px;font-size:12.5px;font-weight:600;color:var(--muted);text-transform:lowercase}
53.sorttabs a:hover{color:var(--text);background:var(--surface-2)}
54.sorttabs a.active{color:#0b0d10;background:var(--orange)}
55.csort a.active{color:var(--orange)}
56
57/* feed list (HN style) */
58.feed{list-style:none;margin:0;padding:0;counter-reset:none}
59.post{display:flex;align-items:baseline;gap:7px;padding:7px 4px;border-bottom:1px solid var(--border)}
60.post .rank{color:var(--faint);font-size:13px;min-width:22px;text-align:right;font-variant-numeric:tabular-nums}
61.arrow{background:none;border:0;color:var(--faint);cursor:pointer;font-size:13px;line-height:1;padding:2px 3px;border-radius:4px}
62.arrow:hover{color:var(--orange)} .arrow.on{color:var(--orange)}
63.arrow.sm{font-size:11px}
64.post-main{flex:1;min-width:0}
65.title-line{display:flex;align-items:baseline;gap:7px;flex-wrap:wrap}
66.post-title{font-size:15px;font-weight:600;overflow-wrap:anywhere}
67.post-title:visited{color:var(--muted)}
68.post-title:hover{color:var(--orange)}
69.domain{font-size:12px;color:var(--faint)}
70.subline{font-size:12px;color:var(--muted);margin-top:2px;display:flex;flex-wrap:wrap;gap:5px;align-items:center}
71.subline a{color:var(--muted)} .subline a:hover{color:var(--orange)}
72.subline .score{color:var(--text);font-weight:600}
73.subline .sep{color:var(--faint)}
74.linkact{background:none;border:0;color:var(--muted);cursor:pointer;font-size:12px;padding:0;font-family:inherit}
75.linkact:hover{color:var(--orange);text-decoration:underline}
76.linkact.danger:hover{color:var(--danger)}
77
78/* item / post page */
79.item{padding-top:4px}
80.item-head{display:flex;gap:9px;align-items:baseline}
81.item-title{font-size:18px;font-weight:700;margin:0 0 3px;line-height:1.3}
82.item-title a:hover{color:var(--orange)}
83.item-text{margin:12px 0 18px;padding:12px 14px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius)}
84.removed{color:var(--faint);font-style:italic}
85
86/* composer */
87.composer{margin:10px 0}
88.composer textarea{width:100%;background:var(--surface);border:1px solid var(--border-2);border-radius:6px;color:var(--text);padding:9px 11px;font-family:inherit;font-size:13.5px;resize:vertical;outline:none}
89.composer textarea:focus{border-color:var(--orange)}
90.composer-actions{display:flex;gap:8px;margin-top:7px}
91.composer.reply{margin:8px 0 4px}
92
93/* comments */
94.comment-bar{font-size:13px;color:var(--muted);font-weight:600;margin:16px 0 8px;display:flex;gap:12px;align-items:center}
95.csort{font-weight:400}
96.comments{display:flex;flex-direction:column}
97.comment{margin-top:9px}
98.comment-row{display:flex;gap:6px;align-items:baseline}
99.collapse{background:none;border:0;color:var(--faint);cursor:pointer;font-size:11px;font-family:var(--mono);padding:0}
100.collapse:hover{color:var(--text)}
101.comment-body{flex:1;min-width:0}
102.chead{font-size:12px;color:var(--muted)}
103.chead a{color:var(--muted);font-weight:600;font-family:var(--mono)}
104.chead a:hover{color:var(--orange)}
105.comment .md{font-size:13.5px;margin:2px 0}
106.cactions{display:flex;gap:10px;margin-top:1px}
107.children{margin-left:12px;padding-left:11px;border-left:2px solid var(--border);margin-top:4px}
108.comment.collapsed>.comment-row>.comment-body>*:not(.chead){display:none}
109.comment.collapsed>.children{display:none}
110
111/* markdown */
112.md{overflow-wrap:anywhere}
113.md p{margin:0 0 8px} .md a{color:var(--link);text-decoration:underline}
114.md code{background:var(--bg);border:1px solid var(--border);border-radius:4px;padding:1px 4px;font-family:var(--mono);font-size:.9em}
115.md pre{background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:10px;overflow:auto}
116.md pre code{border:0;padding:0} .md blockquote{border-left:3px solid var(--border-2);margin:6px 0;padding:1px 12px;color:var(--muted)}
117.md ul,.md ol{margin:6px 0;padding-left:20px} .md.small p{margin:3px 0}
118
119/* panels / forms */
120.panel{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:18px;margin-bottom:16px}
121.panel h1{margin:0 0 14px;font-size:18px;font-family:var(--mono)}
122.panel h2{margin:16px 0 8px;font-size:15px}
123label{display:block;font-size:13px;font-weight:600;color:var(--muted);margin-bottom:12px}
124label input,label select,label textarea{display:block;width:100%;margin-top:5px;background:var(--bg);border:1px solid var(--border-2);border-radius:6px;color:var(--text);padding:8px 10px;font-family:inherit;font-size:13.5px;outline:none}
125label input:focus,label textarea:focus,label select:focus{border-color:var(--orange)}
126.hint{display:block;color:var(--faint);font-weight:400;margin-top:4px}
127.form-actions{display:flex;gap:9px;align-items:center;margin-top:6px}
128.btn{display:inline-flex;align-items:center;gap:6px;justify-content:center;font-weight:700;font-size:13px;font-family:var(--mono);padding:7px 15px;border-radius:6px;cursor:pointer;border:1px solid transparent;background:var(--orange);color:#0b0d10;white-space:nowrap}
129.btn:hover{background:var(--orange-2)}
130.btn.ghost{background:var(--surface-2);border-color:var(--border-2);color:var(--text)}
131.btn.ghost:hover{border-color:var(--orange)}
132.btn.sm{padding:4px 11px;font-size:12px}
133.btn.danger{color:var(--danger)}
134.btn[disabled],button[disabled]{opacity:.6;pointer-events:none}
135.kv{list-style:none;margin:0;padding:0}
136.kv li{display:flex;justify-content:space-between;padding:6px 0;border-bottom:1px solid var(--border);font-size:13px}
137.kv li span{color:var(--muted)}
138
139/* boards / lists */
140.board-list{list-style:none;margin:0;padding:0}
141.board-list li{padding:8px 4px;border-bottom:1px solid var(--border);font-size:14px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
142.board-list a{font-weight:700;font-family:var(--mono)}
143.board-list a:hover{color:var(--orange)}
144.section-title{font-size:15px;margin:18px 0 8px;font-family:var(--mono)}
145
146/* profile */
147.profile-head{display:flex;gap:16px;align-items:flex-start;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:16px;margin-bottom:14px}
148.profile-head h1{margin:0;font-size:18px;font-family:var(--mono)}
149.profile-head .bio{margin:6px 0;color:var(--muted);font-size:13.5px}
150.rep{font-size:12.5px;color:var(--muted);margin:6px 0}
151.rep b{color:var(--text)}
152.activity{list-style:none;margin:0;padding:0}
153.activity li{padding:8px 4px;border-bottom:1px solid var(--border);font-size:13.5px}
154.atag{font-size:10px;font-weight:700;text-transform:uppercase;color:var(--orange);border:1px solid var(--border-2);border-radius:4px;padding:1px 5px;margin-right:5px}
155.activity a{font-weight:600} .activity a:hover{color:var(--orange)}
156
157/* empty / skeleton */
158.empty{text-align:center;background:var(--surface);border:1px dashed var(--border-2);border-radius:var(--radius);padding:40px 20px}
159.empty p{color:var(--muted)}
160.post.skel{opacity:.5} .post.skel .sk{height:12px;flex:1;background:var(--surface-2);border-radius:4px;margin-left:8px}
161
162/* netstatus chip */
163.netstatus{position:fixed;left:10px;bottom:10px;z-index:70;font-family:var(--mono);font-size:11px;font-weight:600;color:var(--muted);background:var(--surface);border:1px solid var(--border-2);border-radius:999px;padding:5px 11px;cursor:pointer;box-shadow:0 6px 18px rgba(0,0,0,.5)}
164.netstatus:hover{color:var(--text)}
165.netstatus b{color:var(--text)} .netstatus.ok b{color:var(--good)} .netstatus.bridge b{color:var(--orange)} .netstatus.warn{border-color:rgba(255,106,26,.5)} .netstatus.warn b{color:var(--orange)}
166
167/* toasts */
168.toasts{position:fixed;bottom:18px;left:50%;transform:translateX(-50%);display:flex;flex-direction:column;gap:7px;z-index:80}
169.toast{background:var(--surface-2);border:1px solid var(--border-2);color:var(--text);padding:9px 16px;border-radius:999px;font-size:13px;font-weight:600;box-shadow:0 10px 30px rgba(0,0,0,.55)}
170.toast.error{border-color:rgba(255,93,108,.5);color:#ffb3bb}
171.toast.out{opacity:0;transition:.3s}
172
173:focus-visible{outline:2px solid var(--orange);outline-offset:2px;border-radius:4px}
174@media(prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
175@media(max-width:600px){
176 .topnav{display:none} .search input{width:110px}
177 .wrap{margin-top:12px} .arrow{padding:5px 7px}
178}
web/test/engine.mjs +116 -0
……@@ -0,0 +1,116 @@
1// engine.mjs — headless verification of the p2pbuilders browser port:
2// PoW gate, reputation weighting, HN ranking, and the full gossip data flow
3// (boards/posts/comments/votes/follow/blocklist) across two peers.
4// Run: node test/engine.mjs
5
6import assert from 'node:assert'
7import { GossipSync, makeHub, mergeOutboxes } from '../js/gossip.js'
8import { DevIdentity } from '../js/identity.js'
9import { createData } from '../js/data.js'
10import { canonical } from '../js/canon.js'
11import { mint, verify, makeValidator } from '../js/pow.js'
12import { weight } from '../js/reputation.js'
13import { sortPosts, hotScore } from '../js/ranking.js'
14import { ready as cryptoReady, isSecure } from '../js/crypto.js'
15
16const BITS = { post: 6, comment: 5, board: 7 } // low difficulty for fast tests
17let passed = 0
18const ok = (c, m) => { assert.ok(c, m); passed++; console.log(' ✓ ' + m) }
19function mem () { const m = new Map(); return { getItem: k => (m.has(k) ? m.get(k) : null), setItem: (k, v) => m.set(k, String(v)), removeItem: k => m.delete(k) } }
20
21async function makePeer (hub, name) {
22 const id = new DevIdentity(mem(), mem()); await id.ready(); await id.createUser(name)
23 const validate = makeValidator(BITS)
24 const sync = new GossipSync({ storage: mem(), bus: hub.connect(), getMe: () => id.me().pubkey, validate })
25 await sync.ready()
26 return { id, sync, data: createData(sync, id, { minBits: BITS }), pub: id.me().pubkey, name }
27}
28
29async function main () {
30 await cryptoReady()
31 ok(isSecure(), 'crypto backend available')
32
33 console.log('\n— proof of work —')
34 const idA = new DevIdentity(mem(), mem()); await idA.ready()
35 const A = idA.me().pubkey
36 const post = { cid: 'abc', board: 'front', author: A, createdAt: 1000 }
37 post.pow = await mint('post', post, 6)
38 ok(await verify('post', post, 6), 'minted PoW verifies at its difficulty')
39 ok(!(await verify('post', post, 12)), 'PoW rejected when difficulty floor is higher')
40 const tampered = { ...post, cid: 'xyz' } // identity changed -> proof no longer valid
41 ok(!(await verify('post', tampered, 6)), 'PoW is bound to the op identity (tamper fails)')
42
43 console.log('\n— reputation weighting —')
44 ok(weight(0, 0) === 0.02, 'fresh key floors at 0.02')
45 ok(weight(90, 50) > weight(30, 10) && weight(30, 10) > weight(0, 0), 'weight grows with age + received upvotes')
46 ok(weight(99999, 99999) <= 1, 'weight is clamped to 1')
47
48 console.log('\n— PoW gate at the merge boundary —')
49 const goodPost = { id: 'front!p1', cid: 'p1', board: 'front', title: 'hi', url: '', text: '', author: A, createdAt: 2000, editedAt: 0, deleted: false }
50 goodPost.pow = await mint('post', goodPost, 6)
51 const s = await idA.sign(canonical('post', goodPost)); Object.assign(goodPost, { _sig: s.signature, _k: s.publicKey, _dk: s.driveKey, _ns: s.namespace, _alg: s.algorithm })
52 const validate = makeValidator(BITS)
53 let merged = await mergeOutboxes([{ pub: A, view: { 'post!front!p1': goodPost } }], {}, validate)
54 ok(merged['post!front!p1'], 'a PoW-backed, signed post is admitted')
55 const noPow = { ...goodPost }; delete noPow.pow
56 const s2 = await idA.sign(canonical('post', noPow)); const noPow2 = { ...noPow, _sig: s2.signature, _k: s2.publicKey, _dk: s2.driveKey, _ns: s2.namespace, _alg: s2.algorithm }
57 merged = await mergeOutboxes([{ pub: A, view: { 'post!front!p1': noPow2 } }], {}, validate)
58 ok(!merged['post!front!p1'], 'a signed post WITHOUT proof-of-work is rejected at ingest')
59
60 console.log('\n— two-peer data flow —')
61 const hub = makeHub()
62 const alice = await makePeer(hub, 'alice')
63 const bob = await makePeer(hub, 'bob')
64
65 await alice.data.createBoard({ name: 'p2p' })
66 ok(await bob.data.getBoard('p2p'), 'bob sees the board alice created (PoW-gated, gossiped)')
67 const p = await alice.data.submitPost({ board: 'p2p', title: 'Show P2PB: peerit', url: 'hyper://x' })
68 ok((await bob.data.listPostsIn('p2p')).some(x => x.cid === p.cid), "bob sees alice's post")
69 const c = await bob.data.addComment({ postCid: p.cid, board: 'p2p', body: 'nice work' })
70 ok((await alice.data.listComments(p.cid)).some(x => x.cid === c.cid), "alice sees bob's comment")
71
72 console.log('\n— reputation-weighted votes —')
73 await alice.data.vote(p.cid, 'post', 1)
74 await bob.data.vote(p.cid, 'post', 1)
75 const t = await alice.data.tallyFor(p.cid)
76 ok(t.up === 2 && t.score === 2, 'raw tally counts both upvotes')
77 ok(t.weighted > 0 && t.weighted <= 2, 'weighted score is positive but reputation-discounted')
78
79 console.log('\n— HN ranking —')
80 const now = Date.now()
81 ok(hotScore(50, now, now) > hotScore(50, now - 48 * 3600000, now), 'newer post outranks older at equal score')
82 const ranked = sortPosts([{ cid: 'a', createdAt: now, score: 1 }, { cid: 'b', createdAt: now, score: 9 }], 'top')
83 ok(ranked[0].cid === 'b', 'top sort ranks higher score first')
84
85 console.log('\n— sticky boards (no hijack) —')
86 const claimed = {}
87 const bd = { id: 'own', name: 'own', description: '', creator: alice.pub, createdAt: 5000 }
88 bd.pow = await mint('board', bd, 7); const bs = await alice.id.sign(canonical('board', bd)); Object.assign(bd, { _sig: bs.signature, _k: bs.publicKey, _dk: bs.driveKey, _ns: bs.namespace, _alg: bs.algorithm })
89 await mergeOutboxes([{ pub: alice.pub, view: { 'board!own': bd } }], claimed, validate)
90 ok(claimed.own === alice.pub, 'first board creator is locked in')
91 const hijack = { id: 'own', name: 'own', description: 'mine now', creator: bob.pub, createdAt: 0 }
92 hijack.pow = await mint('board', hijack, 7); const hs = await bob.id.sign(canonical('board', hijack)); Object.assign(hijack, { _sig: hs.signature, _k: hs.publicKey, _dk: hs.driveKey, _ns: hs.namespace, _alg: hs.algorithm })
93 const m2 = await mergeOutboxes([{ pub: alice.pub, view: { 'board!own': bd } }, { pub: bob.pub, view: { 'board!own': hijack } }], claimed, validate)
94 ok(m2['board!own'].creator === alice.pub, 'createdAt:0 cannot hijack an established board')
95
96 console.log('\n— social graph —')
97 await alice.data.follow(bob.pub)
98 ok((await alice.data.following(alice.pub)).includes(bob.pub), 'alice follows bob (published)')
99 await alice.data.unfollow(bob.pub)
100 ok(!(await alice.data.following(alice.pub)).includes(bob.pub), 'unfollow removes from following')
101 // bob curates a blocklist; alice subscribes -> the listed key is blocked for alice
102 await bob.data.publishBlocklist(['spammerPubkeyXYZ'])
103 const blocked = await alice.data.blockedSet([], [bob.pub])
104 ok(blocked.has('spammerPubkeyXYZ'), 'subscribing to a curator blocklist blocks its keys')
105 const notBlocked = await alice.data.blockedSet([], [])
106 ok(!notBlocked.has('spammerPubkeyXYZ'), 'unsubscribed = not blocked')
107
108 console.log('\n— profiles —')
109 await alice.data.setProfile({ nick: 'alice', bio: 'builds p2p' })
110 ok((await alice.data.nickOf(alice.pub)) === 'alice', 'nick resolves from profile')
111 ok((await bob.data.nickOf('deadbeef00')).startsWith('anon-'), 'unknown key shows anon handle')
112
113 console.log(`\n✅ all ${passed} p2pbuilders engine checks passed\n`)
114}
115
116main().catch(e => { console.error('\n❌ FAILED:', e.message, '\n', e.stack); process.exit(1) })