--- id: 014-p2p-presence-hub-ceced8 title: Add the /ws/p2p presence and signalling hub and the holders endpoint created: 2026-09-29 depends_on: [013-device-identity-and-holdings-3ba493] est_files: 6 --- # 014 — Presence + signalling hub, holders endpoint ## Objective Implements flows 4–5 of `docs/p2p-architecture.md`. After this plan: - `/ws/p2p` is a websocket hub (`server/p2p-hub.js`). A device authenticates with its FIRST message `{type:'auth', device, secret}` (never in the URL — proxies log URLs), gets `{type:'hello', peer}`, and is **online** while the socket is open (memory only). It relays `{type:'signal', to, data}` between two authenticated online devices. - `GET /api/p2p/holders?v=` (or `?cid=`) lists each verified cid with its holders: opaque `peer`, `online`, `lastVerifiedAt`, `stale`, `trust`, plus `serverHas` and counts. Holder rows are NEVER hidden for age — `stale` only flags them. Devices with sharing off are not listed. Device ids never appear in the payload. - `P2PClient` keeps the socket open while sharing or receiving is on (both default ON), with a 50 s keepalive (the server closes idle sockets after 120 s), and exposes `onMessage(type, fn)`, `signal(to, data)`, `peer()`, `isConnected()`. Pre-tested: hub/payload 3/3 (`bun:test`); two real browsers exchanged a signal through a real Bun server (`plans/harness/presence-*.js`). ## Context the executor must NOT rediscover - `plans/patches/014-p2p-hub-new.diff` creates `server/p2p-hub.js` (exports `createP2pHub`, `holdersPayload`, `peerIdOf`) and `server/p2p-hub.test.js`. - `plans/patches/014-p2p-client-presence.diff` patches `frontend/p2p-client.js` (from plan 013). - `server/server.js` (~line 1778-1790): ```js const remote = createRemoteHub({ requireSameNetwork: process.env.REMOTE_SAME_NETWORK === '1' }); const party = createPartyHub(); // Bun allows ONE websocket handler per server: party sockets are tagged // (ws.data.hub === 'party'), everything else belongs to the remote relay. const pickHub = (ws) => (ws.data && ws.data.hub === 'party' ? party.websocket : remote.websocket); ``` - `server/server.js` `Bun.serve({ … fetch(req, server) {` (~line 1926): ```js const path = new URL(req.url).pathname; if (path === '/ws/remote') return remote.upgrade(req, server, clientIpOf(req, server)); if (path === '/ws/party') return party.upgrade(req, server, clientIpOf(req, server)); return app.fetch(req, server); ``` - Plan 013 added in server.js: `async function fileForCid(cid)` and `const p2p = registerP2pRoutes(app, { cfg: P2P, p2pDb, fileForCid, sha256Range });` (`p2p.gate` is a Hono middleware answering 404 when P2P is disabled). ## Steps 1. From the repo root: ```bash git apply plans/patches/014-p2p-hub-new.diff git apply plans/patches/014-p2p-client-presence.diff ``` STOP and report if either fails. 2. `server/package.json` "test" script — append ` && bun test ./p2p-hub.test.js`. 3. `server/server.js` — add import `import { createP2pHub, holdersPayload } from './p2p-hub.js';` 4. `server/server.js` — directly after `const p2p = registerP2pRoutes(app, { … });` add: ```js const p2pHub = createP2pHub({ getDevice: p2pDb.getDevice, enabled: () => P2P.enabled }); // GET /api/p2p/holders?v=|cid= — who holds a copy. Rows are // persistent; `stale` flags a holder not re-verified for P2P_STALE_DAYS. app.get('/api/p2p/holders', p2p.gate, async (c) => { const v = (c.req.query('v') || '').trim(); const cid = (c.req.query('cid') || '').trim().toLowerCase(); const hasCid = /^[0-9a-f]{64}$/.test(cid); if (!hasCid && !/^[A-Za-z0-9_-]{6,64}$/.test(v)) return c.json({ ok: false, error: 'missing v or cid' }, 400); const payload = await holdersPayload({ videoId: v, cid: hasCid ? cid : null, p2pDb, isOnline: p2pHub.isOnline, staleDays: P2P.staleDays, serverHas: fileForCid, }); return c.json(payload, 200, { 'Cache-Control': 'no-store' }); }); ``` 5. `server/server.js` — replace the `pickHub` line with: ```js // …and P2P sockets are tagged ws.data.hub === 'p2p' (p2p-hub.js). const pickHub = (ws) => (ws.data && ws.data.hub === 'party' ? party.websocket : ws.data && ws.data.hub === 'p2p' ? p2pHub.websocket : remote.websocket); ``` (`p2pHub` is declared later in the file; that is fine — `pickHub` only runs once sockets exist.) 6. `server/server.js` `Bun.serve` fetch — after the `/ws/party` line add: ` if (path === '/ws/p2p') return p2pHub.upgrade(req, server);` 7. Copy nothing else; `frontend/index.html` / `sw.js` need no change (no new frontend files). ## Out of scope / do NOT touch - `remote.js`, `party.js` and their behaviour. No UI (plan 015). No file transfer (plan 017). ## Verification ```bash cd /home/user/ytplayer/server && bun install >/dev/null 2>&1 bun test ./p2p-hub.test.js 2>&1 | tail -4 bun run test 2>&1 | grep -E "^ *[0-9]+ (pass|fail)" bun build server.js --target=bun --outdir=/tmp/ytp-check >/dev/null && echo SERVER_OK cd .. && node --check frontend/p2p-client.js && echo FRONT_OK cd server bun ../plans/harness/presence-server.js >/tmp/ytp014.log 2>&1 & SRV=$!; sleep 3 cd ../plans/harness && (npm ls playwright >/dev/null 2>&1 || npm i --no-save playwright >/dev/null 2>&1); timeout 90 node presence-check.mjs kill $SRV; true ``` Expected: hub `3 pass`; every file `0 fail`; `SERVER_OK`; `FRONT_OK`; browser JSON `{"peers":[true,true],"distinct":true,"sent":true,"got":[{"from":true,"data":{"hi":1}}]}`. ## Report format (executor: follow exactly) Output ONLY the following, no other prose: 1. `git diff` (unified) of all changes. 2. Raw output of the Verification commands. 3. `Findings:` — max 10 lines. Do not commit. Do not push. Do not touch files outside the Steps. ## Execution log - Executor: in-session Agent (haiku). Attempts: 1. Fix rounds: 0. - Orchestrator re-ran Verification: `p2p-hub.js`, `p2p-hub.test.js`, `p2p-client.js` byte-identical to the pre-tested versions; hub tests 3 pass; all 13 server test files 0 fail; `SERVER_OK`; `FRONT_OK`; two-browser presence check relayed the signal (`{"peers":[true,true],"distinct":true,"sent":true,"got":[{"from":true,"data":{"hi":1}}]}`). No leftover processes. - Executor Findings (verbatim): Both git apply patches applied successfully without errors. All 13 test files (24 total test groups) passed with 0 failures. Server builds successfully. Frontend syntax check passes. Browser presence test matches expected output exactly. All modifications applied according to plan steps 1-7. No files touched outside the specified changes.