Author SHA1 Message Date
teahubandClaude Opus 5.5 d8f7ad2920 wk-recovery: set or clear the recovery key on our own names (signs in Actions)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UozKbNTNSfntAfsaaFuLuM
2026-10-25 20:03:57 -07:00
teahubandClaude Opus 5.5 e6107d6946 mail-auth-check: print the sender-auth verdict of one email in uniqornlabz@
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UozKbNTNSfntAfsaaFuLuM
2026-10-25 19:59:41 -07:00
Claude 15a3cc9bd0 runner: cron lane (green-tea-runner-cron) and og-badges on it
New Fly app green-tea-runner-cron (shared-cpu-1x, 2GB, label cron, same config.yaml:
30s fetch timeout, 1h job cap) takes scheduled/ops jobs so deploys on green-tea-runner
never wait behind them. og-badges now runs-on: cron.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVFi3wvLWKaEkpDw6eLnQC
2026-10-25 16:56:10 -07:00
teahubxyzandClaude Opus 5.5 a21a12db05 ops: end-to-end check of encrypted messages between names (NIP-17)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVFi3wvLWKaEkpDw6eLnQC
2026-10-25 23:10:54 +00:00
teahubxyzandClaude Opus 5.5 9650dd22a8 ops: mail-e2e workflow (internal delivery between well-known names)
Signs with SNAIL_KNOWN inside Actions (factory key owns get@ and uniqornlabz@)
and exercises API send, API reply, MCP send_reply via /auth/mcp-token, the
external/ineligible gates and two-way threading. Cleans up its test mail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVFi3wvLWKaEkpDw6eLnQC
2026-10-25 22:46:50 +00:00
Claude be2f699456 og-badges: tidy log line
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVFi3wvLWKaEkpDw6eLnQC
2026-10-25 15:32:08 -07:00
teahubxyzandClaude Opus 5.5 9bf8aeceff 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
2026-10-25 22:29:22 +00:00
Claude 0cd7a970f2 runner: green-tea-runner config in repo; fix lost jobs (fetch_timeout 5s -> 30s)
The runner abandoned tasks Gitea had already assigned when FetchTask took
longer than the default 5s fetch_timeout, leaving runs in_progress with no
logs until Gitea's zombie sweep failed them (~15 min). Raise fetch_timeout
to 30s, cap jobs at 1h, and keep the Fly config + runner config here so the
runner can be rebuilt from the repo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVFi3wvLWKaEkpDw6eLnQC
2026-10-25 15:24:12 -07:00
teahubxyzandClaude Opus 5.5 0029c9f2a6 ops: nostr-publish defaults to the relays that accept our writes (damus, nos.lol, primal)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVFi3wvLWKaEkpDw6eLnQC
2026-10-25 22:09:10 +00:00
teahubxyzandClaude Opus 5.5 950480ccdd ops: nostr-publish workflow (sign with a factory account key inside Actions, publish to relays)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVFi3wvLWKaEkpDw6eLnQC
2026-10-25 22:00:47 +00:00
teahubxyzandClaude Opus 5.5 e9627ecc8d ops: assign well-known house handles, signed with the factory key (SNAIL_KNOWN)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVFi3wvLWKaEkpDw6eLnQC
2026-10-25 21:42:19 +00:00
teahubxyzandClaude Opus 5.5 b6d6432458 ops: workflow to mint a scoped Cloudflare token from CROWD_FAIR, sealed to a caller pubkey
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVFi3wvLWKaEkpDw6eLnQC
2026-10-25 20:08:41 +00:00
teahubxyzandClaude Opus 5.5 049718d078 pages: serve maths.teahub.xyz from maths/site
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01161aC97vUnBnyZRBGyJeHy
2026-10-23 19:33:56 +00:00
teahubxyz 091a9e002f plugins/webamp: open in view on long pages (#25) 2026-10-22 19:02:27 +00:00
teahubxyz fd08195cdc plugins/webamp: robust bundle load and visible errors (#24) 2026-10-22 18:47:35 +00:00
teahubxyz 917f69fbce plugins/webamp: self-host the Webamp bundle (#23) 2026-10-22 18:34:38 +00:00
14 changed files with 741 additions and 16 deletions

No files matched your search

+60
View File
@@ -0,0 +1,60 @@
name: cf-mint-token
# Mints a narrowly scoped Cloudflare API token using the broad CROWD_FAIR secret,
# then seals it to the caller's RSA public key so only the caller can read it.
# The plaintext token never appears in logs.
on:
workflow_dispatch:
inputs:
pubkey_b64:
description: base64 of a PEM RSA public key (>=2048 bits)
required: true
name:
description: token name
required: true
default: well-known-mail-deploy
zone:
description: zone name the token is scoped to
required: true
default: well-known.online
jobs:
mint:
runs-on: ubuntu-latest
steps:
- name: mint and seal
env:
CF: ${{ secrets.CROWD_FAIR }}
PUB: ${{ inputs.pubkey_b64 }}
TNAME: ${{ inputs.name }}
ZNAME: ${{ inputs.zone }}
run: |
cat > /tmp/mint.mjs <<'EOF'
import crypto from 'node:crypto';
const H = { Authorization: `Bearer ${process.env.CF}`, 'Content-Type': 'application/json' };
const api = async (p, o = {}) => { const r = await fetch('https://api.cloudflare.com/client/v4/' + p, { headers: H, ...o }); return r.json(); };
const z = (await api('zones?name=' + process.env.ZNAME)).result?.[0];
if (!z) throw new Error('zone not visible to CROWD_FAIR');
const A = z.account.id, Z = z.id;
let base = 'user/tokens';
let pg = await api(base + '/permission_groups');
if (!pg.success) { base = `accounts/${A}/tokens`; pg = await api(base + '/permission_groups'); }
if (!pg.success) throw new Error('cannot list permission groups: ' + JSON.stringify(pg.errors));
const want = {
acct: ['Workers Scripts Write', 'Workers R2 Storage Write', 'Email Routing Addresses Write', 'Account Settings Read'],
zone: ['Email Routing Rules Write', 'Zone Settings Write', 'DNS Write', 'Workers Routes Write', 'Zone Read'],
};
const pick = (names) => names.map((n) => { const g = pg.result.find((x) => x.name === n); if (!g) console.log('missing group:', n); return g && { id: g.id }; }).filter(Boolean);
const body = {
name: process.env.TNAME,
policies: [
{ effect: 'allow', resources: { [`com.cloudflare.api.account.${A}`]: '*' }, permission_groups: pick(want.acct) },
{ effect: 'allow', resources: { [`com.cloudflare.api.account.zone.${Z}`]: '*' }, permission_groups: pick(want.zone) },
],
};
const t = await api(base, { method: 'POST', body: JSON.stringify(body) });
if (!t.success) throw new Error('create failed: ' + JSON.stringify(t.errors));
const pub = Buffer.from(process.env.PUB, 'base64').toString();
const sealed = crypto.publicEncrypt({ key: pub, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' }, Buffer.from(t.result.value));
console.log('account', A, 'zone', Z, 'token id', t.result.id, 'via', base);
console.log('SEALED=' + sealed.toString('base64'));
EOF
node /tmp/mint.mjs
+120
View File
@@ -0,0 +1,120 @@
name: dm-e2e
# End-to-end check of encrypted messages (NIP-17) between well-known names on production.
# well-known (WK_NSEC) and the factory key (SNAIL_KNOWN, owner of uniqornlabz@ and get@) message each other
# over public relays; the mail Worker only ever stores the gift-wrapped ciphertext. Keys stay in the runner.
on:
workflow_dispatch:
inputs:
base: { description: mail base URL, required: true, default: https://mail.well-known.online }
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- name: run
env:
KF: ${{ secrets.SNAIL_KNOWN }}
KW: ${{ secrets.WK_NSEC }}
B: ${{ inputs.base }}
run: |
mkdir -p /tmp/dm && cd /tmp/dm
npm init -y >/dev/null && npm i --silent nostr-tools@2.25.2 @noble/curves@1.9.7 >/dev/null
cat > t.mjs <<'EOF'
import { finalizeEvent, generateSecretKey, getEventHash, getPublicKey, verifyEvent } from 'nostr-tools/pure';
import * as nip44 from 'nostr-tools/nip44';
import * as nip19 from 'nostr-tools/nip19';
import { spawn } from 'node:child_process';
import { writeFileSync } from 'node:fs';
const B = process.env.B, RELAYS = ['wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.primal.net'];
const hex = (s) => { s = s.trim(); if (/^[0-9a-f]{64}$/i.test(s)) return s.toLowerCase(); return Buffer.from(nip19.decode(s).data).toString('hex'); };
const bytes = (h) => Uint8Array.from(Buffer.from(h, 'hex'));
const F = bytes(hex(process.env.KF)), W = bytes(hex(process.env.KW));
const pkF = getPublicKey(F), pkW = getPublicKey(W);
let fails = 0; const made = [];
const check = (n, ok, extra = '') => { console.log(`${ok ? 'PASS' : 'FAIL'} ${n}${extra ? ' ' + extra : ''}`); if (!ok) fails++; };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const nip98 = (sk, url, method) => { const ev = finalizeEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), tags: [['u', url.split('?')[0]], ['method', method]], content: '' }, sk);
return 'Nostr ' + Buffer.from(JSON.stringify(ev)).toString('base64'); };
const api = async (sk, method, path, body) => { const url = B + path; const r = await fetch(url, { method, headers: { authorization: nip98(sk, url, method), 'content-type': 'application/json' }, body: body ? JSON.stringify(body) : undefined });
const t = await r.text(); let j; try { j = JSON.parse(t); } catch { j = t; } return { status: r.status, j, raw: t }; };
const mb = (m) => `/api/v1/mailboxes/${encodeURIComponent(m)}`;
const UNI = 'uniqornlabz@well-known.online', WKM = 'well-known@well-known.online', GET = 'get@well-known.online';
const past = () => Math.floor(Date.now() / 1000) - Math.floor(Math.random() * 172800);
function makeDm(sk, to, text) {
const me = getPublicKey(sk);
const rumor = { pubkey: me, created_at: Math.floor(Date.now() / 1000), kind: 14, tags: [['p', to]], content: text }; rumor.id = getEventHash(rumor);
return [to, me].map((t) => { const seal = finalizeEvent({ kind: 13, created_at: past(), tags: [], content: nip44.encrypt(JSON.stringify(rumor), nip44.getConversationKey(sk, t)) }, sk);
const esk = generateSecretKey(); return finalizeEvent({ kind: 1059, created_at: past(), tags: [['p', t]], content: nip44.encrypt(JSON.stringify(seal), nip44.getConversationKey(esk, t)) }, esk); });
}
function openDm(sk, w) { const seal = JSON.parse(nip44.decrypt(w.content, nip44.getConversationKey(sk, w.pubkey)));
if (seal.kind !== 13 || !verifyEvent(seal)) throw new Error('seal'); const r = JSON.parse(nip44.decrypt(seal.content, nip44.getConversationKey(sk, seal.pubkey)));
if (r.pubkey !== seal.pubkey) throw new Error('sender'); return r; }
const publish = (ev) => Promise.all(RELAYS.map((u) => new Promise((res) => { const ws = new WebSocket(u); const t = setTimeout(() => { try { ws.close(); } catch {} res(false); }, 7000);
ws.onopen = () => ws.send(JSON.stringify(['EVENT', ev])); ws.onmessage = (m) => { const x = JSON.parse(m.data); if (x[0] === 'OK' && x[1] === ev.id) { clearTimeout(t); ws.close(); res(!!x[2]); } }; ws.onerror = () => { clearTimeout(t); res(false); }; })));
async function waitFor(sk, mailbox, pred, label) {
for (let i = 0; i < 24; i++) {
await api(sk, 'POST', `${mb(mailbox)}/dms/poll`);
const r = await api(sk, 'GET', `${mb(mailbox)}/dms`);
for (const w of r.j.wraps || []) { try { const m = openDm(sk, w); if (pred(m)) return { m, raw: r.raw }; } catch {} }
await sleep(5000);
}
return { m: null, raw: '' };
}
const tag = 'dm-e2e-' + Date.now();
// 0. well-known@ mailbox exists (login creates it for an OG handle)
let r = await fetch(B + '/auth/login', { method: 'POST', headers: { authorization: nip98(W, B + '/auth/login', 'POST') } });
const lj = await r.json(); check('well-known key signs in and owns well-known@', r.status === 200 && (lj.mailboxes || []).includes(WKM), JSON.stringify(lj).slice(0, 120));
// 1. well-known -> uniqornlabz over relays
const t1 = `${tag} hello from well-known`;
const oks = await Promise.all(makeDm(W, pkF, t1).map(publish));
check('gift wraps accepted by relays', oks.every((a) => a.some(Boolean)), JSON.stringify(oks));
const got1 = await waitFor(F, UNI, (m) => m.content === t1, 't1');
check('uniqornlabz@ inbox stores it and it decrypts with the factory key', !!got1.m);
check('sender is the well-known key', got1.m?.pubkey === pkW);
check('server holds only ciphertext (no plaintext in /dms)', !!got1.raw && !got1.raw.includes(tag));
// 2. reply uniqornlabz -> well-known
const t2 = `${tag} reply from uniqornlabz`;
await Promise.all(makeDm(F, pkW, t2).map(publish));
const got2 = await waitFor(W, WKM, (m) => m.content === t2, 't2');
check('reply arrives in well-known@ and decrypts', !!got2.m && got2.m.pubkey === pkF);
// 3. local signer: list_messages + send_message, crypto on this machine
execSync: {
const src = await (await fetch(B + '/well-known-mcp.mjs')).text(); writeFileSync('/tmp/dm/well-known-mcp.mjs', src);
const p = spawn('node', ['/tmp/dm/well-known-mcp.mjs'], { env: { ...process.env, WK_NSEC: Buffer.from(W).toString('hex'), WK_MAIL_URL: B }, cwd: '/tmp/dm' });
let buf = ''; const waiting = new Map();
p.stdout.on('data', (d) => { buf += d; let i; while ((i = buf.indexOf('\n')) >= 0) { const line = buf.slice(0, i); buf = buf.slice(i + 1); try { const j = JSON.parse(line); waiting.get(j.id)?.(j); } catch {} } });
p.stderr.on('data', (d) => process.stderr.write('[signer] ' + d));
let id = 0; const call = (method, params) => new Promise((res) => { const n = ++id; const t = setTimeout(() => res({ error: 'timeout' }), 90000); waiting.set(n, (j) => { clearTimeout(t); res(j); }); p.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: n, method, params }) + '\n'); });
await call('initialize', { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'dm-e2e', version: '0' } });
p.stdin.write(JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n');
const tl = await call('tools/list', {});
const names = (tl.result?.tools || []).map((t) => t.name);
check('signer exposes list_messages + send_message (and messages_info from server)', names.includes('list_messages') && names.includes('send_message') && names.includes('messages_info'), names.join(','));
const lm = await call('tools/call', { name: 'list_messages', arguments: { with: 'uniqornlabz' } });
const lt = lm.result?.content?.[0]?.text || JSON.stringify(lm);
check('signer list_messages decrypts the reply', lt.includes(t2), lt.slice(0, 160));
const t3 = `${tag} sent by the local signer`;
const sm = await call('tools/call', { name: 'send_message', arguments: { to: 'uniqornlabz', text: t3 } });
check('signer send_message published', /relays_ok/.test(sm.result?.content?.[0]?.text || ''), (sm.result?.content?.[0]?.text || JSON.stringify(sm)).slice(0, 160));
p.kill();
const got3 = await waitFor(F, UNI, (m) => m.content === t3, 't3');
check('signer message arrives at uniqornlabz@', !!got3.m && got3.m.pubkey === pkW);
}
// 4. mailr bridge: internal mail get@ -> well-known@ triggers a NIP-17 notification (subject only)
const subj = `${tag} mail`;
r = await api(F, 'POST', `${mb(GET)}/emails`, { to: WKM, from: GET, subject: subj, text: 'secret body text ' + tag, html: '<p>secret body text</p>' });
check('internal mail get@ -> well-known@ accepted', r.status === 202, String(r.status));
const got4 = await waitFor(W, WKM, (m) => m.content.includes(subj), 'notify');
check('well-known@ owner gets a DM notification with the subject', !!got4.m, got4.m?.content?.slice(0, 120));
check('notification never carries the body', !!got4.m && !got4.m.content.includes('secret body'));
check('notification comes from a dedicated notify key', !!got4.m && got4.m.pubkey !== pkF && got4.m.pubkey !== pkW);
for (const [m, folder] of [[WKM, 'inbox'], [GET, 'sent']]) { const x = await api(F, 'GET', `${mb(m)}/emails?folder=${folder}&limit=50`); for (const e of (x.j.emails || []).filter((e) => e.subject === subj)) made.push([m, e.id]); }
let n = 0; for (const [m, id2] of made) { const d = await api(F, 'DELETE', `${mb(m)}/emails/${id2}`); if (d.status < 300) n++; } console.log(`cleanup: deleted ${n}/${made.length} test emails (DMs stay on relays, encrypted)`);
console.log(fails ? `FAILED: ${fails}` : 'ALL PASS'); process.exit(fails ? 1 : 0);
EOF
node t.mjs
+42
View File
@@ -0,0 +1,42 @@
name: mail-auth-check
# Reads one email in uniqornlabz@well-known.online by subject and prints its sender-auth verdict (no body).
# Signs NIP-98 with the factory key (SNAIL_KNOWN) inside Actions; the key never leaves the runner.
on:
workflow_dispatch:
inputs:
subject: { description: exact subject to find, required: true }
base: { description: mail base URL, required: true, default: https://mail.well-known.online }
jobs:
check:
runs-on: ubuntu-latest
steps:
- name: run
env:
K: ${{ secrets.SNAIL_KNOWN }}
B: ${{ inputs.base }}
SUBJ: ${{ inputs.subject }}
run: |
mkdir -p /tmp/ac && cd /tmp/ac
npm init -y >/dev/null && npm i --silent @noble/curves@1.9.7 >/dev/null
cat > t.mjs <<'JS'
import { schnorr } from '@noble/curves/secp256k1';
import { createHash } from 'node:crypto';
const B = process.env.B, 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 a = 0, b = 0; const o = [];
for (const v of d) { a = (a << 5) | v; b += 5; while (b >= 8) { b -= 8; o.push((a >> b) & 255); } } return Buffer.from(o).toString('hex'); };
const sk = toHex(process.env.K), pk = Buffer.from(schnorr.getPublicKey(sk)).toString('hex');
const nip98 = (url, method) => { const ev = { pubkey: pk, created_at: Math.floor(Date.now() / 1000), kind: 27235, tags: [['u', url.split('?')[0]], ['method', method]], 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, sk)).toString('hex'); return 'Nostr ' + Buffer.from(JSON.stringify(ev)).toString('base64'); };
const api = async (path) => { const url = B + path; const r = await fetch(url, { headers: { authorization: nip98(url, 'GET') } }); return { status: r.status, j: await r.json().catch(() => null) }; };
const mb = '/api/v1/mailboxes/' + encodeURIComponent('uniqornlabz@well-known.online');
let e;
for (let i = 0; i < 20 && !e; i++) { const l = await api(`${mb}/emails?folder=inbox&limit=50`); e = (l.j?.emails || []).find((x) => x.subject === process.env.SUBJ); if (!e) await new Promise((r) => setTimeout(r, 6000)); }
if (!e) { console.log('FAIL not found'); process.exit(1); }
const g = await api(`${mb}/emails/${e.id}`);
console.log('sender:', g.j.sender);
console.log('auth:', JSON.stringify(g.j.auth, null, 2));
process.exit(g.j.auth ? 0 : 1);
JS
node t.mjs
+102
View File
@@ -0,0 +1,102 @@
name: mail-e2e
# End-to-end check of internal mail between @well-known.online names on production.
# Signs NIP-98 requests with the factory key (SNAIL_KNOWN) inside Actions; the key never leaves the runner.
on:
workflow_dispatch:
inputs:
base: { description: mail base URL, required: true, default: https://mail.well-known.online }
keep: { description: "keep test mail (true/false)", required: true, default: "false" }
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- name: run
env:
K: ${{ secrets.SNAIL_KNOWN }}
B: ${{ inputs.base }}
KEEP: ${{ inputs.keep }}
run: |
mkdir -p /tmp/e2e && cd /tmp/e2e
npm init -y >/dev/null && npm i --silent @noble/curves@1.9.7 >/dev/null
cat > t.mjs <<'EOF'
import { schnorr } from '@noble/curves/secp256k1';
import { createHash } from 'node:crypto';
const B = process.env.B, 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 a = 0, b = 0; const o = [];
for (const v of d) { a = (a << 5) | v; b += 5; while (b >= 8) { b -= 8; o.push((a >> b) & 255); } } return Buffer.from(o).toString('hex'); };
const sk = toHex(process.env.K), pk = Buffer.from(schnorr.getPublicKey(sk)).toString('hex');
const nip98 = (url, method) => { const ev = { pubkey: pk, created_at: Math.floor(Date.now() / 1000), kind: 27235, tags: [['u', url.split('?')[0]], ['method', method]], 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, sk)).toString('hex'); return 'Nostr ' + Buffer.from(JSON.stringify(ev)).toString('base64'); };
const api = async (method, path, body) => { const url = B + path; const r = await fetch(url, { method, headers: { authorization: nip98(url, method), 'content-type': 'application/json' }, body: body ? JSON.stringify(body) : undefined });
const t = await r.text(); let j; try { j = JSON.parse(t); } catch { j = t; } return { status: r.status, j }; };
const GET = 'get@well-known.online', UNI = 'uniqornlabz@well-known.online', mb = (m) => `/api/v1/mailboxes/${encodeURIComponent(m)}`;
const tag = 'e2e-' + Date.now(); let fails = 0; const made = [];
const check = (name, ok, extra = '') => { console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${extra ? ' ' + extra : ''}`); if (!ok) fails++; };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const find = async (m, folder, subj) => { for (let i = 0; i < 10; i++) { const r = await api('GET', `${mb(m)}/emails?folder=${folder}&limit=50`);
const e = (r.j.emails || []).find((x) => x.subject === subj); if (e) return e; await sleep(1500); } return null; };
// 1. API send get@ -> uniqornlabz@
const s1 = `${tag} hello`;
let r = await api('POST', `${mb(GET)}/emails`, { to: UNI, from: GET, subject: s1, text: 'internal hello', html: '<p>internal hello</p>' });
check('api send get@ -> uniqornlabz@ accepted', r.status === 202, `${r.status} ${JSON.stringify(r.j).slice(0, 120)}`);
const inb1 = await find(UNI, 'inbox', s1), sent1 = await find(GET, 'sent', s1);
check('delivered to uniqornlabz@ INBOX', !!inb1); check('sender keeps SENT copy', !!sent1);
if (inb1) made.push([UNI, inb1.id]); if (sent1) made.push([GET, sent1.id]);
if (inb1) check('from header is the sender mailbox', (inb1.sender || '').toLowerCase() === GET, inb1.sender);
// 2. gates
r = await api('POST', `${mb(GET)}/emails`, { to: 'someone@example.com', from: GET, subject: `${tag} ext`, text: 'x' });
check('external recipient refused', r.status === 422 && /external/i.test(JSON.stringify(r.j)), `${r.status} ${JSON.stringify(r.j).slice(0, 140)}`);
r = await api('POST', `${mb(GET)}/emails`, { to: [UNI, 'someone@example.com'], from: GET, subject: `${tag} mixed`, text: 'x' });
check('mixed internal+external refused whole send', r.status === 422);
check('nothing delivered for refused mixed send', !(await (async () => { await sleep(2000); const x = await api('GET', `${mb(UNI)}/emails?folder=inbox&limit=50`); return (x.j.emails || []).some((e) => e.subject === `${tag} mixed`); })()));
r = await api('POST', `${mb(GET)}/emails`, { to: `nobody-${tag}@well-known.online`, from: GET, subject: `${tag} nobody`, text: 'x' });
check('non-receiving name refused', r.status === 422 && /can't receive/i.test(JSON.stringify(r.j)), `${r.status} ${JSON.stringify(r.j).slice(0, 140)}`);
r = await api('POST', `${mb(GET)}/emails`, { to: UNI, from: UNI, subject: `${tag} spoof`, text: 'x' });
check('from must be the sending mailbox', r.status === 400, String(r.status));
// 3. API reply uniqornlabz@ -> get@ threads in both mailboxes
if (inb1) {
r = await api('POST', `${mb(UNI)}/emails/${inb1.id}/reply`, { to: GET, from: UNI, subject: `Re: ${s1}`, text: 'reply via api', html: '<p>reply via api</p>' });
check('api reply accepted', r.status === 202, `${r.status} ${JSON.stringify(r.j).slice(0, 120)}`);
const inb2 = await find(GET, 'inbox', `Re: ${s1}`), sent2 = await find(UNI, 'sent', `Re: ${s1}`);
check('reply delivered to get@ INBOX', !!inb2); if (inb2) made.push([GET, inb2.id]); if (sent2) made.push([UNI, sent2.id]);
check('reply threads with original in get@', !!(inb2 && sent1 && inb2.thread_id === sent1.thread_id), `${inb2?.thread_id} vs ${sent1?.thread_id}`);
check('reply threads with original in uniqornlabz@', !!(sent2 && sent2.thread_id === inb1.thread_id), `${sent2?.thread_id} vs ${inb1.thread_id}`);
// 4. MCP send_reply get@ -> uniqornlabz@ via the signer token flow
const tr = await fetch(B + '/auth/mcp-token', { method: 'POST', headers: { authorization: nip98(B + '/auth/mcp-token', 'POST') } });
const tj = await tr.json(); const tok = tj.access_token || tj.token;
check('mcp token issued', tr.status === 200 && !!tok, String(tr.status));
const H = { 'content-type': 'application/json', accept: 'application/json, text/event-stream', authorization: 'Bearer ' + tok };
const rpc = async (body, sid) => { const x = await fetch(B + '/mcp', { method: 'POST', headers: sid ? { ...H, 'mcp-session-id': sid } : H, body: JSON.stringify(body) });
const t = await x.text(); const m = t.match(/data: (.*)/); return { x, j: m ? JSON.parse(m[1]) : (t ? JSON.parse(t) : null) }; };
let { x, j } = await rpc({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'mail-e2e', version: '0' } } });
const sid = x.headers.get('mcp-session-id'); await rpc({ jsonrpc: '2.0', method: 'notifications/initialized' }, sid);
if (inb2) {
({ j } = await rpc({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'send_reply', arguments: { mailboxId: GET, originalEmailId: inb2.id, to: UNI, subject: `Re: ${s1}`, bodyHtml: '<p>Thanks, this is a reply sent over MCP between two well-known names.</p>' } } }, sid));
const txt = j?.result?.content?.[0]?.text || JSON.stringify(j);
check('mcp send_reply ok', !j?.result?.isError && /sent/i.test(txt), txt.slice(0, 140));
await sleep(2500);
const list = await api('GET', `${mb(UNI)}/emails?folder=inbox&limit=50`);
const thread = (list.j.emails || []).filter((e) => e.thread_id === inb1.thread_id);
check('mcp reply lands in the same uniqornlabz@ thread', thread.length >= 2, `${thread.length} in thread`);
for (const e of thread) if (e.id !== inb1.id) made.push([UNI, e.id]);
const gs = await api('GET', `${mb(GET)}/emails?folder=sent&limit=50`);
for (const e of (gs.j.emails || []).filter((e) => e.subject === `Re: ${s1}`)) made.push([GET, e.id]);
}
({ j } = await rpc({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'send_email', arguments: { mailboxId: GET, to: 'someone@example.com', subject: `${tag} mcp ext`, bodyHtml: '<p>x</p>' } } }, sid));
const t3 = j?.result?.content?.[0]?.text || JSON.stringify(j);
check('mcp send_email to external refused', /external/i.test(t3), t3.slice(0, 140));
const gs2 = await api('GET', `${mb(GET)}/emails?folder=sent&limit=50`);
check('refused mcp send leaves no SENT copy', !(gs2.j.emails || []).some((e) => e.subject === `${tag} mcp ext`));
}
// 5. cleanup
if (process.env.KEEP !== 'true') { let n = 0; for (const [m, id] of made) { const d = await api('DELETE', `${mb(m)}/emails/${id}`); if (d.status < 300) n++; } console.log(`cleanup: deleted ${n}/${made.length}`); }
console.log(fails ? `FAILED: ${fails}` : 'ALL PASS'); process.exit(fails ? 1 : 0);
EOF
node t.mjs
+71
View File
@@ -0,0 +1,71 @@
name: nostr-publish
# Sign nostr events with one of the factory's account keys and publish them to relays.
# Keys live only in Actions secrets: factory = SNAIL_KNOWN (uniqornlabz), well-known = WK_NSEC.
# Input `events` is a JSON array of {kind, content, tags?}; content may be a string or an object (kind-0 profile).
on:
workflow_dispatch:
inputs:
account:
description: factory | well-known
required: true
default: factory
events:
description: 'JSON array: [{"kind":0,"content":{...}},{"kind":1,"content":"..."}]'
required: true
relays:
description: comma-separated relay URLs
required: true
default: wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net
jobs:
publish:
runs-on: ubuntu-latest
steps:
- name: sign and publish
env:
ACCOUNT: ${{ inputs.account }}
K_FACTORY: ${{ secrets.SNAIL_KNOWN }}
K_WK: ${{ secrets.WK_NSEC }}
EVENTS: ${{ inputs.events }}
RELAYS: ${{ inputs.relays }}
run: |
mkdir -p /tmp/np && cd /tmp/np
npm init -y >/dev/null && npm i --silent @noble/curves@1.9.7 >/dev/null
cat > p.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 raw = { factory: process.env.K_FACTORY, 'well-known': process.env.K_WK }[process.env.ACCOUNT];
if (!raw) { console.error('unknown account or missing secret'); process.exit(1); }
const sk = toHex(raw);
const pubkey = Buffer.from(schnorr.getPublicKey(sk)).toString('hex');
console.log('account', process.env.ACCOUNT, 'pubkey', pubkey);
const relays = process.env.RELAYS.split(',').map((s) => s.trim()).filter(Boolean);
const publish = (url, ev) => new Promise((resolve) => {
let done = false; const fin = (r) => { if (!done) { done = true; try { ws.close(); } catch {} resolve(r); } };
const ws = new WebSocket(url);
const t = setTimeout(() => fin('timeout'), 8000);
ws.onopen = () => ws.send(JSON.stringify(['EVENT', ev]));
ws.onmessage = (m) => { try { const x = JSON.parse(m.data); if (x[0] === 'OK' && x[1] === ev.id) { clearTimeout(t); fin(x[2] ? 'ok' : 'rejected: ' + x[3]); } } catch {} };
ws.onerror = () => { clearTimeout(t); fin('error'); };
});
let i = 0;
for (const e of JSON.parse(process.env.EVENTS)) {
const ev = { pubkey, created_at: Math.floor(Date.now() / 1000) + i++, kind: e.kind,
tags: e.tags || [], content: typeof e.content === 'string' ? e.content : JSON.stringify(e.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, sk)).toString('hex');
const res = await Promise.all(relays.map(async (r) => [r, await publish(r, ev)]));
const ok = res.filter(([, s]) => s === 'ok').length;
console.log(`kind ${ev.kind} id ${ev.id}: ${ok}/${relays.length} ok`, JSON.stringify(Object.fromEntries(res)));
if (!ok) process.exitCode = 1;
}
EOF
node p.mjs
+110
View File
@@ -0,0 +1,110 @@
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: cron
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.size}; 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
+58
View File
@@ -0,0 +1,58 @@
name: wk-house
# Assign well-known.online "house" handles to a pubkey (default: the factory key) via POST /house,
# signed with the factory nsec (SNAIL_KNOWN). The key never appears in logs.
on:
workflow_dispatch:
inputs:
names:
description: comma-separated names
required: true
og:
description: list in og.well-known.online (free inbox)
required: true
default: 'true'
pubkey:
description: owner pubkey (hex or npub); empty = the factory key
required: false
default: ''
jobs:
house:
runs-on: ubuntu-latest
steps:
- name: assign
env:
NSEC: ${{ secrets.SNAIL_KNOWN }}
NAMES: ${{ inputs.names }}
OG: ${{ inputs.og }}
OWNER: ${{ inputs.pubkey }}
run: |
mkdir -p /tmp/h && cd /tmp/h
npm init -y >/dev/null && npm i --silent @noble/curves@1.9.7 >/dev/null
cat > h.mjs <<'EOF'
import { schnorr } from '@noble/curves/secp256k1';
import { createHash } from 'node:crypto';
const CH = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
function nsecToHex(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 sk = nsecToHex(process.env.NSEC);
const pk = Buffer.from(schnorr.getPublicKey(sk)).toString('hex');
console.log('signer', pk);
let i = 0;
for (const name of process.env.NAMES.split(',').map((s) => s.trim()).filter(Boolean)) {
const ev = { pubkey: pk, created_at: Math.floor(Date.now() / 1000), kind: 27235, content: String(i++),
tags: [['u', 'https://well-known.online/house'], ['method', 'POST'], ['name', name]] };
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, sk)).toString('hex');
const body = { event: ev, og: process.env.OG === 'true', ...(process.env.OWNER ? { pubkey: process.env.OWNER } : {}) };
const r = await fetch('https://well-known.online/house', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
console.log(name, r.status, await r.text());
if (r.status >= 300) process.exitCode = 1;
}
EOF
node h.mjs
+50
View File
@@ -0,0 +1,50 @@
name: wk-recovery
# Sets (or clears, with recovery=none) the recovery key on our own well-known names.
# Signs with the name's main key inside Actions: factory = SNAIL_KNOWN, well-known = WK_NSEC. Keys never leave the runner.
on:
workflow_dispatch:
inputs:
key: { description: "which main key signs: factory or well-known", required: true, default: factory }
names: { description: comma-separated names, required: true }
recovery: { description: "recovery pubkey (64 hex) or none", required: true }
jobs:
set:
runs-on: ubuntu-latest
steps:
- name: run
env:
K_FACTORY: ${{ secrets.SNAIL_KNOWN }}
K_WK: ${{ secrets.WK_NSEC }}
WHICH: ${{ inputs.key }}
NAMES: ${{ inputs.names }}
REC: ${{ inputs.recovery }}
run: |
mkdir -p /tmp/rk && cd /tmp/rk
npm init -y >/dev/null && npm i --silent @noble/curves@1.9.7 >/dev/null
cat > t.mjs <<'JS'
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 a = 0, b = 0; const o = [];
for (const v of d) { a = (a << 5) | v; b += 5; while (b >= 8) { b -= 8; o.push((a >> b) & 255); } } return Buffer.from(o).toString('hex'); };
const raw = process.env.WHICH === 'well-known' ? process.env.K_WK : process.env.WHICH === 'factory' ? process.env.K_FACTORY : '';
if (!raw) { console.log('key must be factory or well-known'); process.exit(2); }
const rec = process.env.REC.trim().toLowerCase();
if (rec !== 'none' && !/^[0-9a-f]{64}$/.test(rec)) { console.log('recovery must be 64 hex or none'); process.exit(2); }
const sk = toHex(raw), pk = Buffer.from(schnorr.getPublicKey(sk)).toString('hex');
let fails = 0, n = 0;
for (const name of process.env.NAMES.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean)) {
const ev = { pubkey: pk, created_at: Math.floor(Date.now() / 1000), kind: 27235, content: String(n++),
tags: [['u', 'https://well-known.online/recovery'], ['method', 'POST'], ['name', name], ...(rec === 'none' ? [] : [['p', rec]])] };
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, sk)).toString('hex');
const r = await fetch('https://well-known.online/recovery', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ event: ev }) });
const j = await r.json().catch(() => ({}));
const ok = r.status === 200 && (rec === 'none' ? j.recovery === null : j.recovery === rec);
console.log(`${ok ? 'PASS' : 'FAIL'} ${name} ${r.status} ${JSON.stringify(j)}`);
if (!ok) fails++;
}
process.exit(fails ? 1 : 0);
JS
node t.mjs
+1 -1
View File
@@ -6,7 +6,7 @@ primary_region = "iad"
[env]
GITEA_URL = "https://teahub.xyz"
PAGES_SITES = '{"persimmons.app":"persimmons/app","phly.cloud":"phly/site","supertestnet.lol":"supertestnet/app#pages","yolopreneur.lol":"teahubxyz/time#gh-pages/ventures","gandora.lol":"gandora/site","www.gandora.lol":"gandora/site","gandora.yolopreneur.lol":"gandora/site","dev.gandora.lol":"gandora/site#dev","asmr.gandora.lol":"gandora/site#main/asmr"}'
PAGES_SITES = '{"persimmons.app":"persimmons/app","phly.cloud":"phly/site","supertestnet.lol":"supertestnet/app#pages","yolopreneur.lol":"teahubxyz/time#gh-pages/ventures","gandora.lol":"gandora/site","www.gandora.lol":"gandora/site","gandora.yolopreneur.lol":"gandora/site","dev.gandora.lol":"gandora/site#dev","asmr.gandora.lol":"gandora/site#main/asmr","maths.teahub.xyz":"maths/site"}'
PAGES_WILDCARDS = '{"yolopreneur.lol":"teahubxyz/time#gh-pages/ventures"}'
PAGES_PULL_SECONDS = "300"
@@ -1,7 +1,7 @@
// teahub plugin: webamp — a Winamp-style player docked on every Teahub page.
// Loads the self-hosted Webamp bundle on first click, so pages stay fast for everyone else.
// Loads Webamp from the CDN on first click, so pages stay fast for everyone else.
// Module scripts have no document.currentScript, so find our tag by its src.
// Module scripts have no document.currentScript, so find our tag by its data attribute.
const script = document.querySelector('script[src*="plugins/webamp/webamp-mod.js"]');
const PLAYLIST_URL = script?.dataset.playlist;
const KEY = 'teahub-webamp-open';
@@ -36,37 +36,69 @@ async function loadTracks() {
let webamp = null;
// Load the UMD bundle as a classic script. Hide any AMD/CommonJS globals on the page
// while it runs, so the bundle always attaches itself to window.Webamp.
function loadWebamp() {
if (window.Webamp) return Promise.resolve(window.Webamp);
return new Promise((ok, fail) => {
const saved = { define: window.define, module: window.module, exports: window.exports };
window.define = window.module = window.exports = undefined;
const restore = () => Object.assign(window, saved);
const s = document.createElement('script');
s.src = new URL('webamp.bundle.min.js', import.meta.url).href;
s.onload = () => ok(window.Webamp);
s.onerror = fail;
s.onload = () => {
restore();
window.Webamp ? ok(window.Webamp) : fail(new Error('Webamp global missing after load'));
};
s.onerror = () => { restore(); fail(new Error('could not load ' + s.src)); };
document.head.appendChild(s);
});
}
async function open() {
if (webamp) return;
const Webamp = await loadWebamp();
if (!Webamp.browserIsSupported()) return;
webamp = new Webamp({ initialTracks: await loadTracks() });
webamp.onClose(() => {
if (webamp) {
// Already open (maybe off-screen or hidden): close and reopen so it lands centered in view.
webamp.dispose();
webamp = null;
try { localStorage.removeItem(KEY); } catch {}
});
await webamp.renderWhenReady(document.body.appendChild(document.createElement('div')));
try { localStorage.setItem(KEY, '1'); } catch {}
document.getElementById('teahub-webamp')?.remove();
}
btn.textContent = '… webamp';
try {
const Webamp = await loadWebamp();
if (!Webamp.browserIsSupported()) throw new Error('browser not supported');
webamp = new Webamp({ initialTracks: await loadTracks() });
webamp.onClose(() => {
webamp.dispose();
webamp = null;
try { localStorage.removeItem(KEY); } catch {}
});
const host = document.createElement('div');
host.id = 'teahub-webamp';
// Webamp centers itself in this node, so make it exactly the visible viewport.
host.style.cssText = 'position:fixed;left:0;top:0;width:100vw;height:100vh;pointer-events:none';
document.body.appendChild(host);
await webamp.renderWhenReady(host);
try { localStorage.setItem(KEY, '1'); } catch {}
btn.textContent = '▶ webamp';
} catch (err) {
console.error('[teahub webamp]', err);
webamp = null;
btn.textContent = '✕ webamp';
btn.title = String(err && err.message || err);
}
}
// Keep Webamp pinned to the viewport and above Gitea's own layers.
const style = document.createElement('style');
style.textContent = '#webamp{position:fixed!important;z-index:99990!important}';
document.head.appendChild(style);
// Small launcher in the corner: the "sound system" button.
const btn = document.createElement('button');
btn.textContent = '▶ webamp';
btn.title = 'Open Webamp';
btn.style.cssText = [
'position:fixed', 'right:12px', 'bottom:12px', 'z-index:9999',
'position:fixed', 'right:12px', 'bottom:12px', 'z-index:99999',
'font:12px/1 monospace', 'padding:6px 10px', 'border-radius:4px',
'border:1px solid #1d3', 'background:#000', 'color:#1d3', 'cursor:pointer',
].join(';');
+1 -1
View File
@@ -1,2 +1,2 @@
{{/* teahub plugin: webamp */}}
<script type="module" nonce="{{ctx.CspScriptNonce}}" src="{{AssetUrlPrefix}}/plugins/webamp/webamp-mod.js?v=011" data-playlist="https://gandora.yolopreneur.lol/playlist.json"></script>
<script type="module" nonce="{{ctx.CspScriptNonce}}" src="{{AssetUrlPrefix}}/plugins/webamp/webamp-mod.js?v=015"></script>
+20
View File
@@ -0,0 +1,20 @@
# gitea-runner (act_runner 5.0.0) config for Fly app green-tea-runner.
# Mounted at /etc/gitea-runner/config.yaml via fly.toml [[files]]; CONFIG_FILE points at it.
log:
level: info
runner:
file: /data/.runner # registration state lives on the volume
capacity: 1
# Root cause of "job taken, container never starts" (Oct 25 2026): FetchTask to teahub.xyz
# sometimes takes >5s; the default 5s fetch_timeout made the runner abandon a task the
# server had already assigned, leaving it in_progress with no logs until Gitea's zombie
# sweep failed it (~15 min). Give the call room.
fetch_timeout: 30s
fetch_interval: 2s
fetch_interval_max: 10s
# Fail a hung job in 1h instead of the 3h default.
timeout: 1h
shutdown_timeout: 60s
report_close_timeout: 10s
container:
force_pull: false
+30
View File
@@ -0,0 +1,30 @@
# Fly app green-tea-runner-cron: cron-lane Gitea Actions runner (label `cron`) for scheduled/ops jobs, so they never queue ahead of deploys.
# Deploy: cd runner && fly deploy -c fly.cron.toml. Registration token is a Fly secret.
app = "green-tea-runner-cron"
primary_region = "iad"
[build]
image = "docker.gitea.com/act_runner:5.0.0-dind"
[experimental]
# Fly's rootfs is overlay, so Docker's data root must live on the volume.
entrypoint = ["sh", "-c", "mkdir -p /data/docker && rm -rf /var/lib/docker && ln -sfn /data/docker /var/lib/docker && exec s6-svscan /etc/s6"]
[env]
DOCKER_HOST = "unix:///var/run/docker.sock"
GITEA_INSTANCE_URL = "https://teahub.xyz"
GITEA_RUNNER_LABELS = "cron:docker://node:22-bookworm"
GITEA_RUNNER_NAME = "fly-iad-cron-1"
CONFIG_FILE = "/etc/gitea-runner/config.yaml"
[[files]]
guest_path = "/etc/gitea-runner/config.yaml"
local_path = "config.yaml"
[mounts]
source = "runner_cron_data"
destination = "/data"
[[vm]]
size = "shared-cpu-1x"
memory = "2gb"
+30
View File
@@ -0,0 +1,30 @@
# Fly app green-tea-runner: Gitea Actions runner (act_runner 5.0.0-dind), instance-wide as fly-iad-1.
# Deploy: fly deploy -c runner/fly.toml (from repo root). Registration token is a Fly secret.
app = "green-tea-runner"
primary_region = "iad"
[build]
image = "docker.gitea.com/act_runner:5.0.0-dind"
[experimental]
# Fly's rootfs is overlay, so Docker's data root must live on the volume.
entrypoint = ["sh", "-c", "mkdir -p /data/docker && rm -rf /var/lib/docker && ln -sfn /data/docker /var/lib/docker && exec s6-svscan /etc/s6"]
[env]
DOCKER_HOST = "unix:///var/run/docker.sock"
GITEA_INSTANCE_URL = "https://teahub.xyz"
GITEA_RUNNER_LABELS = "ubuntu-latest:docker://node:22-bookworm,ubuntu-22.04:docker://node:22-bookworm"
GITEA_RUNNER_NAME = "fly-iad-1"
CONFIG_FILE = "/etc/gitea-runner/config.yaml"
[[files]]
guest_path = "/etc/gitea-runner/config.yaml"
local_path = "config.yaml"
[mounts]
source = "runner_data"
destination = "/data"
[[vm]]
size = "shared-cpu-2x"
memory = "4gb"