diff --git a/.gitea/workflows/og-badges.yml b/.gitea/workflows/og-badges.yml new file mode 100644 index 0000000..e89df7a --- /dev/null +++ b/.gitea/workflows/og-badges.yml @@ -0,0 +1,114 @@ +name: og-badges +# NIP-58 OG badge for well-known.online, run every 15 minutes (and on demand). +# 1. Ensures the badge definition (kind 30009, d=og) by the well-known account (WK_NSEC) is on the relays. +# 2. Diffs og.well-known.online/.well-known/nostr.json against existing awards (kind 8, #a=30009::og) +# on the relays and awards only keys that don't have it yet, so repeat runs are no-ops. +# 3. Our own keys (factory = SNAIL_KNOWN, well-known = WK_NSEC) accept their award via a kind-30008 +# profile_badges list, so clients show it on those profiles. +on: + schedule: + - cron: '*/15 * * * *' + workflow_dispatch: + inputs: + republish_definition: + description: re-sign the definition even if one exists (true/false) + required: false + default: 'false' +jobs: + badges: + runs-on: ubuntu-latest + timeout-minutes: 8 + steps: + - name: define, diff, award + env: + K_WK: ${{ secrets.WK_NSEC }} + K_FACTORY: ${{ secrets.SNAIL_KNOWN }} + REPUBLISH: ${{ inputs.republish_definition }} + RELAYS: wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net + OG_DIR: https://og.well-known.online/.well-known/nostr.json + run: | + mkdir -p /tmp/ob && cd /tmp/ob + npm init -y >/dev/null && npm i --silent @noble/curves@1.9.7 >/dev/null + cat > b.mjs <<'EOF' + import { schnorr } from '@noble/curves/secp256k1'; + import { createHash } from 'node:crypto'; + const CH = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'; + const toHex = (s) => { + s = (s || '').trim().toLowerCase(); + if (/^[0-9a-f]{64}$/.test(s)) return s; + const d = [...s.slice(s.lastIndexOf('1') + 1)].map((c) => CH.indexOf(c)).slice(0, -6); + let acc = 0, bits = 0; const out = []; + for (const v of d) { acc = (acc << 5) | v; bits += 5; while (bits >= 8) { bits -= 8; out.push((acc >> bits) & 255); } } + return Buffer.from(out).toString('hex'); + }; + const key = (raw) => { const sk = toHex(raw); return { sk, pk: Buffer.from(schnorr.getPublicKey(sk)).toString('hex') }; }; + if (!process.env.K_WK) { console.error('WK_NSEC missing'); process.exit(1); } + const WK = key(process.env.K_WK); + const FACTORY = process.env.K_FACTORY ? key(process.env.K_FACTORY) : null; + const RELAYS = process.env.RELAYS.split(',').map((s) => s.trim()).filter(Boolean); + const A = `30009:${WK.pk}:og`; + const now = () => Math.floor(Date.now() / 1000); + + const sign = (k, kind, tags, content = '') => { + const ev = { pubkey: k.pk, created_at: now(), kind, tags, content }; + ev.id = createHash('sha256').update(JSON.stringify([0, ev.pubkey, ev.created_at, ev.kind, ev.tags, ev.content])).digest('hex'); + ev.sig = Buffer.from(schnorr.sign(ev.id, k.sk)).toString('hex'); + return ev; + }; + const ws = (url, onopen, onmsg, ms) => new Promise((resolve) => { + let done = false, sock; const fin = (r) => { if (!done) { done = true; try { sock.close(); } catch {} resolve(r); } }; + const t = setTimeout(() => fin('timeout'), ms); + try { sock = new WebSocket(url); } catch { clearTimeout(t); return fin('error'); } + sock.onopen = () => onopen(sock); + sock.onmessage = (m) => { let x; try { x = JSON.parse(m.data); } catch { return; } const r = onmsg(x); if (r !== undefined) { clearTimeout(t); fin(r); } }; + sock.onerror = () => { clearTimeout(t); fin('error'); }; + }); + const query = async (filter) => { + const seen = new Map(); + await Promise.all(RELAYS.map((url) => ws(url, (s) => s.send(JSON.stringify(['REQ', 'q', filter])), + (x) => { if (x[0] === 'EVENT' && x[1] === 'q') seen.set(x[2].id, x[2]); else if (x[0] === 'EOSE') return 'eose'; }, 8000))); + return [...seen.values()]; + }; + const publish = async (ev, label) => { + const res = await Promise.all(RELAYS.map(async (url) => [url, await ws(url, (s) => s.send(JSON.stringify(['EVENT', ev])), + (x) => (x[0] === 'OK' && x[1] === ev.id ? (x[2] ? 'ok' : 'rejected: ' + x[3]) : undefined), 8000)])); + const ok = res.filter(([, r]) => r === 'ok').length; + console.log(`${label}: kind ${ev.kind} id ${ev.id} ${ok}/${RELAYS.length}`, JSON.stringify(Object.fromEntries(res))); + if (!ok) process.exitCode = 1; + return ok; + }; + + // 1. definition + const defs = await query({ kinds: [30009], authors: [WK.pk], '#d': ['og'] }); + if (!defs.length || process.env.REPUBLISH === 'true') { + const img = 'https://well-known.online/badges/og.png'; + await publish(sign(WK, 30009, [['d', 'og'], ['name', 'OG'], + ['description', 'One of the first names on well-known.online: an OG handle, with a free inbox at name@well-known.online.'], + ['image', img, '512x512'], ['thumb', img, '512x512']]), 'definition'); + } else console.log('definition: present', defs[0].id); + + // 2. diff directory vs awards + const dir = await (await fetch(process.env.OG_DIR)).json(); + const ogKeys = [...new Set(Object.values(dir.names || {}).map((k) => String(k).toLowerCase()).filter((k) => /^[0-9a-f]{64}$/.test(k)))]; + const awards = await query({ kinds: [8], authors: [WK.pk], '#a': [A] }); + const awarded = new Set(awards.flatMap((e) => e.tags.filter((t) => t[0] === 'p').map((t) => t[1]))); + const missing = ogKeys.filter((k) => !awarded.has(k)); + console.log(`og directory: ${ogKeys.length} keys; already awarded: ${[...awarded].length}; to award: ${missing.length}`); + for (let i = 0; i < missing.length; i += 50) { + await publish(sign(WK, 8, [['a', A], ...missing.slice(i, i + 50).map((p) => ['p', p, RELAYS[0]])]), 'award'); + } + + // 3. our own keys accept (kind 30008 profile_badges), once + const allAwards = missing.length ? await query({ kinds: [8], authors: [WK.pk], '#a': [A] }) : awards; + for (const [label, k] of [['factory', FACTORY], ['well-known', WK]]) { + if (!k || !ogKeys.includes(k.pk)) continue; + const award = allAwards.find((e) => e.tags.some((t) => t[0] === 'p' && t[1] === k.pk)); + if (!award) continue; + const pb = await query({ kinds: [30008], authors: [k.pk], '#d': ['profile_badges'] }); + const latest = pb.sort((a, b) => b.created_at - a.created_at)[0]; + if (latest && latest.tags.some((t) => t[0] === 'a' && t[1] === A)) { console.log(`${label}: profile_badges already lists OG`); continue; } + const keep = latest ? latest.tags.filter((t) => t[0] !== 'd') : []; + await publish(sign(k, 30008, [['d', 'profile_badges'], ...keep, ['a', A], ['e', award.id, RELAYS[0]]]), `${label} accept`); + } + EOF + node b.mjs