aboutsummaryrefslogtreecommitdiffstats
path: root/tools
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-18 23:03:57 -0700
committerAdam Soltys <[email protected]>2026-10-18 23:03:57 -0700
commit07fb145efbc32cdfe2dc488c65d8708260f6f648 (patch)
treee75dffaabdf9012a2c71628c6ba643146392fa9f /tools
parent7b29bcbf37adb6680531827eeb9442c1960e4e79 (diff)
downloadcoinosv3-server-07fb145efbc32cdfe2dc488c65d8708260f6f648.tar.gz
coinosv3-server-07fb145efbc32cdfe2dc488c65d8708260f6f648.zip
live: a name streams a day after it's claimed (the registrar now records when; older records count from their last update), never if banned; GET /live/allowed says which and until when. Viewers can report a stream (POST /live/<id>/report, NIP-98): kept in .reports.jsonl and DM'd to the admins from the welcome key. An admin takes a stream down (POST /live/<id>/remove {ban}): it ends, its playlists and segments answer 410, its files move to .removed/ (kept for reporting, never trimmed), and a ban stops its sender streaming.
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_013ZDf1cyCQLkfjftYhymZAg
Diffstat (limited to 'tools')
-rw-r--r--tools/live-ingest-test.js34
1 files changed, 31 insertions, 3 deletions
diff --git a/tools/live-ingest-test.js b/tools/live-ingest-test.js
index 7bb2489..c760679 100644
--- a/tools/live-ingest-test.js
+++ b/tools/live-ingest-test.js
@@ -4,7 +4,7 @@
// discontinuity between); the end closes the playlist; nobody else may send.
// Run: bun tools/live-ingest-test.js (needs ffmpeg)
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
-import { mkdtempSync, rmSync, readFileSync } from 'node:fs';
+import { mkdtempSync, rmSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let ok = true;
@@ -16,11 +16,15 @@ Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-y', '-f', 'lavfi', '-i', 'tests
'-t', '12', '-c:v', 'libvpx', '-deadline', 'realtime', '-b:v', '1M', '-c:a', 'libopus', '-f', 'webm', dir + '/cam.webm']);
const webm = readFileSync(dir + '/cam.webm');
-const SK = generateSecretKey(), PK = getPublicKey(SK), OTHER = generateSecretKey();
+const SK = generateSecretKey(), PK = getPublicKey(SK), OTHER = generateSecretKey(), ADMIN = generateSecretKey();
+const NEWBIE = generateSecretKey();
const names = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: (req) => {
const n = new URL(req.url).pathname.split('/').pop();
- return Response.json(n === 'alice' ? { taken: true, pubkey: PK, manager: null } : { taken: false });
+ return Response.json(n === 'alice' ? { taken: true, pubkey: PK, manager: null, created: Date.now() - 3 * 86400_000 }
+ : n === 'newbie' ? { taken: true, pubkey: getPublicKey(NEWBIE), created: Date.now() - 3600_000 } : { taken: false });
} });
+writeFileSync(dir + '/config.json', JSON.stringify({ admins: [getPublicKey(ADMIN)] }));
+process.env.LIVE_CONFIG = dir + '/config.json';
process.env.LIVE_DATA = dir + '/data';
process.env.NAMES_URL = names.url.origin;
process.env.LIVE_BASE = 'http://x/live';
@@ -50,6 +54,12 @@ try {
check('a stranger to the name is refused', (await connect(`auth=${auth(OTHER)}&name=alice`)).error === true);
check('...as is a name nobody owns', (await connect(`auth=${auth(SK)}&name=bob`)).error === true);
check('...and a forged auth', (await connect(`auth=${auth(SK).slice(0, -8)}AAAAAAAA&name=alice`)).error === true);
+ const nip98 = (sk, path, method = 'GET') => 'Nostr ' + Buffer.from(JSON.stringify(finalizeEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: '',
+ tags: [['u', 'https://v3.coinos.io' + path], ['method', method]] }, sk))).toString('base64');
+ check('...and a name claimed an hour ago (a day first)', (await connect(`auth=${auth(NEWBIE)}&name=newbie`)).error === true);
+ const al = await (await fetch(`${HTTP}/allowed?name=newbie`, { headers: { authorization: nip98(NEWBIE, '/live/allowed') } })).json();
+ check('...and asked, the server says when it can', al.ok === false && al.reason === 'new-name' && al.until > Date.now() + 22 * 3600_000, JSON.stringify(al));
+ check('a day-old name\'s owner may', (await (await fetch(`${HTTP}/allowed?name=alice`, { headers: { authorization: nip98(SK, '/live/allowed') } })).json()).ok === true);
console.log('\n[TURN for calls]');
const turnAuth = (sk, path = '/live/turn') => 'Nostr ' + Buffer.from(JSON.stringify(finalizeEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: '',
@@ -89,6 +99,13 @@ try {
check('the whole playlist joins the two with a discontinuity', /#EXT-X-DISCONTINUITY\n/.test(full.body) && segs(full.body).some((x) => x.startsWith('0-')) && segs(full.body).some((x) => x.startsWith('1-')), segs(full.body).length + ' segments');
check('...as an EVENT playlist while live', /PLAYLIST-TYPE:EVENT/.test(full.body) && !/ENDLIST/.test(full.body));
+ console.log('\n[reports and taking down]');
+ const post = (sk, path, body) => fetch(`http://127.0.0.1:${srv.port}${path}`, { method: 'POST', headers: { authorization: nip98(sk, path, 'POST'), 'content-type': 'application/json' }, body: JSON.stringify(body) });
+ check('a viewer reports the stream', (await post(OTHER, `/live/${id}/report`, { reason: 'sexual', text: 'look' })).ok);
+ const reps = readFileSync(dir + '/data/.reports.jsonl', 'utf8').trim().split('\n').map((l) => JSON.parse(l));
+ check('...kept, with who and why', reps.length === 1 && reps[0].stream === id && reps[0].reason === 'sexual' && reps[0].by === getPublicKey(OTHER));
+ check('...unsigned, refused', (await fetch(`${HTTP}/${id}/report`, { method: 'POST', body: '{}' })).status === 401);
+ check('only an admin can take it down', (await post(OTHER, `/live/${id}/remove`, { ban: true })).status === 403);
console.log('\n[the end]');
b2.ws.send('end');
await sleep(1500);
@@ -101,6 +118,17 @@ try {
const c = await connect(`auth=${auth(SK)}&name=alice`);
check('once ended, the owner can go live again', !!c.hello?.id && c.hello.id !== id);
c.ws.send('end');
+ await sleep(500);
+ const c0 = await connect(`auth=${auth(SK)}&name=alice`);
+ const tid = c0.hello.id;
+ await send(c0.ws, 0, Math.floor(webm.length * 0.3));
+ await sleep(1500);
+ const rm = await (await post(ADMIN, `/live/${tid}/remove`, { ban: true })).json();
+ check('the admin takes a stream down, banning its sender', rm.removed === true && rm.banned === true, JSON.stringify(rm));
+ check('...its playlists are gone (410), its segments too', (await fetch(`${HTTP}/${tid}/live.m3u8`)).status === 410 && (await fetch(`${HTTP}/${tid}/0-00000.ts`)).status === 410);
+ check('...kept aside for reporting, not deleted', existsSync(`${dir}/data/.removed/${tid}`) && !existsSync(`${dir}/data/${tid}`));
+ check('...and its sender can\'t go live again', (await connect(`auth=${auth(SK)}&name=alice`)).error === true
+ && (await (await fetch(`${HTTP}/allowed?name=alice`, { headers: { authorization: nip98(SK, '/live/allowed') } })).json()).reason === 'banned');
} catch (e) { console.error(e); ok = false; } finally {
srv.stop(true); names.stop(true);
await sleep(300);