Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51f1325bb0 |
No files matched your search
@@ -1,60 +0,0 @@
|
||||
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
|
||||
@@ -1,120 +0,0 @@
|
||||
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
|
||||
@@ -1,42 +0,0 @@
|
||||
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
|
||||
@@ -1,102 +0,0 @@
|
||||
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
|
||||
@@ -1,71 +0,0 @@
|
||||
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
|
||||
@@ -1,110 +0,0 @@
|
||||
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
|
||||
@@ -1,58 +0,0 @@
|
||||
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
|
||||
@@ -1,50 +0,0 @@
|
||||
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
|
||||
@@ -13,18 +13,10 @@ record; nothing here is scaffolding from before 2026-10-14.
|
||||
Time acts as the Gitea user `time` (its own token, TIME_TOKEN) for every write except comments and commit statuses,
|
||||
which stay with the Actions identity (`gitea-actions`); code goes in as git pushes. Seats post as themselves: a
|
||||
review by `haiku` is a Gitea review by the user `haiku` (one Gitea user per model line, TIME_SEAT_<SEAT>).
|
||||
Rhythm: Astra (daily, 13:13 UTC) is the router: its seat judges every candidate card (kind, value, size) and
|
||||
`router.py` ranks them with the owner's weights per kind in teahubxyz/time's `time/router.json` (mode dry: it posts the ranking on
|
||||
the Astra log; live: it queues from the top and the heartbeat promotes in ranked order). The heartbeat (hourly, and on demand) builds `ready` cards with a
|
||||
Rhythm: Astra (daily, 13:13 UTC) queues cards; the heartbeat (hourly, and on demand) builds `ready` cards with a
|
||||
seat, gets each PR reviewed by the council, and merges on approve + green CI, engine code included. Only the workflow
|
||||
directories (`.github/workflows/`) stay a human's merge: the CEO batches them. A card whose PR already exists (Codex, a
|
||||
chat, the CEO) is adopted: labelled `ready`, it is reviewed and landed, never rebuilt. Housekeeping sweeps daily (13:17).
|
||||
Ventures: paused since 2026-10-19 (TIME_VENTURE=off; the launched ones are still measured) until the router starts
|
||||
them (time#170). When on: one a day, built for quality; the day's card starts once every other venture card is launched or held. Opus leads alone: a research round over the trending repos and the
|
||||
studio's track record, one pick, the plan, the build (`claude:opus`) and the judging (a 1-10 score: under 8 earns
|
||||
polish rounds, then it launches with its score); sonnet drafts the launch posts. No fable. Batches (off) keep the board. Each venture lives at <slug>.yolopreneur.lol (gh-pages `ventures/<slug>/`); the
|
||||
portfolio at yolopreneur.lol is gh-pages `ventures/index.html`, made by hand, and reads `ventures.json`.
|
||||
Seat workers (and the venture builder) carry Ponytail's rules (time `.github/time/rules/ponytail.md`); reviewers don't.
|
||||
Tiers: Luna = haiku (Codex: luna), Terra = sonnet (terra), Sol = opus (sol), Astra = fable (astra), the C level. Venice
|
||||
seats (kimi, grok, ...) break ties. The operator hears from the Astra tier only on exception or check-in.
|
||||
Spend: every model call is booked to time/ledger/<YYYY-MM>.jsonl, cached tokens priced. TIME_MONTHLY_USD=100 is the
|
||||
@@ -63,7 +55,6 @@ What to do when you meet one of these. Each row names the procedure; the procedu
|
||||
| The reviewer objected; the PR is `needs-human` | Read the objection on the PR. If it is right and small, comment `/time retry` on the card: the worker gets the review text and answers on the same branch. If it is wrong, say so on the PR as yourself (Approve/Object comments from trusted users count) and remove the label. |
|
||||
| A PR is approved but `mergeable: false` | It fell behind its base. Until time#123 lands, a human re-cuts the branch from the base and re-applies the change (or merges the base into the branch); a `Closes #N` line keeps the card's adoption. |
|
||||
| A worker stopped at the turn ceiling ("stopped without a PR after N steps") | First check the card's `Base:` line (a venture card without `Base: gh-pages` cannot find its files). Then split the card; never retry the same card unchanged twice. |
|
||||
| An open venture card whose history is comments by a person, not `gitea-actions` (the GitHub-era cards, migrated) | Close it. Time counts only its own beats, so the loop takes such a card as unstarted and pitches a new venture on it (batch time#26 was reused that way on 2026-10-18). The venture itself stays in `ventures.json`. |
|
||||
| An `andon` issue: spend cap reached | Read the ledger for the day. The daily cap is pacing (the CEO may raise it with the owner's OK; log it in `docs/decisions.md`); the monthly cap is the owner's alone. Close the andon once handled; Astra queues nothing while one is open. |
|
||||
| A PR head shows `heartbeat / tick: failure` | Not a check. Time's own workflow statuses (heartbeat, mention, venture, astra, doctor, update, steward) are ignored by the merge gate (time#124). CI decides. |
|
||||
| Housekeeping posted a stale list on the Astra log | Follow `.agents/skills/housekeeping/SKILL.md` in teahubxyz/time: decide each `needs-human`, idle PR, parked or `error` card. Housekeeping never acts on the list itself. |
|
||||
@@ -77,7 +68,5 @@ What to do when you meet one of these. Each row names the procedure; the procedu
|
||||
- Rebuild log: `docs/rebuild.md`.
|
||||
- Skills: `SKILLS.md` (routes, ledger, belts); the vendored folders are in `.agents/skills/`.
|
||||
- Time's engine: `teahubxyz/time` `.github/time/` (`time.py` the clock, `venture.py` the venture loop, `astra.py` the
|
||||
daily planner, `router.py` its ranking, `housekeeping.py` the daily sweep, `sync.py` copies on other forges
|
||||
(`time/sync.json`: one home, one writer per branch; main out fast-forward, `claude/*` branches home as PRs),
|
||||
`seats.json` the seats). Spend pools (expiring credit, refilling windows) are `time/pools.json`. Its SOPs: `.agents/skills/<name>/SKILL.md`
|
||||
daily planner, `housekeeping.py` the daily sweep, `seats.json` the seats). Its SOPs: `.agents/skills/<name>/SKILL.md`
|
||||
in that repo. The planner, the steward and housekeeping all write to the "Astra log" issue there (time#57).
|
||||
@@ -85,15 +85,3 @@ Format: date · decision · why. Add at the bottom. Never edit an old entry; sup
|
||||
- 2026-10-18 · **A card names its base branch, and venture cards say `Base: gh-pages`.** The ventures live on `gh-pages`, an orphan branch; the worker cuts its branch from the card's `Base:` line and opens the PR against it, and every venture card carries the line. Why: the worker had branched from `main` whatever a card said, which is why #60, #63 and #95 ran out of turns and #109 landed the right files on the wrong branch. (time #117)
|
||||
- 2026-10-18 · **Claude seats call Anthropic's own Messages API with prompt caching.** The OpenAI-compatible endpoint has no caching; `TIME_ANTHROPIC_API=compat` is the way back if the native path misbehaves. Why: a 43-step run re-sent its whole context at full price every step and cost $2.80 for nothing; the same run with caching costs a fraction. (time #119)
|
||||
- 2026-10-18 · **A card is what a Terra worker finishes in forty turns; larger work is split before it is queued.** A spec-sized issue (time #72, #60, #63) is a parent labelled `planned`, never `queued`, and its children carry their own `DONE:` lines. Why: three cards died at the turn ceiling; each one cost a full run and produced nothing.
|
||||
- 2026-10-18 · **The venture loop runs on Teahub with the Claude seats, every 6 hours.** Fable chairs (the launch plan, the launch drafts) and judges the build; haiku, sonnet and opus pitch, vote and write plans; `claude:sonnet` builds and `claude:opus` takes the second fix round. The engine had coerced every builder to a Venice seat, which is where the token burn that paused the 20-minute loop came from. Ported to Gitea in time#140 (three council rounds with haiku; the second found that the loop would have written over the yolopreneur.lol portfolio), schedule in time#141. The GitHub-era venture cards were closed: their migrated history is authored by `teahubxyz`, Time trusts only its own beats, and they would have been re-pitched as new ventures. Why: the owner's call, to try the loop on the Claude API with fable leading and the other three voting and executing.
|
||||
- 2026-10-18 · **Daily pacing at $25 for today's first Claude venture run**, on the owner's OK; back to $15 once it is through. `TIME_MONTHLY_USD` stays 100 (the month was at $30.40). The andon (time#136) is closed. Why: the day's $15 was spent before the loop came back, and the owner wanted the run now rather than after midnight.
|
||||
- 2026-10-18 · **Ponytail on prod.** Seat workers, and the venture builder through them, carry Ponytail's rules (DietrichGebert/ponytail, MIT, vendored at time `.github/time/rules/ponytail.md`); reviewers, Astra and the board don't (a coding ruleset distorts answers that aren't a diff). `TIME_PONYTAIL=off` drops it. Why: the owner's call; it had run on hb-5's seats since 2026-10-12 and prod never had it. (time#145)
|
||||
- 2026-10-18 · **One venture a day, built for quality.** The day's card waits until every other venture card is launched or held. The board picks it from a field of six pitches, made in rounds where each round is told what is already pitched. `claude:opus` builds: the code's default, and `TIME_VENTURE_WORKER` on teahubxyz/time. Fable's judge also scores the product 1–10 and names its top three changes. A page under 8 gets up to two polish rounds; the third round stays for fixing whatever a polish broke. After that it launches with its score: quality never holds a launch for a human, only a broken page does. Batches stay off, and an open batch card with no Time beats no longer starts one. The caps are unchanged. October has $61 left in the $100 envelope; if one opus venture a day and the heartbeat outrun that, the loop waits at the cap rather than spending past it. Why: the owner's call ("would rather 1 higher quality"). Today made four ventures: three from migrated batch #26 and the daily card. All four passed on the first sonnet build in about ten steps, so nothing had pushed any of them past "works". (time#162, #163)
|
||||
- 2026-10-18 · **Opus leads the day's venture alone; fable is out of the venture loop.** One research round replaces the pitches and the vote: opus reads the top six trending repos with the start of their READMEs, plus the studio's track record (each venture's status and signups), lists six candidates and picks one. Opus also writes the plan without a synthesis step and judges the build, and hands the launch-post drafts to sonnet. The chair and the judge are opus. That is the code's default, and `TIME_VENTURE_CHAIR` / `TIME_VENTURE_JUDGE` are set to opus so that tonight's run is already fable-free. Batches keep the board; `TIME_VENTURE_LEAD=board` brings it back for the daily venture. Why: the owner's call ("fable eats a lot of credit"). Fable was $2.23 of the day's $23.66, the dearest seat per call, on work opus does as well; the builds were most of the rest. (time#164, #165)
|
||||
- 2026-10-19 · **The venture loop is paused, and the factory picks its own work through the router.** `TIME_VENTURE=off`; the four ventures launched on the 18th are still measured. Astra is now the router (time#68, #72). Its seat judges every candidate card's kind (venture, feature, refinement or optimization), value and size. `router.py` ranks them with the owner's weights per kind in `time/router.json`; all 1 is balanced, the owner's choice. A kind not picked lately climbs (the restless part), and an L is split before it is queued. The router starts in `dry` mode: it posts the ranked table on the Astra log and touches nothing. Going `live` (queue from the top, heartbeat promotes in ranked order) is a one-word edit of that file, made once the rankings look right. The follow-ups are cards the router ranks itself: ventures only when picked (#170), optimization candidates from the ledger and CI (#171), pick outcomes as reward data for a bandit policy (#172), the spine's cards (#173). Why: the owner's call, to have the factory allocate across ventures, features, refinement and optimization rather than run a venture cron beside everything else. The venture register held 33 ventures and 0 signups, so producing more ventures wasn't the bottleneck. (time#168, #169)
|
||||
- 2026-10-19 · **Claude spend is paced to the Anthropic credit.** Time's `time/pools.json` holds one expiring pool: $161.49 as of the 19th, lapsing 2026-11-08. That paces the anthropic route to about $8/day; the $100 monthly cap stays hard and binds first in October. Why: the owner's credit (a $200 grant, $162.22 left on the 18th) expires; a fixed daily cap would either leave it unspent or burn it early. Worker PR #167 was re-cut as #174, with `pools.json` read through the API, since every workflow checks out only `.github/time`. (time#82, #174)
|
||||
- 2026-10-19 · **Repos sync across forges with one home and one writer per branch.** The home pushes its `main` to every copy, fast-forward only; a diverged copy becomes a `needs-human` issue and is never overwritten. A copy's work branches (`claude/*` from cloud sessions) come home as PRs for the council. Config is `time/sync.json`; tokens are secrets named there. Gitea's own mirrors don't fit: a push mirror force-pushes every ref, and a pull mirror makes the home read-only. The first copy is `github.com/teahubxyz/time`, so cloud sessions clone a current engine; it waits on the owner (unarchive it, turn GitHub Actions off on it, set `GITHUB_MIRROR_TOKEN`). The same script syncs Gitea with Gitea, run from the side that can reach both. Why: the owner's $250 cloud-session credit (expires 2026-11-04) covers only sessions that clone from GitHub, and the owner wants several Gitea instances to stay synced on chosen repos. (time#177)
|
||||
- 2026-10-19 · **The GitHub copy of Time is live, pushed with a deploy key.** github.com/teahubxyz/time is unarchived, GitHub Actions is off on it, and its `main` follows Teahub's. It was forced once at 04:07 UTC to 7452318; the proof-of-concept history stays on GitHub as `archive/poc-main` (e85709e). Sync pushes over SSH from prod's runner with a write deploy key for that one repo (GitHub key 166898032), held in Teahub's `SYNC_GITHUB_KEY`, so prod doesn't depend on hb-5. This corrects the entry above: there is no `GITHUB_MIRROR_TOKEN`, because Gitea refuses secret names starting `GITHUB_` or `GITEA_`. The key was added through `gh` on hb-5, signed in as teahubxyz by the owner's device-code approvals. GitHub holds a key that an OAuth app added to that app's scopes, so `gh`'s authorization needed `workflow` before a push touching `.github/workflows/` was accepted; revoking GitHub CLI's access on GitHub may take the key with it. hb-5's `gh` token (repo, workflow) sits in plain text in `~/.config/gh/hosts.yml`. Cloud sessions on teahubxyz/time now clone the current engine, and their `claude/*` branches come home as PRs within 15 minutes. Why: the owner's $250 cloud-session credit (expires 2026-11-04) covers only sessions that clone from GitHub. (time#179, #180)
|
||||
- 2026-10-19 · **The router is live.** `time/router.json` is `live` from 13:49 UTC: Astra queues from the top of the ranking each day, writes its new cards unqueued, and the heartbeat promotes queued cards in ranked order. The two dry rankings (01:02 and 13:14) put the engine's own blockers on top (#135, #175), dropped #84 once #75 was known unmergeable, and held L cards back. Three things were fixed before the flip. Money left is now the worker pool's allowance (about $8/day from the Anthropic credit), not the $15 daily cap. The seat sees the cards closed in the last 14 days, and a new card repeating an open or closed one isn't written; both dry runs had proposed the label-filter fix that landed as #138. And a `ready` card waiting on a person no longer holds a slot: three parked cards (#79, #80, #85) had stalled promotion for ten hours while 22 cards sat `queued`, and #135, the fix, could never be promoted past them. Weights stay all 1 (balanced). Why: the owner's call to have the factory pick its own work across ventures, features, refinement and optimization, live once the rankings looked right. (time#181, #183)
|
||||
- 2026-10-19 · **The monthly cap counts out-of-pocket money; prepaid credit is limited by its own pool.** `TIME_MONTHLY_USD` ($100) leaves out what an expiring pool's credit paid for: its routes, from its `as_of`, until it lapses, up to its balance. Spend before `as_of`, past the balance or the lapse, and on routes without a pool still counts. October's out-of-pocket figure is $38.51, counting the spend before the balance was read. Why: the owner's word that the budget should factor in the usage pool. Counting credit against the cap would have tripped it about Oct 26, stopping the factory for five days with credit left to lapse on Nov 8. The $250 cloud-session credit (expires Nov 4) is not a factory pool: `claude --cloud` refuses to start without an interactive terminal, and scheduled routines don't count toward the credit. So it is drawn only by sessions a person starts on the GitHub copy, whose `claude/*` branches come home through sync. (time#193)
|
||||
- 2026-10-19 · **The cloud lane: the factory spends the cloud-session credit itself.** This corrects the entry above. The credit's terms cover cloud sessions started "from web, desktop, CLI, or mobile"; only Routines, Remote Control and Chat are excluded. A CLI-launched session qualifies, and `claude --cloud` only needs a terminal, which a pseudo-terminal provides. How it runs: Astra labels the best-ranked actionable M cards `cloud`, up to 3 in the lane at once (`time/router.json`). A launcher on hb-5, `~/claude/cloud-lane/launch.py`, runs hourly as the user agent `xyz.teahub.cloud-lane` inside the desktop session. It starts one cloud session per card on the GitHub copy, at most 3 a day and 2 hours apart, and none after Nov 4 noon PT. The session's branch comes home through sync as a PR that closes the card, sync moves the card from `cloud` to `ready`, and the council lands it. Why the desktop session: the claude.ai sign-in lives in the login keychain, which SSH sessions can't read, and the `CLAUDE_CODE_OAUTH_TOKEN` in hb-5's `.zshenv` is inference-only (cloud session create: 403). Separation holds: the launcher reads Teahub's public issue list and holds no Teahub credentials, and prod doesn't depend on it; with the Mac off, the lane simply waits. Off switch: `touch ~/claude/cloud-lane/OFF`. Extra usage is off on the account, so once the credit is gone, sessions count against plan limits and cost no money. Why: the owner asked to use the credit through the factory rather than by hand. (time#197)
|
||||
+1
-2
@@ -10,8 +10,7 @@ CNAME, then A/AAAA to the app's IPs (`fly ips list`), and a push webhook on the
|
||||
|
||||
Live: persimmons.app → `persimmons/app` (repo root, as its `pages/page.conf` says), phly.cloud → `phly/site`,
|
||||
supertestnet.lol → `supertestnet/app#pages`, yolopreneur.lol and
|
||||
`*.yolopreneur.lol` → `teahubxyz/time#gh-pages/ventures` (`PAGES_WILDCARDS`); gandora.yolopreneur.lol → `gandora/site` (a venture in
|
||||
its own repo: an exact host in `PAGES_SITES` wins over the wildcard, so a venture graduates by adding one line). A site can be any public repo,
|
||||
`*.yolopreneur.lol` → `teahubxyz/time#gh-pages/ventures` (`PAGES_WILDCARDS`). A site can be any public repo,
|
||||
`org/repo#branch/subdir`; it doesn't have to be called `site`. A product with a backend serves its own pages
|
||||
instead (phast.app is `phast-app`), so there is never a copy to drift.
|
||||
|
||||
|
||||
+1
-1
@@ -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","maths.teahub.xyz":"maths/site"}'
|
||||
PAGES_SITES = '{"persimmons.app":"persimmons/app","phly.cloud":"phly/site","supertestnet.lol":"supertestnet/app#pages","yolopreneur.lol":"teahubxyz/time#gh-pages/ventures"}'
|
||||
PAGES_WILDCARDS = '{"yolopreneur.lol":"teahubxyz/time#gh-pages/ventures"}'
|
||||
PAGES_PULL_SECONDS = "300"
|
||||
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
# Teahub plugins
|
||||
|
||||
Each folder is one plugin: a `plugin.json` manifest plus the files it drops into Gitea's custom dir (`$GITEA_CUSTOM`, `/data/gitea` on prod).
|
||||
|
||||
- `slots` — templates appended into Gitea's custom template slots (e.g. `footer`).
|
||||
- `assets` — files served under `/assets/...`.
|
||||
- `config` — plugin settings.
|
||||
|
||||
Install today is manual: back up the slot template, append the plugin's template, copy assets, restart Gitea. An install Action that reads the manifest is next.
|
||||
|
||||
| Plugin | Status |
|
||||
|---|---|
|
||||
| webamp | live on prod |
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"name": "webamp",
|
||||
"title": "Webamp",
|
||||
"version": "0.1.1",
|
||||
"description": "A Winamp-style player on every Teahub page, fed by gandora.",
|
||||
"slots": {
|
||||
"footer": "templates/custom/footer.tmpl"
|
||||
},
|
||||
"assets": ["public/assets/plugins/webamp/webamp-mod.js"],
|
||||
"vendor": {
|
||||
"public/assets/plugins/webamp/webamp.bundle.min.js": "https://registry.npmjs.org/webamp/-/webamp-1.5.0.tgz#package/built/webamp.bundle.min.js"
|
||||
},
|
||||
"config": {
|
||||
"playlist_url": "https://gandora.yolopreneur.lol/playlist.json"
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
// teahub plugin: webamp — a Winamp-style player docked on every Teahub page.
|
||||
// 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 data attribute.
|
||||
const script = document.querySelector('script[src*="plugins/webamp/webamp-mod.js"]');
|
||||
const PLAYLIST_URL = script?.dataset.playlist;
|
||||
const KEY = 'teahub-webamp-open';
|
||||
|
||||
const FALLBACK = [
|
||||
{
|
||||
metaData: { artist: 'DJ Mike Llama', title: "Llama Whippin' Intro" },
|
||||
url: 'https://cdn.jsdelivr.net/gh/captbaritone/webamp@43434d82/mp3/llama-2.91.mp3',
|
||||
duration: 5.322286,
|
||||
},
|
||||
];
|
||||
|
||||
async function loadTracks() {
|
||||
if (!PLAYLIST_URL) return FALLBACK;
|
||||
try {
|
||||
const res = await fetch(PLAYLIST_URL, { mode: 'cors' });
|
||||
if (!res.ok) throw new Error(res.status);
|
||||
const list = await res.json();
|
||||
// gandora format: [{ title, artist, url, duration? }]
|
||||
const tracks = list
|
||||
.filter((t) => t && t.url)
|
||||
.map((t) => ({
|
||||
metaData: { artist: t.artist || 'gandora', title: t.title || 'untitled' },
|
||||
url: t.url,
|
||||
...(t.duration ? { duration: t.duration } : {}),
|
||||
}));
|
||||
return tracks.length ? tracks : FALLBACK;
|
||||
} catch {
|
||||
return FALLBACK;
|
||||
}
|
||||
}
|
||||
|
||||
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 = () => {
|
||||
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) {
|
||||
// Already open (maybe off-screen or hidden): close and reopen so it lands centered in view.
|
||||
webamp.dispose();
|
||||
webamp = null;
|
||||
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:99999',
|
||||
'font:12px/1 monospace', 'padding:6px 10px', 'border-radius:4px',
|
||||
'border:1px solid #1d3', 'background:#000', 'color:#1d3', 'cursor:pointer',
|
||||
].join(';');
|
||||
btn.addEventListener('click', open);
|
||||
document.body.appendChild(btn);
|
||||
|
||||
// Reopen if it was open on the last page.
|
||||
try { if (localStorage.getItem(KEY)) open(); } catch {}
|
||||
@@ -1,2 +0,0 @@
|
||||
{{/* teahub plugin: webamp */}}
|
||||
<script type="module" nonce="{{ctx.CspScriptNonce}}" src="{{AssetUrlPrefix}}/plugins/webamp/webamp-mod.js?v=015"></script>
|
||||
@@ -1,20 +0,0 @@
|
||||
# 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
|
||||
@@ -1,30 +0,0 @@
|
||||
# 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"
|
||||
@@ -1,30 +0,0 @@
|
||||
# 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"
|
||||
Reference in new issue
Block a user