og-badges: NIP-58 OG badge definition + idempotent awards to the OG directory

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVFi3wvLWKaEkpDw6eLnQC
This commit is contained in:
teahubxyzandClaude Opus 5.5 committed 2026-10-25 22:29:22 +00:00
1 parent 0cd7a970f2
commit 9bf8aeceff
1 file changed
+114
+114
View File
@@ -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:<wk>: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