| … | … | @@ -191,3 +191,17 @@ node scripts/publish.js # publish ./public as a hyperdrive |
| 191 | 191 | ## License |
| 192 | 192 | |
| 193 | 193 | Apache 2.0. See [LICENSE](./LICENSE). |
| 194 | ||
| 195 | --- | |
| 196 | ||
| 197 | ## `web/` — browser build (PearBrowser P2P site) | |
| 198 | ||
| 199 | A 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 | |
| 201 | engine. Same permissionless model: **proof-of-work** spam gating, | |
| 202 | **reputation-weighted** votes, boards, threaded comments, follow/block, and | |
| 203 | subscribable 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) |
| … | … | @@ -0,0 +1,7 @@ |
| 1 | .DS_Store | |
| 2 | .claude/ | |
| 3 | .hiverelay-local/ | |
| 4 | .hiverelay-seed/ | |
| 5 | .catalog-publish-*/ | |
| 6 | node_modules/ | |
| 7 | *.log |
| … | … | @@ -0,0 +1,91 @@ |
| 1 | # p2pbuilders (browser) | |
| 2 | ||
| 3 | Permissionless peer-to-peer **Hacker News** for anons who build P2P — ported from | |
| 4 | the 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 | |
| 6 | comments, follows and subscribable blocklists replicate directly between peers. | |
| 7 | ||
| 8 | ``` | |
| 9 | hyper://<driveKey>/ ← open in PearBrowser | |
| 10 | ``` | |
| 11 | ||
| 12 | It reuses **peerit's gossip engine verbatim** — the signature-authority + per-user- | |
| 13 | outbox + gossip-merge layer is app-agnostic. p2pbuilders only adds an HN schema, | |
| 14 | PoW, 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 | ||
| 22 | The engine gained one generic extension — a `validate` admit hook — so PoW can | |
| 23 | gate 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 | ``` | |
| 48 | board board!<name> (sticky; creator owns it) | |
| 49 | post post!<board>!<cid> | |
| 50 | comment comment!<postCid>!<cid> (parentCid threads it) | |
| 51 | vote vote!<targetCid>!<voter> (one per voter, LWW) | |
| 52 | profile profile!<author> | |
| 53 | follow follow!<author>!<target> | |
| 54 | block block!<author>!<target> | |
| 55 | blocklist blocklist!<author> | |
| 56 | ``` | |
| 57 | ||
| 58 | ## Run it | |
| 59 | ||
| 60 | ```bash | |
| 61 | cd 02-apps/p2pbuilders | |
| 62 | node 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 | ||
| 66 | In PearBrowser (real bridge): `node publish.mjs --local` prints a `hyper://` key and | |
| 67 | hosts the drive; open it in PearBrowser. The same code runs on `window.pear.sync/ | |
| 68 | identity/swarm` (the `BridgeGossipSync` path) — the status chip shows `gossip-bridge`. | |
| 69 | ||
| 70 | ## Test | |
| 71 | ||
| 72 | ```bash | |
| 73 | node 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 | |
| 80 | node publish.mjs # publish + seed to live HiveRelay + register in catalog | |
| 81 | KEEP=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. |
| … | … | @@ -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. | |
| 4 | import http from 'node:http' | |
| 5 | import { readFile } from 'node:fs/promises' | |
| 6 | import { extname, join, normalize, relative, isAbsolute } from 'node:path' | |
| 7 | import { fileURLToPath } from 'node:url' | |
| 8 | ||
| 9 | const ROOT = fileURLToPath(new URL('.', import.meta.url)).replace(/\/$/, '') | |
| 10 | const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.json': 'application/json' } | |
| 11 | const PORT = Number(process.env.PORT) || 8778 | |
| 12 | const HOST = process.env.HOST || '127.0.0.1' | |
| 13 | const PUBLIC_FILES = new Set(['/index.html', '/styles.css', '/icon.svg', '/manifest.json']) | |
| 14 | ||
| 15 | const isPublicPath = (p) => PUBLIC_FILES.has(p) || /^\/js\/[a-z0-9-]+\.js$/i.test(p) | |
| 16 | const isInsideRoot = (file) => { const rel = relative(ROOT, file); return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)) } | |
| 17 | ||
| 18 | http.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)`)) |
| … | … | @@ -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> |
| … | … | @@ -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> |
| … | … | @@ -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 | ||
| 7 | import { createSync } from './sync.js' | |
| 8 | import { createIdentity } from './identity.js' | |
| 9 | import { createData } from './data.js' | |
| 10 | import { Prefs } from './prefs.js' | |
| 11 | import { makeValidator, MIN_BITS } from './pow.js' | |
| 12 | import { renderMarkdown } from './markdown.js' | |
| 13 | import { sortPosts, sortComments, POST_SORTS, COMMENT_SORTS } from './ranking.js' | |
| 14 | import { buildCommentTree, sortCommentTree, countDescendants, DEFAULT_BOARD, normalizeBoard } from './model.js' | |
| 15 | import { escapeHtml as esc, timeAgo, fmtCount, parseRoute, buildRoute, shortKey, colorFor, debounce } from './util.js' | |
| 16 | ||
| 17 | let sync, identity, data, prefs | |
| 18 | let renderToken = 0 | |
| 19 | let _lastHash = '' | |
| 20 | const openReplies = new Set() | |
| 21 | const nameCache = new Map() | |
| 22 | ||
| 23 | const $ = (s, r = document) => r.querySelector(s) | |
| 24 | const app = () => $('#app') | |
| 25 | const nameOf = (pub) => nameCache.get(pub) || ('anon-' + String(pub || '?').slice(0, 6)) | |
| 26 | async 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 | } | |
| 29 | function domainOf (url) { try { return new URL(url).hostname.replace(/^www\./, '') } catch { return null } } | |
| 30 | function safeUrl (u) { return /^(https?:\/\/|hyper:\/\/|pear:\/\/)/i.test(String(u || '')) ? u : null } | |
| 31 | ||
| 32 | // ---- boot ------------------------------------------------------------------- | |
| 33 | async 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 | } | |
| 63 | function refreshPrefs () { prefs = new Prefs(typeof localStorage !== 'undefined' ? localStorage : null, identity.me().pubkey) } | |
| 64 | ||
| 65 | // ---- chrome ----------------------------------------------------------------- | |
| 66 | function 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 | ||
| 83 | async 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 | } | |
| 99 | function 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 | ||
| 106 | async 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 ----------------------------------------------------------------- | |
| 116 | function 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 ------------------------------------------------------------------- | |
| 137 | async 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 | ||
| 160 | function 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 | ||
| 188 | async 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) ------------------------------------------------- | |
| 195 | async 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 | ||
| 232 | function 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 ----------------------------------------------------------------- | |
| 260 | async 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 ----------------------------------------------------------------- | |
| 276 | async 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 ---------------------------------------------------------------- | |
| 293 | async 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 --------------------------------------------------------------- | |
| 318 | async 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 ------------------------------------------------------------- | |
| 335 | async 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 ----------------------------------------------------------------- | |
| 351 | async 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 ---------------------------------------------------------------- | |
| 363 | function skeleton () { return '<div class="feed-head"><h1> </h1></div><ol class="feed">' + '<li class="post skel"><span class="rank"></span><span class="sk"></span></li>'.repeat(5) + '</ol>' } | |
| 364 | function notFound (m) { return `<div class="empty"><h3>not found</h3><p>${esc(m || 'Nothing here.')}</p><a class="btn" href="#/">home</a></div>` } | |
| 365 | function 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) } | |
| 366 | function parseScore (s) { s = String(s).trim(); const m = parseFloat(s); return /k$/i.test(s) ? m * 1000 : (parseInt(s, 10) || 0) } | |
| 367 | ||
| 368 | // ---- events ----------------------------------------------------------------- | |
| 369 | async 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 | ||
| 395 | function onChange (e) { | |
| 396 | if (e.target.matches('.sorttabs')) {} | |
| 397 | } | |
| 398 | ||
| 399 | async 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 | ||
| 411 | async 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 | ||
| 445 | async 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 | ||
| 460 | if (typeof window !== 'undefined') window.__p2pb = { get data () { return data }, get sync () { return sync }, route } | |
| 461 | if (typeof document !== 'undefined') { if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); else boot() } | |
| 462 | export { boot } |
| … | … | @@ -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 | ||
| 6 | import { TYPE, keys } from './model.js' | |
| 7 | ||
| 8 | // The pubkey that must have authored a record of this type (signer must match). | |
| 9 | export 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. | |
| 14 | export 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. | |
| 19 | export 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 | ||
| 33 | const 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). | |
| 37 | export 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. | |
| 46 | export function canonical (type, data) { | |
| 47 | return type + '|' + stableStringify(data) | |
| 48 | } | |
| 49 | ||
| 50 | export function typeFromKey (key) { return String(key).split('!')[0] } | |
| 51 | ||
| 52 | export function recordTs (data) { | |
| 53 | return (data && (data.editedAt || data.updatedAt || data.ts || data.createdAt)) || 0 | |
| 54 | } |
| … | … | @@ -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 | ||
| 14 | const PKCS8_PREFIX = '302e020100300506032b657004220420' // Ed25519 PKCS8 header for a 32B seed | |
| 15 | const SPKI_PREFIX = '302a300506032b6570032100' // Ed25519 SPKI header for a 32B pubkey | |
| 16 | ||
| 17 | const 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 } | |
| 18 | const 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 } | |
| 19 | const concat = (a, b) => { const u = new Uint8Array(a.length + b.length); u.set(a, 0); u.set(b, a.length); return u } | |
| 20 | const utf8 = (s) => new TextEncoder().encode(s) | |
| 21 | ||
| 22 | let backend = null // 'subtle' | 'node' | 'none' | |
| 23 | let nodeCrypto = null | |
| 24 | let _ready = null | |
| 25 | ||
| 26 | async 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 | ||
| 49 | export function ready () { if (!_ready) _ready = detect(); return _ready } | |
| 50 | export function isSecure () { return backend === 'subtle' || backend === 'node' } | |
| 51 | export function backendName () { return backend } | |
| 52 | ||
| 53 | // --- node helpers --- | |
| 54 | function nodePriv (seedHex) { return nodeCrypto.createPrivateKey({ key: Buffer.from(PKCS8_PREFIX + seedHex, 'hex'), format: 'der', type: 'pkcs8' }) } | |
| 55 | function nodePub (pubHex) { return nodeCrypto.createPublicKey({ key: Buffer.from(SPKI_PREFIX + pubHex, 'hex'), format: 'der', type: 'spki' }) } | |
| 56 | ||
| 57 | // --- subtle helpers --- | |
| 58 | async function subtlePriv (seedHex) { | |
| 59 | return globalThis.crypto.subtle.importKey('pkcs8', fromHex(PKCS8_PREFIX + seedHex), { name: 'Ed25519' }, false, ['sign']) | |
| 60 | } | |
| 61 | async 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) }. | |
| 66 | export 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). | |
| 87 | export 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. | |
| 95 | export 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 | } |
| … | … | @@ -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 | ||
| 7 | import { keys, id as mkid, TYPE, DEFAULT_BOARD, isValidBoard, normalizeBoard, resolveBlocked } from './model.js' | |
| 8 | import { canonical } from './canon.js' | |
| 9 | import { mint, MIN_BITS } from './pow.js' | |
| 10 | import { weight as repWeight, weightedTally } from './reputation.js' | |
| 11 | import { uid } from './util.js' | |
| 12 | ||
| 13 | const DAY = 86400000 | |
| 14 | ||
| 15 | export 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 | ||
| 292 | export function createData (sync, identity, opts) { return new Data(sync, identity, opts) } |
| … | … | @@ -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 | ||
| 21 | import { ownerOf, expectedKey, typeFromKey, recordTs, canonical, stickyKey } from './canon.js' | |
| 22 | import { verifyRecord } from './verify.js' | |
| 23 | import { verify as edVerify, isSecure, ready as cryptoReady } from './crypto.js' | |
| 24 | ||
| 25 | const PEERS_KEY = 'p2pb:peers' | |
| 26 | const CLAIMED_KEY = 'p2pb:claimed' | |
| 27 | const outboxKey = (pub) => 'p2pb:outbox:' + pub | |
| 28 | const TOPIC = 'p2pb-gossip-v1' | |
| 29 | const PROTO_KEYS = new Set(['__proto__', 'prototype', 'constructor']) | |
| 30 | const MAX_PEERS = 4096 | |
| 31 | ||
| 32 | // ---- local reducer + range over a {key:value} view -------------------------- | |
| 33 | function 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 | } | |
| 37 | function 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) -- | |
| 53 | const _verdict = new Map() | |
| 54 | async 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 | ||
| 63 | async 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 | |
| 77 | function 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. | |
| 86 | function 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). | |
| 97 | export 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 ------------------------------------------------------ | |
| 124 | class 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 ------------------------------------------------ | |
| 239 | class 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 ----------------------------------------------------------- | |
| 354 | export 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 | ||
| 368 | function 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 | ||
| 373 | export 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 | ||
| 379 | export { GossipSync, BridgeGossipSync, applyOp as gossipApplyOp } |
| … | … | @@ -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 | ||
| 14 | import { genKeyPair, sign as edSign } from './crypto.js' | |
| 15 | ||
| 16 | const NS = 'peerit' | |
| 17 | ||
| 18 | class 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 | ||
| 78 | class 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 | ||
| 96 | export 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 | ||
| 104 | function 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 | ||
| 109 | export { DevIdentity, BridgeIdentity } |
| … | … | @@ -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 | ||
| 5 | import { escapeHtml } from './util.js' | |
| 6 | ||
| 7 | const SAFE_LINK = /^(https?:\/\/|hyper:\/\/|pear:\/\/|#\/|\/)/i | |
| 8 | ||
| 9 | function 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. | |
| 16 | function 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 | ||
| 47 | export 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). | |
| 131 | export 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 | } |
| … | … | @@ -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 | ||
| 15 | export const TYPE = { | |
| 16 | BOARD: 'board', POST: 'post', COMMENT: 'comment', VOTE: 'vote', | |
| 17 | PROFILE: 'profile', FOLLOW: 'follow', BLOCK: 'block', BLOCKLIST: 'blocklist' | |
| 18 | } | |
| 19 | ||
| 20 | export const DEFAULT_BOARD = 'front' | |
| 21 | ||
| 22 | export 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). | |
| 42 | export 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. | |
| 54 | export function isValidBoard (s) { return typeof s === 'string' && /^[a-z0-9_]{2,24}$/.test(s) } | |
| 55 | export 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. | |
| 59 | export 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 | ||
| 71 | export 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 | ||
| 77 | export 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] }) | |
| 87 | export 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 | } |
| … | … | @@ -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. | |
| 11 | export 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. | |
| 18 | export 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 | ||
| 27 | function 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 | ||
| 38 | async 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. | |
| 45 | export 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. | |
| 61 | export 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). | |
| 71 | export 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 | ||
| 80 | export { leadingZeroBits } |
| … | … | @@ -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 | ||
| 5 | export 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 | ||
| 46 | function 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 | } |
| … | … | @@ -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. | |
| 7 | export 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 | ||
| 12 | export const POST_SORTS = ['hot', 'new', 'top'] | |
| 13 | export const COMMENT_SORTS = ['best', 'new', 'top'] | |
| 14 | ||
| 15 | export 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 | ||
| 28 | export 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 | } |
| … | … | @@ -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 | ||
| 12 | export 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 }. | |
| 21 | export 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 | } |
| … | … | @@ -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 | ||
| 18 | import { createGossip } from './gossip.js' | |
| 19 | ||
| 20 | export const APP_ID = 'p2pbuilders' | |
| 21 | ||
| 22 | // ---- shared reducer (mirrors the bridge generic fallback) ------------------- | |
| 23 | function 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 | ||
| 31 | function 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 | ||
| 48 | function 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 ---------------------------------------------------------------- | |
| 56 | class 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) ------------------------------------------ | |
| 132 | class 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). | |
| 197 | const 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. | |
| 203 | export 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). | |
| 214 | export 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 | ||
| 224 | export { DevSync, BridgeSync, applyOp } |
| … | … | @@ -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 | ||
| 4 | const 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. | |
| 8 | export 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. | |
| 17 | export function invTs (ms = Date.now()) { | |
| 18 | return String(9999999999999 - ms).padStart(13, '0') | |
| 19 | } | |
| 20 | ||
| 21 | export 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. | |
| 24 | export function normalizeSlug (s) { | |
| 25 | return String(s || '').toLowerCase().replace(/[^a-z0-9_]/g, '').slice(0, 24) | |
| 26 | } | |
| 27 | export function isValidSlug (s) { | |
| 28 | return typeof s === 'string' && /^[a-z0-9_]{2,24}$/.test(s) | |
| 29 | } | |
| 30 | ||
| 31 | export 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. | |
| 38 | export 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". | |
| 54 | export 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 | ||
| 62 | export function escapeHtml (s) { | |
| 63 | return String(s == null ? '' : s) | |
| 64 | .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') | |
| 65 | .replace(/"/g, '"').replace(/'/g, ''') | |
| 66 | } | |
| 67 | ||
| 68 | // Deterministic pastel color from a string (used for avatar/community tints). | |
| 69 | export 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'} }. | |
| 78 | export 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 | ||
| 93 | export 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 | ||
| 100 | export 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 | } |
| … | … | @@ -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 | ||
| 11 | import { canonical, ownerOf } from './canon.js' | |
| 12 | import { verify as edVerify, isSecure, ready as cryptoReady } from './crypto.js' | |
| 13 | ||
| 14 | const HEX64 = /^[0-9a-f]{64}$/i | |
| 15 | const NS = 'peerit' | |
| 16 | ||
| 17 | function 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 | ||
| 24 | export 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 | } |
| … | … | @@ -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 | } |
| … | … | @@ -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 | */ | |
| 13 | import { HiveRelayClient } from '/Users/localllm/Projects/pear-ecosystem/00-core/hiverelay/packages/client/index.js' | |
| 14 | import { readFileSync, writeFileSync } from 'fs' | |
| 15 | import { fileURLToPath } from 'url' | |
| 16 | import { dirname, join } from 'path' | |
| 17 | ||
| 18 | const __dir = dirname(fileURLToPath(import.meta.url)) | |
| 19 | const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) | |
| 20 | const relayCount = (s) => (s && (s.relays ? s.relays.length : s.relayCount)) || 0 | |
| 21 | const LOCAL = process.argv.includes('--local') | |
| 22 | ||
| 23 | const 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 | ||
| 30 | async 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 | ||
| 74 | main().catch((err) => { console.error('[p2pb] failed:', err.stack || err.message); process.exit(1) }) |
| … | … | @@ -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} | |
| 10 | html,body{margin:0;padding:0} | |
| 11 | body{background:var(--bg);color:var(--text);font-family:var(--sans);font-size:14px;line-height:1.5;-webkit-font-smoothing:antialiased} | |
| 12 | a{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} | |
| 123 | label{display:block;font-size:13px;font-weight:600;color:var(--muted);margin-bottom:12px} | |
| 124 | label 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} | |
| 125 | label 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 | } |
| … | … | @@ -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 | ||
| 6 | import assert from 'node:assert' | |
| 7 | import { GossipSync, makeHub, mergeOutboxes } from '../js/gossip.js' | |
| 8 | import { DevIdentity } from '../js/identity.js' | |
| 9 | import { createData } from '../js/data.js' | |
| 10 | import { canonical } from '../js/canon.js' | |
| 11 | import { mint, verify, makeValidator } from '../js/pow.js' | |
| 12 | import { weight } from '../js/reputation.js' | |
| 13 | import { sortPosts, hotScore } from '../js/ranking.js' | |
| 14 | import { ready as cryptoReady, isSecure } from '../js/crypto.js' | |
| 15 | ||
| 16 | const BITS = { post: 6, comment: 5, board: 7 } // low difficulty for fast tests | |
| 17 | let passed = 0 | |
| 18 | const ok = (c, m) => { assert.ok(c, m); passed++; console.log(' ✓ ' + m) } | |
| 19 | function 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 | ||
| 21 | async 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 | ||
| 29 | async 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 | ||
| 116 | main().catch(e => { console.error('\n❌ FAILED:', e.message, '\n', e.stack); process.exit(1) }) |