Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a0ee513b9 | ||
|
|
d7ef5861e2 | ||
|
|
2f33c5ec18 | ||
|
|
071186c338 | ||
|
|
2739f29a5e | ||
|
|
70f19fb677 | ||
|
|
60ae76327d | ||
|
|
bec77c1ea6 | ||
|
|
9721b59ea9 | ||
|
|
6c497686da | ||
|
|
33a9e553b2 | ||
|
|
56cebd3e29 | ||
|
|
e8c20ba6b7 |
No files matched your search
@@ -1,62 +0,0 @@
|
||||
name: cf-mint-tunnel
|
||||
# Sibling of cf-mint-token: mints a narrowly scoped Cloudflare API token using the broad CROWD_FAIR
|
||||
# secret — Cloudflare Tunnel write on the account, DNS write on one zone — 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: hb5-tunnels
|
||||
zone:
|
||||
description: zone name the token is scoped to
|
||||
required: true
|
||||
default: teahub.xyz
|
||||
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: ['Cloudflare Tunnel Write', 'Account Settings Read'],
|
||||
zone: ['DNS 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 acct = pick(want.acct);
|
||||
if (!acct.length) { console.log('tunnel-ish groups:', pg.result.filter((x) => /tunnel/i.test(x.name)).map((x) => x.name).join(' | ')); throw new Error('no tunnel permission group found'); }
|
||||
const body = {
|
||||
name: process.env.TNAME,
|
||||
policies: [
|
||||
{ effect: 'allow', resources: { [`com.cloudflare.api.account.${A}`]: '*' }, permission_groups: 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
|
||||
@@ -0,0 +1,73 @@
|
||||
name: gate-take
|
||||
# One of our keys takes the well-known.online gate (the reverse captcha), answered live by Claude:
|
||||
# 1. fetch a fresh challenge for the key; write it to gate/<nonce>.challenge.json on the gate-desk branch
|
||||
# 2. wait (inside the 180 s window) for gate/<nonce>.answers.json, a JSON array of answers, written there by Claude
|
||||
# 3. sign the answers with the key (it never leaves Actions) and POST them to /gate
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
key:
|
||||
description: factory | well-known
|
||||
required: true
|
||||
default: factory
|
||||
jobs:
|
||||
gate:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 6
|
||||
steps:
|
||||
- name: fetch, hand off, sign, submit
|
||||
env:
|
||||
K_FACTORY: ${{ secrets.SNAIL_KNOWN }}
|
||||
K_WK: ${{ secrets.WK_NSEC }}
|
||||
WHICH: ${{ inputs.key }}
|
||||
TOKEN: ${{ gitea.token }}
|
||||
API: https://teahub.xyz/api/v1/repos/teahubxyz/teahub
|
||||
WK: https://well-known.online
|
||||
run: |
|
||||
mkdir -p /tmp/gt && cd /tmp/gt
|
||||
npm init -y >/dev/null && npm i --silent @noble/curves@1.9.7 >/dev/null
|
||||
cat > g.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 = process.env.WHICH === 'well-known' ? process.env.K_WK : process.env.K_FACTORY;
|
||||
if (!raw) { console.error(`no key for ${process.env.WHICH}`); process.exit(1); }
|
||||
const sk = toHex(raw); const pk = Buffer.from(schnorr.getPublicKey(sk)).toString('hex');
|
||||
const H = { authorization: `token ${process.env.TOKEN}`, 'content-type': 'application/json' };
|
||||
const api = (p, o = {}) => fetch(process.env.API + p, { ...o, headers: H });
|
||||
// the desk branch
|
||||
if ((await api('/branches/gate-desk')).status === 404) await api('/branches', { method: 'POST', body: JSON.stringify({ new_branch_name: 'gate-desk', old_branch_name: 'main' }) });
|
||||
// 1. the challenge (the clock starts now)
|
||||
const c = await (await fetch(`${process.env.WK}/gate?pubkey=${pk}`)).json();
|
||||
if (!c.nonce) { console.error('no challenge', JSON.stringify(c)); process.exit(1); }
|
||||
const id = c.nonce.slice(0, 12);
|
||||
const put = await api(`/contents/gate/${id}.challenge.json`, { method: 'POST', body: JSON.stringify({ branch: 'gate-desk',
|
||||
message: `gate challenge ${id} for ${pk.slice(0, 8)}`, content: Buffer.from(JSON.stringify({ pubkey: pk, deadline: c.deadline, need: c.need, count: c.count, items: c.items }, null, 1)).toString('base64') }) });
|
||||
console.log(`challenge ${id} for ${pk.slice(0, 8)} written (${put.status}); deadline ${c.deadline}`);
|
||||
// 2. wait for the answers
|
||||
const until = Date.parse(c.deadline) - 4000; let answers = null;
|
||||
while (Date.now() < until) {
|
||||
const r = await api(`/contents/gate/${id}.answers.json?ref=gate-desk`);
|
||||
if (r.ok) { const j = await r.json(); try { answers = JSON.parse(Buffer.from(j.content, 'base64').toString('utf8')); break; } catch {} }
|
||||
await new Promise((res) => setTimeout(res, 1500));
|
||||
}
|
||||
if (!Array.isArray(answers)) { console.error('no answers in time'); process.exit(1); }
|
||||
// 3. sign and submit
|
||||
const ev = { pubkey: pk, created_at: Math.floor(Date.now() / 1000), kind: 27235, content: JSON.stringify(answers.map(String)),
|
||||
tags: [['u', `${process.env.WK}/gate`], ['method', 'POST'], ['nonce', c.nonce]] };
|
||||
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 fetch(`${process.env.WK}/gate`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ event: ev }) });
|
||||
const body = await res.json().catch(() => ({}));
|
||||
console.log(res.status, JSON.stringify({ passed: body.passed, score: body.score, of: body.of, ms: body.ms, reason: body.reason, wrong: body.wrong, rank: body.rank && body.rank.rung, names: body.names }));
|
||||
if (!body.passed) process.exit(1);
|
||||
EOF
|
||||
node g.mjs
|
||||
@@ -0,0 +1,110 @@
|
||||
name: rank-badges
|
||||
# NIP-58 badges for the well-known.online ladder, run every 15 minutes (and on demand).
|
||||
# 1. Ensures the three definitions (kind 30009, d=not|slightly|very) by the well-known account (WK_NSEC).
|
||||
# 2. Reads https://well-known.online/ranks.json (every ranked key by its current rung). A key holds its rung and
|
||||
# every rung below it: not = all ranked keys, slightly = slightly + very, very = very.
|
||||
# 3. Awards (kind 8) one event per key per rung, only where missing, so repeat runs are no-ops.
|
||||
# 4. Revokes: a key that slipped below a rung, or whose name lapsed (rungs belong to names), gets that award deleted
|
||||
# (NIP-09 kind 5).
|
||||
on:
|
||||
schedule:
|
||||
- cron: '*/15 * * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
republish_definitions:
|
||||
description: re-sign the definitions even if they exist (true/false)
|
||||
required: false
|
||||
default: 'false'
|
||||
jobs:
|
||||
badges:
|
||||
runs-on: cron
|
||||
timeout-minutes: 8
|
||||
steps:
|
||||
- name: define, diff, award, revoke
|
||||
env:
|
||||
K_WK: ${{ secrets.WK_NSEC }}
|
||||
REPUBLISH: ${{ inputs.republish_definitions }}
|
||||
RELAYS: wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net
|
||||
RANKS: https://well-known.online/ranks.json
|
||||
run: |
|
||||
mkdir -p /tmp/rb && cd /tmp/rb
|
||||
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');
|
||||
};
|
||||
if (!process.env.K_WK) { console.error('WK_NSEC missing'); process.exit(1); }
|
||||
const sk = toHex(process.env.K_WK); const WK = { sk, pk: Buffer.from(schnorr.getPublicKey(sk)).toString('hex') };
|
||||
const RELAYS = process.env.RELAYS.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
const now = () => Math.floor(Date.now() / 1000);
|
||||
const sign = (kind, tags, content = '') => {
|
||||
const ev = { pubkey: WK.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, WK.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}`);
|
||||
if (!ok) process.exitCode = 1;
|
||||
return ok;
|
||||
};
|
||||
const RUNGS = ['not', 'slightly', 'very'];
|
||||
const coord = (r) => `30009:${WK.pk}:${r}`;
|
||||
// 1. definitions: just the title
|
||||
for (const r of RUNGS) {
|
||||
const defs = await query({ kinds: [30009], authors: [WK.pk], '#d': [r] });
|
||||
if (defs.length && process.env.REPUBLISH !== 'true') { console.log(`definition ${r}: present`); continue; }
|
||||
const img = `https://well-known.online/badges/${r}.png`;
|
||||
await publish(sign(30009, [['d', r], ['name', `${r}.well-known`],
|
||||
['description', `${r}.well-known: a rung on the well-known.online ladder. https://well-known.online/rank.md`],
|
||||
['image', img, '256x256'], ['thumb', img, '256x256']]), `definition ${r}`);
|
||||
}
|
||||
// 2. who should hold what
|
||||
const res = await fetch(process.env.RANKS);
|
||||
const ranks = res.ok ? await res.json().catch(() => null) : null;
|
||||
if (!ranks || !Array.isArray(ranks.not) || !Array.isArray(ranks.slightly) || !Array.isArray(ranks.very)) {
|
||||
console.error(`ranks directory unreadable (${res.status}); changing nothing`); process.exit(1);
|
||||
}
|
||||
const hex = (a) => (Array.isArray(a) ? a : []).map((k) => String(k).toLowerCase()).filter((k) => /^[0-9a-f]{64}$/.test(k));
|
||||
const want = { not: new Set([...hex(ranks.not), ...hex(ranks.slightly), ...hex(ranks.very)]),
|
||||
slightly: new Set([...hex(ranks.slightly), ...hex(ranks.very)]), very: new Set(hex(ranks.very)) };
|
||||
// 3./4. what's awarded now (ignoring awards we already deleted), then award / revoke
|
||||
const deleted = new Set((await query({ kinds: [5], authors: [WK.pk] })).flatMap((e) => e.tags.filter((t) => t[0] === 'e').map((t) => t[1])));
|
||||
for (const r of RUNGS) {
|
||||
const awards = (await query({ kinds: [8], authors: [WK.pk], '#a': [coord(r)] })).filter((e) => !deleted.has(e.id));
|
||||
const held = new Map(); // pubkey -> [award event ids]
|
||||
for (const e of awards) for (const t of e.tags) if (t[0] === 'p') held.set(t[1], [...(held.get(t[1]) || []), e.id]);
|
||||
const add = [...want[r]].filter((k) => !held.has(k));
|
||||
let drop = [...held.keys()].filter((k) => !want[r].has(k));
|
||||
if (drop.length > 3 && drop.length > held.size / 2) { console.error(`${r}: refusing to revoke ${drop.length} of ${held.size} at once; check ${process.env.RANKS}`); process.exitCode = 1; drop = []; }
|
||||
console.log(`${r}: want ${want[r].size}, held ${held.size}, award ${add.length}, revoke ${drop.length}`);
|
||||
for (const k of add) await publish(sign(8, [['a', coord(r)], ['p', k, RELAYS[0]]]), `award ${r} ${k.slice(0, 8)}`);
|
||||
for (const k of drop) await publish(sign(5, [...held.get(k).map((id) => ['e', id]), ['k', '8']], `slipped below ${r}.well-known`), `revoke ${r} ${k.slice(0, 8)}`);
|
||||
}
|
||||
EOF
|
||||
node b.mjs
|
||||
@@ -0,0 +1,92 @@
|
||||
name: relay-post
|
||||
# Post a note (kind 1) to wss://relay.well-known.online as one of our own names, signing in with NIP-42.
|
||||
# key: factory (SNAIL_KNOWN: uniqornlabz, well-known, get) or well-known (WK_NSEC). Before the note, the key's latest
|
||||
# profile (kind 0) is copied from the public relays onto ours, so the feed can show its name.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
key:
|
||||
description: factory | well-known
|
||||
required: true
|
||||
default: factory
|
||||
content:
|
||||
description: the note to post
|
||||
required: true
|
||||
room:
|
||||
description: optional room (not | slightly | very); posts a kind-9 chat message with an h tag
|
||||
required: false
|
||||
default: ''
|
||||
jobs:
|
||||
post:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: sign in, post
|
||||
env:
|
||||
K_FACTORY: ${{ secrets.SNAIL_KNOWN }}
|
||||
K_WK: ${{ secrets.WK_NSEC }}
|
||||
WHICH: ${{ inputs.key }}
|
||||
CONTENT: ${{ inputs.content }}
|
||||
ROOM: ${{ inputs.room }}
|
||||
RELAY: wss://relay.well-known.online
|
||||
PUBLIC: wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net
|
||||
run: |
|
||||
mkdir -p /tmp/rp && cd /tmp/rp
|
||||
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 = process.env.WHICH === 'well-known' ? process.env.K_WK : process.env.K_FACTORY;
|
||||
if (!raw) { console.error(`no key for ${process.env.WHICH}`); process.exit(1); }
|
||||
const sk = toHex(raw); const pk = Buffer.from(schnorr.getPublicKey(sk)).toString('hex');
|
||||
const sign = (ev) => {
|
||||
ev = { pubkey: pk, created_at: Math.floor(Date.now() / 1000), tags: [], content: '', ...ev };
|
||||
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 ev;
|
||||
};
|
||||
// the latest profile from the public relays (already signed by the key; we just carry it over)
|
||||
const profile = await new Promise((resolve) => {
|
||||
let best = null; let left = 0; const urls = process.env.PUBLIC.split(',');
|
||||
const done = () => { if (--left <= 0) resolve(best); };
|
||||
for (const url of urls) {
|
||||
left++; let ws; const t = setTimeout(() => { try { ws.close(); } catch {} done(); }, 6000);
|
||||
try { ws = new WebSocket(url); } catch { clearTimeout(t); done(); continue; }
|
||||
ws.onopen = () => ws.send(JSON.stringify(['REQ', 'p', { kinds: [0], authors: [pk], limit: 1 }]));
|
||||
ws.onmessage = (m) => { const x = JSON.parse(m.data); if (x[0] === 'EVENT' && (!best || x[2].created_at > best.created_at)) best = x[2]; if (x[0] === 'EOSE') { clearTimeout(t); ws.close(); done(); } };
|
||||
ws.onerror = () => { clearTimeout(t); done(); };
|
||||
}
|
||||
});
|
||||
console.log(`key ${pk.slice(0, 8)}; profile ${profile ? 'found' : 'none'}`);
|
||||
// sign in (NIP-42), then publish
|
||||
const ws = new WebSocket(process.env.RELAY);
|
||||
const pending = new Map(); let authed;
|
||||
const authedP = new Promise((r) => { authed = r; });
|
||||
const send = (ev) => new Promise((resolve) => { pending.set(ev.id, resolve); ws.send(JSON.stringify(['EVENT', ev])); setTimeout(() => resolve([false, 'timeout']), 8000); });
|
||||
ws.onmessage = (m) => {
|
||||
const x = JSON.parse(m.data);
|
||||
if (x[0] === 'AUTH') { const a = sign({ kind: 22242, tags: [['relay', process.env.RELAY], ['challenge', x[1]]] }); pending.set(a.id, (r) => authed(r)); ws.send(JSON.stringify(['AUTH', a])); }
|
||||
if (x[0] === 'OK' && pending.has(x[1])) { pending.get(x[1])([x[2], x[3]]); pending.delete(x[1]); }
|
||||
};
|
||||
await new Promise((r, j) => { ws.onopen = r; ws.onerror = j; });
|
||||
const [ok, msg] = await Promise.race([authedP, new Promise((r) => setTimeout(() => r([false, 'no auth challenge']), 8000))]);
|
||||
console.log('auth:', ok, msg || '');
|
||||
if (!ok) process.exit(1);
|
||||
if (profile) { const [pok, pmsg] = await send(profile); console.log('profile:', pok, pmsg || ''); }
|
||||
const room = (process.env.ROOM || '').trim();
|
||||
const note = room ? sign({ kind: 9, content: process.env.CONTENT, tags: [['h', room]] }) : sign({ kind: 1, content: process.env.CONTENT });
|
||||
const [nok, nmsg] = await send(note);
|
||||
console.log('note:', nok, nmsg || '', note.id);
|
||||
ws.close();
|
||||
if (!nok) process.exit(1);
|
||||
EOF
|
||||
node p.mjs
|
||||
@@ -0,0 +1,114 @@
|
||||
# CLAUDE.md — sessions the owner opened
|
||||
|
||||
Additive to `AGENTS.md`. Where the two conflict, `AGENTS.md` wins.
|
||||
|
||||
If you are a seat worker, Time, a council reviewer or a card runner: `AGENTS.md` governs you, not this
|
||||
file. Read it and stop here.
|
||||
|
||||
If the owner opened you from a chat, a phone, or a scheduled task: you are in the **operator lane**,
|
||||
which `AGENTS.md` does not describe. Read on.
|
||||
|
||||
## Read first
|
||||
- `AGENTS.md` — the factory: cards, seats, Time's rhythm, spend, the bus, the situations table.
|
||||
- `docs/decisions.md` — append-only, dated, 91 entries. Why things are the way they are. Read the last
|
||||
ten before proposing anything structural; most "new" ideas are already in there with a reason.
|
||||
- `SKILLS.md` — skill routes. Find your task's route and load only the skills it names, by path. Do not
|
||||
pick skills yourself.
|
||||
|
||||
## The operator lane
|
||||
Not every session is a card. A session opened to look at something, answer a question, or survey state
|
||||
is not work that belongs on the board: do not open a card for it, do not leave receipts on issues you
|
||||
only read, and do not check in on the bus for a read-only look.
|
||||
|
||||
Check in (`tea_bus_checkin`) the moment you start changing something, and check out when you stop.
|
||||
|
||||
**Default to read-only.** The factory is AI-managed by design, and the board is where that happens. An
|
||||
interactive session is the one place a human is actually watching, so spend it showing rather than
|
||||
acting. Before the first write of a session, say what you are about to change and why.
|
||||
|
||||
Report what you found, including the parts that contradict what you expected. Distinguish what a tool
|
||||
returned from what you reasoned. Name what you could not verify and what call you deliberately did not
|
||||
make.
|
||||
|
||||
## Stop and ask before
|
||||
- Any commit, PR, merge, label or issue close on prod.
|
||||
- Any Fly deploy, restart, machine action, volume change or DNS write.
|
||||
- Anything that spends: a model call booked to the ledger, a purchase, a launch, restarting an agent
|
||||
that bills on wake.
|
||||
- Closing an issue you did not open, however stale it looks.
|
||||
|
||||
Do everything up to the gate, then stop and say what is ready and what is needed. This is the same rule
|
||||
as the money/human-step row in `AGENTS.md`, applied to the whole operator lane.
|
||||
|
||||
## Two wallets
|
||||
The factory's model calls bill an **Anthropic API** balance at `api.anthropic.com`, booked to
|
||||
`time/ledger/<YYYY-MM>.jsonl` against `TIME_MONTHLY_USD=100` with `TIME_DAILY_USD=15` pacing. A Claude
|
||||
Code session spends the owner's **subscription credits**. These are different pools. Topping up one does
|
||||
not top up the other, and a session with plenty of credit tells you nothing about whether Astra can run.
|
||||
|
||||
Observed 2026-10-29: the API balance emptied, `astra.yml` died on a billing 400, Oppi went off, the
|
||||
Moltbook loop no-opped, **no andon opened**, and nobody was told for four hours.
|
||||
|
||||
## Tool order
|
||||
- **Gitea** → Teahub MCP (`tea_*`). Not `gh`, not web-fetching teahub.xyz.
|
||||
- **Fly, Cloudflare, hb-5** → Boring (`boring_*`). `boring_fly_status` over shelling out.
|
||||
- Deterministic script, then cheap inference, then expensive inference. (`AGENTS.md`: be boring by
|
||||
default.)
|
||||
|
||||
## Cloud session facts
|
||||
- The container is **ephemeral** and holds no repo unless one was attached. Nothing in `~/.claude` or
|
||||
`/home/user` survives the session. Commit it or lose it.
|
||||
- **`teahub.xyz` is denied by the cloud network policy** (403 at the proxy tunnel, observed 2026-10-29),
|
||||
so a cloud session cannot clone this repo, run its tests locally, or `git push`. That is the expected
|
||||
shape of the lane, not a fault to work around.
|
||||
- **Commit through the MCP and let CI be the test oracle.** `tea_commit` with `new_branch` writes a
|
||||
branch as you; `tea_pull_create` opens the PR; Gitea Actions runs the tests on the runner, which does
|
||||
have repo access. Do not try to clone first, and do not route a push through hb-5 to save a step.
|
||||
- Outbound HTTPS denies any host off the environment allowlist (`moltbook.com` among them). Do not retry
|
||||
a denied host: say it is blocked, and route that call through the box that can reach it.
|
||||
- `boring_fly_volumes` returns metadata only, no used bytes. Volume headroom needs `df` inside the
|
||||
machine.
|
||||
|
||||
## The cloud lane
|
||||
`time/router.json` carries a `cloud` block (`per_day`, `sizes`). Cards of those sizes are labelled
|
||||
`cloud` and built by Claude Code cloud sessions, paid from the owner's session credit rather than the
|
||||
ledger — so a cloud card's spend never appears in `time/ledger/`, and the envelope does not gate it.
|
||||
If you are a cloud session on a `cloud` card, you are that lane.
|
||||
|
||||
Read the bus before you start. The owner also works this backlog from chat sessions, and two agents on
|
||||
one card is how card branches collide.
|
||||
|
||||
## Situations
|
||||
Additions to the table in `AGENTS.md`.
|
||||
|
||||
| You see | Do |
|
||||
|---|---|
|
||||
| A run failed with `400 ... credit balance is too low` | Not an andon — that path only covers cap breaches, so nothing opened and nobody was told. Tell the owner directly, name the API balance, and stop. Do not retry the run. Do not wake an agent that bills the same balance. |
|
||||
| An agent on the bus says "paused by the owner" | Leave it off. "Not failing, just off" is a decision, not a fault. |
|
||||
| A bus status that went "running" → "failed" and then nothing | A dropped thread. Say how long it has been, and who owns it. Do not silently pick it up mid-session. |
|
||||
| A `needs-human` queue older than three days | Hand the owner a ranked list with ages and the objection on each. Do not merge your way through it. |
|
||||
| A hostname resolving to a suspended Fly machine | Fly suspend can be wake-on-request, so status alone cannot tell broken from scale-to-zero. One request settles it — say which you checked. |
|
||||
| An issue housekeeping marked "done by #N" | A close candidate, not a close. It goes in the list for the owner. |
|
||||
| A card you are about to start | `tea_bus_list` first, every time. If another agent is on it, take a different card. |
|
||||
| A card PR whose diff is empty | Do not rebuild it. Its branch may have been rewound to base — `time#277`: two cards lost their work that way on 2026-10-29, in the same minute the API credit ran out. Report it on the PR and leave the branch alone. |
|
||||
| A planner or judge reply you cannot parse | Queue nothing. A ranking where nothing was judged is noise, not a ranking (`time#211`: 37 candidates all scored 1.911 and three cards were sent to the cloud lane on it). |
|
||||
| You are about to delegate a survey to subagents | Give each one the files to read, not your conclusions, and do not narrow it to the names you already know — a brief that lists the known apps is how `molty-scout` got missed on 2026-10-29. |
|
||||
|
||||
## The map
|
||||
The spine is here; the clock is `teahubxyz/time`. A session cloned into any other repo will not see this
|
||||
file, so the active ones, with what they are:
|
||||
|
||||
| Repo | What |
|
||||
|---|---|
|
||||
| `teahubxyz/teahub` | This repo. The spine: Gitea config, MCP source, decisions, skills. |
|
||||
| `teahubxyz/time` | The clock. Astra (13:13 UTC), heartbeat (hourly), housekeeping (13:17), the seats, the ledger. Engine in `.github/time/`. |
|
||||
| `teahubxyz/oracle` | The spine of the machine economy: capability index, SQLite graph, evidence ladder claimed→reachable→tested→verified→transacted→repeated. `oracle.teahub.xyz`. |
|
||||
| `teahubxyz/sux-desk` | Silicon UX desk. Drafts wait on the owner; nothing sends itself. |
|
||||
| `well-known/app` | Agent identity, inbox, DMs, trust card. Node. **`npm test` must pass before every push**; main deploys via CI. |
|
||||
| `well-known/mail` | Cloudflare Worker; CI deploys. |
|
||||
| `househand/house` | Drawing style as code. Publish = build release + the `teahubxyz/time` seed workflow. |
|
||||
| `phast/app` | Oracle's consumer. |
|
||||
| `teahubxyz/doors`, `teahubxyz/merman`, `maths/*` | Active; read their own repo docs first. |
|
||||
|
||||
hb-5 and the `matcha/*` namespace are **not prod** — the operator's playground and the old factory's
|
||||
shell. Boring lives there and may reach prod as a client; prod never depends on it.
|
||||
@@ -32,6 +32,12 @@ primary_region = "iad"
|
||||
GITEA__mirror__ENABLED = "true"
|
||||
GITEA__actions__ENABLED = "true"
|
||||
GITEA__repository__DISABLE_HTTP_GIT = "false"
|
||||
# Merman (merman.teahub.xyz) reads public workflow files from the browser: that origin only, read-only, no cookies
|
||||
GITEA__cors__ENABLED = "true"
|
||||
GITEA__cors__ALLOW_DOMAIN = "https://merman.teahub.xyz"
|
||||
GITEA__cors__METHODS = "GET,HEAD,OPTIONS"
|
||||
GITEA__cors__ALLOW_CREDENTIALS = "false"
|
||||
GITEA__cors__MAX_AGE = "10m"
|
||||
[mounts]
|
||||
source = "gitea_data"
|
||||
destination = "/data"
|
||||
|
||||
+1
-1
@@ -11,7 +11,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`); open.teahub.xyz → `teahubxyz/open#pages` (the factory openvenice); 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,
|
||||
its own repo: an exact host in `PAGES_SITES` wins over the wildcard, so a venture graduates by adding one line); yapstr.lol (+www) → `yap/site` (the one-button on-device speech-to-text PWA; DNS in Cloudflare, apex CNAME-flattened to green-tea-pages.fly.dev); its old address yap.yolopreneur.lol → `yap/site#main/moved`, a static hand-off (moved page + self-removing service worker) rather than a 301, because installed PWAs can't follow a redirect for their service worker. 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","open.teahub.xyz":"teahubxyz/open#pages","merman.teahub.xyz":"teahubxyz/merman#main/merman","oracle.teahub.xyz":"teahubxyz/oracle#pages"}'
|
||||
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","open.teahub.xyz":"teahubxyz/open#pages","merman.teahub.xyz":"teahubxyz/merman#main/merman","oracle.teahub.xyz":"teahubxyz/oracle#pages","yap.yolopreneur.lol":"yap/site#main/moved","yapstr.lol":"yap/site","www.yapstr.lol":"yap/site","uniqornz.lol":"uniqorn/site","www.uniqornz.lol":"uniqorn/site"}'
|
||||
PAGES_WILDCARDS = '{"yolopreneur.lol":"teahubxyz/time#gh-pages/ventures"}'
|
||||
PAGES_PULL_SECONDS = "300"
|
||||
|
||||
|
||||
Reference in new issue
Block a user