// GENERATED by scripts/gen-static-assets.mjs — DO NOT EDIT.
//
// web/static/ as a module, so the browser scripts survive bundling. lib/static.js reads the filesystem
// first (unchanged for servers) and falls back to this map, which is the only copy that exists inside
// favela-android's worklet bundle. Regenerate with `npm run gen:static`; test/static-assets.test.mjs
// fails if this drifts from web/static/.
export const STATIC_ASSETS = {
"actions.js": "// Responsive like / repost / follow — progressive enhancement over the plain forms.\n//\n// THE PROBLEM. Every one of these is a real <form method=\"POST\"> that redirects back to `return`. That is\n// what makes them work with scripting off, and it stays the fallback — but with JS on it means a FULL PAGE\n// RELOAD to toggle a heart: the timeline is re-fetched and re-rendered, scroll position is lost, and on a\n// slow P2P read the whole page stalls behind one click. The affordance felt broken even though it worked.\n//\n// The server already speaks JSON on these routes (`content-type: application/json` -> {ok, liked,\n// likeCount} / {ok, reposted, repostCount} / {ok, following}) instead of redirecting. Nothing server-side\n// was needed; only this.\n//\n// OPTIMISTIC, THEN RECONCILED. The button flips immediately — a P2P write can take a moment and a control\n// that does nothing for 400ms reads as broken. If the request fails we put the ORIGINAL state back and say\n// so, rather than leaving a lie on screen. The server's returned count is authoritative and overwrites the\n// optimistic guess, so a concurrent like from another device self-corrects on the next click.\n//\n// Delegated on document, so posts swapped in by the live refresh keep working with nothing to re-bind.\nimport { toast } from './toast.js'\n\n// Naming the action in the message matters: \"failed\" on its own does not tell you WHICH of three\n// controls in a row it was, and the button has already reverted by the time you read it.\nconst LABEL = { '/api/like': 'like', '/api/repost': 'repost', '/api/follow': 'follow' }\n\nconst ROUTES = {\n '/api/like': { btn: '.likebtn', on: 'liked', count: 'likeCount', countEl: '.likes', active: '♥', idle: '♡' },\n '/api/repost': { btn: '.rpbtn', on: 'reposted', count: 'repostCount', countEl: '.rps', active: '⇄', idle: '⇄' },\n '/api/follow': { btn: '.followbtn', on: 'following', count: null, countEl: null, active: 'following ✓', idle: 'follow' }\n}\n\nfunction setState (btn, spec, isOn, count) {\n btn.classList.toggle('on', !!isOn)\n if (spec.active !== spec.idle || spec.count === null) btn.textContent = isOn ? spec.active : spec.idle\n btn.title = spec.count === null ? (isOn ? 'unfollow' : 'follow') : (isOn ? 'undo' : 'do')\n btn.setAttribute('aria-pressed', isOn ? 'true' : 'false')\n if (!spec.countEl) return\n // The count lives OUTSIDE the button, as a sibling in the same form. Absent at zero by design, so it has\n // to be created when a count appears and removed when it returns to zero — otherwise a freshly-liked\n // post shows a heart with no number, or a stale \"0\" lingers.\n const form = btn.closest('form')\n if (!form) return\n let el = form.querySelector(spec.countEl)\n const n = Number(count)\n if (!Number.isFinite(n) || n <= 0) { if (el) el.remove(); return }\n if (!el) {\n el = document.createElement('span')\n el.className = spec.countEl.slice(1)\n btn.insertAdjacentElement('afterend', el)\n }\n el.textContent = String(n)\n}\n\ndocument.addEventListener('submit', async (e) => {\n const form = e.target\n if (!form || !form.action) return\n const path = new URL(form.action, location.href).pathname\n const spec = ROUTES[path]\n if (!spec) return\n const btn = form.querySelector(spec.btn)\n if (!btn) return\n\n e.preventDefault()\n const data = new FormData(form)\n const wasOn = btn.classList.contains('on')\n const countEl = spec.countEl ? form.querySelector(spec.countEl) : null\n const wasCount = countEl ? Number(countEl.textContent) || 0 : 0\n\n // Optimistic flip. A P2P write is not instant and the click must feel like it landed.\n setState(btn, spec, !wasOn, wasOn ? wasCount - 1 : wasCount + 1)\n btn.disabled = true\n\n try {\n const body = {}\n for (const [k, v] of data.entries()) body[k] = v\n const res = await fetch(path, {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify(body)\n })\n const out = await res.json().catch(() => null)\n if (!res.ok || !out || out.ok !== true) throw new Error((out && out.error) || ('HTTP ' + res.status))\n // The server's answer wins over the optimistic guess.\n setState(btn, spec, out[spec.on], spec.count ? out[spec.count] : null)\n } catch (err) {\n setState(btn, spec, wasOn, wasCount) // put it back — never leave a lie on screen\n btn.classList.add('failed')\n const why = String((err && err.message) || 'failed')\n btn.title = why\n // ...and SAY it. The flash + title alone meant a failed write was invisible on touch (no hover) and\n // to assistive tech, so a revert read as \"my tap didn't register\" and people tapped again.\n toast(`${LABEL[path] || 'action'} failed — ${why}`)\n setTimeout(() => btn.classList.remove('failed'), 1200)\n } finally {\n btn.disabled = false\n }\n})\n\n// ── lightbox ─────────────────────────────────────────────────────────────────────────────────────\n// Each image is a real <a> to its own bytes, so with no JS a click opens the full-size picture — already\n// better than the inline-only view. With JS we keep the reader on the page instead.\n//\n// It must be a link regardless: the whole post is a stretched anchor to its thread, so an image with no\n// element of its own would open the thread rather than the picture.\nlet lb = null\nfunction closeLb () {\n if (!lb) return\n lb.remove(); lb = null\n document.removeEventListener('keydown', onKey)\n document.body.style.overflow = ''\n}\nfunction onKey (e) { if (e.key === 'Escape') closeLb() }\n\ndocument.addEventListener('click', (e) => {\n const a = e.target.closest && e.target.closest('a.lb')\n if (!a) return\n // Let the browser do its normal thing for new-tab/window intents — never hijack a modified click.\n if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return\n e.preventDefault()\n e.stopPropagation() // do not also trigger the post's whole-entry navigation\n\n closeLb()\n lb = document.createElement('div')\n lb.className = 'lbox'\n lb.setAttribute('role', 'dialog')\n lb.setAttribute('aria-modal', 'true')\n const alt = a.getAttribute('data-alt') || ''\n lb.setAttribute('aria-label', alt || 'image')\n const img = document.createElement('img')\n img.src = a.getAttribute('href')\n img.alt = alt\n lb.appendChild(img)\n // ALT TEXT IS SHOWN, not just carried in the attribute. Someone wrote it to describe the image; a\n // viewer is the one place there is room to actually display it.\n if (alt) {\n const cap = document.createElement('p')\n cap.className = 'lbalt'\n cap.textContent = alt\n lb.appendChild(cap)\n }\n const close = document.createElement('button')\n close.className = 'lbclose'\n close.setAttribute('aria-label', 'close')\n close.textContent = '✕'\n lb.appendChild(close)\n\n // Click anywhere outside the image closes — the expected gesture, and the only one available on touch.\n lb.addEventListener('click', (ev) => { if (ev.target !== img) closeLb() })\n document.body.appendChild(lb)\n document.body.style.overflow = 'hidden' // no scrolling the page behind the overlay\n document.addEventListener('keydown', onKey)\n close.focus()\n})\n",
"atproto-client.js": "// AtprotoClient — secp256k1 / ES256K identity for the browser (the atproto-native \"pgv3 method\").\n//\n// The browser analog of pear-git-v3's anproto-client.js, but for atproto's own key world: secp256k1\n// signing keys and ES256K signed assertions (NOT ed25519/anproto, NOT keet). A browser holds a\n// per-origin SUB-KEY; the DID's #atproto key DELEGATES it (a signed assertion minted once by the\n// keyholder, the hpp); on login the sub-key signs the server's fresh challenge and favela verifies\n// KEYLESSLY against the DID's PLC-published key (atproto-identity/lib/web-auth.js). favela holds no key.\n//\n// Byte-compatible with the SERVER's atproto-identity/lib/auth.js: signAssertion here produces the SAME\n// `<b64url(head)>.<b64url(body)>.<b64url(sig)>` ES256K JWT the server's verifyAssertion consumes. We use\n// the standalone @noble/secp256k1 (vendored, pure-JS, works on plain HTTP) — its compact low-S sigs and\n// compressed pubkeys interoperate exactly with the server's @noble/curves secp256k1 (same author,\n// same RFC6979/low-S). Proven by test/browser-auth-client.test.mjs.\n//\n// Universal (browser / Node / Bare): base64 falls back to Buffer when btoa is absent, so the same file\n// unit-tests under Node. generateSubKey() is browser-only (needs crypto.getRandomValues).\n\nimport * as secp from './vendor/noble-secp256k1.js'\nimport { sha256 } from './vendor/sha256-pure.js'\n\n// ── HMAC-SHA256 from the pure-JS sha256 — wired into noble so its RFC6979 deterministic signer runs\n// SYNC with no crypto.subtle dependency (works on plain HTTP LAN). Mirrors how pgv3's anproto-client\n// wires ed.etc.sha512Sync from a pure sha512. ──\nconst HMAC_BLOCK = 64 // sha256 block size\nfunction hmacSha256 (key, msg) {\n let k = key\n if (k.length > HMAC_BLOCK) k = sha256(k)\n const block = new Uint8Array(HMAC_BLOCK)\n block.set(k)\n const ipad = new Uint8Array(HMAC_BLOCK)\n const opad = new Uint8Array(HMAC_BLOCK)\n for (let i = 0; i < HMAC_BLOCK; i++) { ipad[i] = block[i] ^ 0x36; opad[i] = block[i] ^ 0x5c }\n const inner = sha256(concatBytes(ipad, msg))\n return sha256(concatBytes(opad, inner))\n}\nsecp.etc.hmacSha256Sync = (key, ...msgs) => hmacSha256(key, concatBytes(...msgs))\n\n// ── encoding helpers (universal) ──\nfunction concatBytes (...arrs) {\n let n = 0\n for (const a of arrs) n += a.length\n const out = new Uint8Array(n)\n let o = 0\n for (const a of arrs) { out.set(a, o); o += a.length }\n return out\n}\nfunction utf8 (str) { return new TextEncoder().encode(str) }\nfunction bytesToHex (bytes) { return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('') }\nfunction hexToBytes (hex) {\n const clean = String(hex).toLowerCase().replace(/[^0-9a-f]/g, '')\n const out = new Uint8Array(clean.length >> 1)\n for (let i = 0; i < out.length; i++) out[i] = parseInt(clean.substr(i * 2, 2), 16)\n return out\n}\n// base64url over bytes, matching the server's b64url (base64 → +/ to -_, strip padding).\nfunction b64url (bytes) {\n let b64\n if (typeof btoa !== 'undefined') {\n let bin = ''\n for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i])\n b64 = btoa(bin)\n } else {\n b64 = Buffer.from(bytes).toString('base64')\n }\n return b64.replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')\n}\n\n// Generate a fresh browser sub-key (secp256k1). Browser-only: randomPrivateKey → crypto.getRandomValues.\n// Returns { privHex, subKey } where subKey is the 33-byte COMPRESSED pubkey hex (what delegate() names).\nexport function generateSubKey () {\n const priv = secp.utils.randomPrivateKey()\n const pub = secp.getPublicKey(priv, true) // compressed\n return { privHex: bytesToHex(priv), subKey: bytesToHex(pub) }\n}\n\n// The compressed-pubkey hex for a private key (used to re-derive subKey from a stored priv).\nexport function subKeyOf (privHex) { return bytesToHex(secp.getPublicKey(hexToBytes(privHex), true)) }\n\n// Sign an ES256K assertion — byte-for-byte the server's atproto-identity signAssertion shape:\n// head = b64url(utf8(JSON({alg:'ES256K',typ:'JWT'}))), body = b64url(utf8(JSON(payload))),\n// sig = low-S compact secp256k1 over sha256(utf8(head.body)); token = head.body.sig.\nexport function signAssertion (payload, privHex) {\n const head = b64url(utf8(JSON.stringify({ alg: 'ES256K', typ: 'JWT' })))\n const body = b64url(utf8(JSON.stringify(payload)))\n const signingInput = `${head}.${body}`\n const digest = sha256(utf8(signingInput))\n const sig = secp.sign(digest, hexToBytes(privHex)).toCompactRawBytes()\n return `${signingInput}.${b64url(sig)}`\n}\n\nexport { bytesToHex, hexToBytes }\nexport default { generateSubKey, subKeyOf, signAssertion }\n",
"browser-identity.js": "// AtprotoIdentity — the browser sign-in client for favela (the atproto-native \"pgv3 method\").\n//\n// Mirrors pear-git-v3's browser-identity.js, adapted from ed25519/TOFU to atproto's DELEGATED model:\n// - A per-origin secp256k1 SUB-KEY lives in localStorage; its secret NEVER leaves the browser.\n// - favela is NOT TOFU (unlike pgv3): the sub-key is powerless until the DID's #atproto key\n// DELEGATES it. That delegation is minted by the KEYHOLDER (the hpp `delegate` RPC) out-of-band —\n// the browser is \"paired\" to a DID once by storing { did, delegation }, then logs in repeatedly\n// until the delegation expires (default 30d). This is the atproto analog of pgv3 registering a\n// device under the root identity.\n// - Login: GET /api/auth/challenge → the sub-key signs it (ES256K) → POST /api/auth/login. The\n// server sets an HttpOnly session cookie (favela_session), so SSR navigation is authenticated.\n// favela verifies keylessly against the DID's PLC-published key; it holds no key of ours.\n//\n// The pairing (obtaining a delegation) is deliberately out-of-band for now: on your own machine, where\n// your hpp holds the DID key, run its `delegate` RPC for THIS browser's sub-key and paste the result.\n// A one-click in-browser \"authorize this device\" needs the hpp reachable from the browser — deferred.\n\nimport { generateSubKey, subKeyOf, signAssertion } from './atproto-client.js'\n\nconst SUBKEY_KEY = 'favela-subkey' // { privHex, subKey } — the browser's secp256k1 sub-key\nconst PAIRING_KEY = 'favela-pairing' // { did, delegation, handle? } — the DID that delegated it\n\nexport class AtprotoIdentity {\n constructor (opts = {}) {\n this._baseUrl = opts.baseUrl || ''\n this._subkey = null\n }\n\n // Get or generate the persistent per-origin sub-key. Secret stays in localStorage.\n getSubKey () {\n if (this._subkey) return this._subkey\n if (typeof localStorage !== 'undefined') {\n const raw = localStorage.getItem(SUBKEY_KEY)\n if (raw) {\n try {\n const kp = JSON.parse(raw)\n if (kp && kp.privHex) {\n // Re-derive subKey from the secret so a hand-edited/legacy record can't desync pub↔priv.\n this._subkey = { privHex: kp.privHex, subKey: subKeyOf(kp.privHex) }\n return this._subkey\n }\n } catch {}\n }\n }\n this._subkey = generateSubKey()\n if (typeof localStorage !== 'undefined') localStorage.setItem(SUBKEY_KEY, JSON.stringify(this._subkey))\n return this._subkey\n }\n\n // The compressed sub-key pubkey hex — this is what you feed to the hpp `delegate` RPC.\n subKeyHex () { return this.getSubKey().subKey }\n\n // The stored pairing { did, delegation, handle? }, or null if this browser isn't paired yet.\n getPairing () {\n if (typeof localStorage === 'undefined') return null\n try { return JSON.parse(localStorage.getItem(PAIRING_KEY) || 'null') } catch { return null }\n }\n\n isPaired () { const p = this.getPairing(); return !!(p && p.did && p.delegation) }\n\n // Pair this browser to a DID by storing the hpp-minted delegation. Light client-side sanity only —\n // the real cryptographic check (the delegation names THIS sub-key and is signed by the DID's PLC key)\n // happens server-side on every login, so a bad paste simply fails login, it never grants access.\n pair ({ did, delegation, handle } = {}) {\n if (!did || !/^did:/.test(String(did))) throw new Error('a valid did: is required')\n if (!delegation || String(delegation).split('.').length !== 3) throw new Error('delegation must be an ES256K assertion (head.body.sig)')\n const rec = { did: String(did), delegation: String(delegation), handle: handle ? String(handle) : null }\n if (typeof localStorage !== 'undefined') localStorage.setItem(PAIRING_KEY, JSON.stringify(rec))\n return rec\n }\n\n // Ensure a live favela session. Returns { did, handle } on success. Throws if not paired or if the\n // server rejects the proof (expired/revoked delegation, bad sub-key, PLC unreachable). Cookies are\n // HttpOnly so we ask the server \"who am I?\" via /api/auth/me rather than reading document.cookie.\n async signIn () {\n const pairing = this.getPairing()\n if (!pairing || !pairing.did || !pairing.delegation) throw new Error('this browser is not paired to a DID yet')\n const { challenge } = await this._fetch('/api/auth/challenge')\n if (!challenge) throw new Error('challenge missing')\n const { privHex, subKey } = this.getSubKey()\n const nowSec = Math.floor(Date.now() / 1000)\n const sig = signAssertion({ challenge, iat: nowSec, exp: nowSec + 300 }, privHex) // 5-min proof\n const out = await this._fetch('/api/auth/login', {\n method: 'POST',\n body: { did: pairing.did, subKey, challenge, sig, delegation: pairing.delegation, handle: pairing.handle || undefined }\n })\n return { did: out.did, handle: out.handle || pairing.handle || null }\n }\n\n // hpp-INITIATED pairing (the fix for the backwards flow): paste the ONE hash-URL invite the hpp minted\n // (via `favela-pair`). The browser generates a sub-key + signs a challenge (proving it holds the secret);\n // favela-web joins the invite's blind-pairing tunnel, has the hpp delegate the sub-key, and mints the\n // session — no manual delegation shuttle. Accepts the raw invite hex OR a `…/login#pair=<invite>` URL.\n // Returns { did, handle } on success.\n async pairWithInvite (invite) {\n const inv = String(invite || '').trim().replace(/^.*[#?&]pair=/, '')\n if (!/^[0-9a-f]{20,}$/i.test(inv)) throw new Error('paste the pairing invite the hpp printed (favela-pair)')\n const { challenge } = await this._fetch('/api/auth/challenge')\n if (!challenge) throw new Error('challenge missing')\n const { privHex, subKey } = this.getSubKey()\n const nowSec = Math.floor(Date.now() / 1000)\n const sig = signAssertion({ challenge, iat: nowSec, exp: nowSec + 300 }, privHex) // 5-min proof\n const out = await this._fetch('/api/auth/pair', { method: 'POST', body: { invite: inv, subKey, challenge, sig } })\n // PERSIST the delegation locally (localStorage favela-pairing) so isPaired() stays true across a\n // favela-web restart — the browser then re-signs-in SILENTLY with the stored delegation (signIn),\n // instead of falling back to the paste-invite screen. This is the pgv3-parity persistence fix.\n if (out.delegation) this.pair({ did: out.did, delegation: out.delegation, handle: out.handle })\n return { did: out.did, handle: out.handle || null }\n }\n\n // Current session identity per the server (reads the HttpOnly cookie), or null.\n async whoami () {\n try { const me = await this._fetch('/api/auth/me'); return me && me.did ? me : null } catch { return null }\n }\n\n async logout () {\n try { await this._fetch('/api/auth/logout', { method: 'POST' }) } catch {}\n }\n\n // Forget the pairing (unpair this browser from the DID). The sub-key is kept so re-pairing the SAME\n // sub-key can reuse an existing delegation; call forgetSubKey() to rotate the key entirely.\n unpair () { if (typeof localStorage !== 'undefined') localStorage.removeItem(PAIRING_KEY) }\n forgetSubKey () { if (typeof localStorage !== 'undefined') localStorage.removeItem(SUBKEY_KEY); this._subkey = null }\n\n async _fetch (path, opts = {}) {\n const init = { headers: { Accept: 'application/json' }, credentials: 'same-origin' }\n if (opts.method) init.method = opts.method\n if (opts.body) {\n init.method = init.method || 'POST'\n init.headers['Content-Type'] = 'application/json'\n init.body = JSON.stringify(opts.body)\n }\n const res = await fetch(this._baseUrl + path, init)\n if (!res.ok) {\n let msg = ''\n try { const j = await res.json(); msg = j.error || '' } catch { try { msg = await res.text() } catch {} }\n throw new Error(msg || `${res.status}`)\n }\n return res.json()\n }\n}\n\nexport default AtprotoIdentity\n",
"compose.js": "// Composer image attachments — progressive enhancement.\n//\n// The composer is a plain <form method=POST action=/api/post> that works with NO JS. This script only\n// ADDS the ability to attach images: it reveals the picker, uploads each file to /api/upload (which\n// returns an atproto blob ref), and — only when there is at least one image — submits the post as JSON\n// so the refs can travel. With no images selected the native form submit is left completely alone, so\n// the no-JS path is never regressed.\n//\n// bsky caps app.bsky.embed.images at 4 and REJECTS a longer array outright, so the cap is enforced here\n// too: a record the relay refuses would federate as nothing while looking fine locally.\n\nimport { MAX_BYTES, resizePlan, aspectRatioOf } from './resize-plan.js'\n\nconst MAX_IMAGES = 4\n\n// Shrink a picture to something the lexicon accepts, and report the dimensions it ended up with.\n//\n// ⛔ REFUSING WAS THE BUG. This used to alert \"the limit is 1024 KB\" and skip the file — half the real\n// ceiling, and hopeless once the phone's composer could open the camera on 2026-08-18, because a phone\n// camera JPEG is 3–12 MB. Every photo taken through favela bounced. The fix is not a bigger number; it\n// is doing what every other client does and resizing the picture.\n//\n// ⚠ Re-encodes to JPEG, so transparency is lost on a PNG large enough to need shrinking. Accepted: the\n// byte ceiling is a hard protocol limit and a >2 MB PNG is a photo or a screenshot, not an icon. Images\n// that already fit are passed through UNTOUCHED, so nothing small is ever re-encoded.\nasync function prepare (file) {\n const bitmap = await createImageBitmap(file)\n const { width, height } = bitmap\n const plan = resizePlan({ width, height, bytes: file.size, type: file.type })\n if (!plan.needed) { bitmap.close(); return { blob: file, width, height } }\n\n const canvas = document.createElement('canvas')\n canvas.width = plan.width\n canvas.height = plan.height\n canvas.getContext('2d').drawImage(bitmap, 0, 0, plan.width, plan.height)\n bitmap.close()\n\n // Step the quality down only as far as needed. Stops at the first size that fits, so a picture barely\n // over the line keeps almost all of its quality.\n let out = null\n for (const q of [0.9, 0.82, 0.72, 0.6, 0.45]) {\n out = await new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', q))\n if (out && out.size <= MAX_BYTES) break\n }\n return { blob: out || file, width: plan.width, height: plan.height }\n}\n\nconst form = document.querySelector('form.composer')\nif (form) {\n const controls = document.getElementById('img-controls')\n const picker = document.getElementById('post-image')\n const preview = document.getElementById('img-preview')\n const attached = [] // { blob, alt, name, url }\n\n if (controls) controls.hidden = false // only shown once JS is confirmed present\n\n const draw = () => {\n if (!preview) return\n preview.innerHTML = ''\n attached.forEach((a, i) => {\n const wrap = document.createElement('span')\n wrap.className = 'thumb'\n const im = document.createElement('img')\n im.src = a.url; im.alt = a.name\n const alt = document.createElement('input')\n alt.type = 'text'; alt.placeholder = 'alt text (describe the image)'; alt.value = a.alt || ''\n alt.addEventListener('input', () => { a.alt = alt.value })\n const rm = document.createElement('button')\n rm.type = 'button'; rm.textContent = '✕'; rm.title = 'remove'\n rm.addEventListener('click', () => { URL.revokeObjectURL(a.url); attached.splice(i, 1); draw() })\n wrap.append(im, alt, rm)\n preview.append(wrap)\n })\n }\n\n if (picker) {\n picker.addEventListener('change', async () => {\n const files = [...(picker.files || [])]\n picker.value = ''\n for (const f of files) {\n if (attached.length >= MAX_IMAGES) { alert(`bsky allows at most ${MAX_IMAGES} images per post`); break }\n try {\n // Resize BEFORE the size check, so the check is a last resort rather than the policy.\n let up = f\n let ratio = null\n try {\n const prepared = await prepare(f)\n up = prepared.blob\n ratio = aspectRatioOf(prepared.width, prepared.height)\n } catch (e) {\n alert(`could not read ${f.name}: ${e.message}`)\n continue\n }\n // Only reachable if even the lowest quality could not fit — say so honestly rather than\n // blaming the file for being large.\n if (up.size > MAX_BYTES) {\n alert(`${f.name} is still ${Math.round(up.size / 1024)} KB after resizing — the limit is ${Math.round(MAX_BYTES / 1024)} KB`)\n continue\n }\n const res = await fetch('/api/upload', { method: 'POST', headers: { 'content-type': up.type || f.type || 'application/octet-stream' }, body: up })\n const body = await res.json().catch(() => ({}))\n if (!res.ok) { alert(body.error || `upload failed (${res.status})`); continue }\n // aspectRatio is what bsky lays the image out with. writer.js has always accepted it\n // (`images = [{ blob, alt?, aspectRatio? }]`) and this client never sent it, so every favela\n // image has been laid out from a guess.\n attached.push({ blob: body.blob, alt: '', name: f.name, url: URL.createObjectURL(up), aspectRatio: ratio })\n draw()\n } catch (e) { alert('upload failed: ' + e.message) }\n }\n })\n }\n\n form.addEventListener('submit', async (ev) => {\n if (!attached.length) return // no images -> leave the native no-JS form submit untouched\n ev.preventDefault()\n const text = form.querySelector('textarea[name=text]').value\n const scope = form.querySelector('select[name=scope]').value\n try {\n const res = await fetch('/api/post', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ text, scope, images: attached.map((a) => ({ blob: a.blob, alt: a.alt, ...(a.aspectRatio ? { aspectRatio: a.aspectRatio } : {}) })) })\n })\n const body = await res.json().catch(() => ({}))\n if (!res.ok) { alert(body.error || `post failed (${res.status})`); return }\n attached.forEach((a) => URL.revokeObjectURL(a.url))\n attached.length = 0\n window.location.href = '/' // same destination the no-JS form redirect lands on\n } catch (e) { alert('post failed: ' + e.message) }\n })\n}\n\n// ── character budget ─────────────────────────────────────────────────────────────────────────────\n//\n// atproto caps a post at 300 GRAPHEMES, not 300 JS string units. Without a counter the limit is\n// invisible until the PDS rejects the write — after the user has typed the whole thing. Counting in\n// graphemes matters: an emoji or a family sequence is one character to a reader and several UTF-16\n// code units to `.length`, so a naive count tells people they are over when they are not.\nconst ta = document.querySelector('.composer textarea[name=\"text\"]')\nconst cc = document.getElementById('charcount')\nconst postBtn = document.getElementById('post-btn')\nconst LIMIT = 300\n\nconst seg = (typeof Intl !== 'undefined' && Intl.Segmenter) ? new Intl.Segmenter(undefined, { granularity: 'grapheme' }) : null\n// [...str] at least splits on code POINTS (so astral characters count once) — still wrong for combining\n// sequences, but never wrong in the direction that blocks a legal post.\nfunction graphemes (s) { return seg ? [...seg.segment(s)].length : [...String(s)].length }\n\nfunction updateCount () {\n if (!ta || !cc) return\n const n = graphemes(ta.value)\n const left = LIMIT - n\n // Stay quiet until it is nearly relevant — a counter that is always shouting is ignored.\n cc.textContent = left > 50 ? '' : String(left)\n cc.classList.toggle('over', left < 0)\n if (postBtn) postBtn.disabled = left < 0 || ta.value.trim().length === 0\n}\n\nif (ta) {\n ta.addEventListener('input', updateCount)\n updateCount()\n // Guard the no-JS-shaped submit path too: with the button disabled this is belt-and-braces, but a\n // form can still be submitted with Enter from some controls.\n const form = ta.closest('form')\n if (form) form.addEventListener('submit', (e) => {\n if (graphemes(ta.value) > LIMIT) { e.preventDefault(); updateCount() }\n })\n}\n",
"draft.js": "// Draft recovery — keep what you typed when a write fails.\n//\n// THE PROBLEM. A post goes through hpp over P2P, and that can fail: the daemon is restarting, the peer\n// is unreachable, the record cannot be signed yet. When it did, the browser navigated to a redirect and\n// the text was simply GONE. Losing someone's words because the network blinked is the worst small bug a\n// client can have, and it is entirely avoidable.\n//\n// Deliberately dumb: the value is mirrored to localStorage as you type, restored on load, and cleared\n// only once a post has actually SUCCEEDED. Nothing is scheduled, nothing is synced, and if scripting is\n// off the composer behaves exactly as it did before.\nconst KEY = 'favela.draft'\n\nfunction fields () {\n // The main composer and the reply box are the same shape; both are worth protecting, keyed apart so a\n // half-written reply does not reappear in the timeline composer.\n return [...document.querySelectorAll('form.composer textarea[name=\"text\"], form.replyf textarea[name=\"text\"]')]\n}\nconst keyFor = (ta) => KEY + ':' + (ta.closest('form.replyf') ? 'reply:' + location.pathname : 'composer')\n\nfor (const ta of fields()) {\n const k = keyFor(ta)\n // Restore first — but never clobber something the server already put there (a quote preview, an edit).\n if (!ta.value) {\n let saved = null\n try { saved = localStorage.getItem(k) } catch {}\n if (saved) {\n ta.value = saved\n const note = document.createElement('p')\n note.className = 'draftnote'\n note.textContent = 'restored an unsent draft'\n const clear = document.createElement('button')\n clear.type = 'button'\n clear.className = 'devbtn'\n clear.textContent = 'discard'\n clear.addEventListener('click', () => {\n ta.value = ''\n try { localStorage.removeItem(k) } catch {}\n note.remove()\n })\n note.appendChild(document.createTextNode(' '))\n note.appendChild(clear)\n ta.insertAdjacentElement('beforebegin', note)\n }\n }\n ta.addEventListener('input', () => {\n try { if (ta.value) localStorage.setItem(k, ta.value); else localStorage.removeItem(k) } catch {}\n })\n // Clear ONLY on a real success. A plain form navigates away on submit, so we cannot see the outcome\n // here — instead the server sets ?posted=1 on the redirect, and that is the signal. Clearing on submit\n // would throw the text away in exactly the case this exists for: the write that failed.\n const form = ta.closest('form')\n if (form) form.addEventListener('submit', () => { try { sessionStorage.setItem(KEY + '.pending', k) } catch {} })\n}\n\n// On load: if we arrived from a successful post, drop the draft it came from.\ntry {\n const params = new URLSearchParams(location.search)\n const pending = sessionStorage.getItem(KEY + '.pending')\n if (params.get('posted') === '1' && pending) {\n localStorage.removeItem(pending)\n sessionStorage.removeItem(KEY + '.pending')\n }\n} catch {}\n",
"login.js": "// favela /login controller — drives the DID web sign-in DOM against the live /api/auth/* routes.\n// Loaded as an ES module by the SSR /login page. All crypto is client-side (the sub-key secret never\n// leaves the browser); this file is only glue between the AtprotoIdentity client and the page elements.\nimport { AtprotoIdentity } from './browser-identity.js'\n\nconst id = new AtprotoIdentity()\nlet autoSignInTried = false // pgv3-parity: attempt a silent re-sign-in once per page load\nconst $ = (sel) => document.querySelector(sel)\nconst show = (el, on) => { if (el) el.style.display = on ? '' : 'none' }\nfunction status (msg, kind) {\n const el = $('#status')\n if (!el) return\n el.textContent = msg || ''\n el.className = kind || ''\n}\n\nasync function render () {\n const subKey = id.subKeyHex()\n const skEl = $('#subkey')\n if (skEl) skEl.textContent = subKey\n\n let session = await id.whoami()\n const pairing = id.getPairing()\n\n // pgv3-parity: hold a delegation but no live session (e.g. favela-web restarted + cleared its\n // in-memory sessions)? Silently re-sign-in with the stored delegation — never fall back to\n // paste-invite. Try once per load; on failure (expired/revoked delegation) fall through to the\n // manual sign-in / pair boxes below.\n if (!session && id.isPaired() && !autoSignInTried) {\n autoSignInTried = true\n try { await id.signIn(); session = await id.whoami() } catch {}\n }\n\n // Signed in → show the session + a link home.\n show($('#session-box'), !!session)\n if (session) {\n const who = $('#session-who')\n if (who) who.textContent = session.handle ? `@${session.handle}` : session.did\n }\n\n // Paired (delegation stored) but not signed in → offer sign-in.\n show($('#signin-box'), !session && id.isPaired())\n if (!session && pairing) {\n const p = $('#paired-did')\n if (p) p.textContent = pairing.handle ? `@${pairing.handle} (${pairing.did})` : pairing.did\n }\n\n // Not paired → show the pairing instructions + form.\n show($('#pair-box'), !session && !id.isPaired())\n}\n\n// Pair: paste the ONE hash-URL invite the hpp minted (favela-pair). favela-web joins the tunnel, has the\n// hpp delegate this browser's sub-key, and signs us in — no manual delegation shuttle.\nasync function onPair (e) {\n e.preventDefault()\n const field = ($('#pair-invite') && $('#pair-invite').value.trim()) || ''\n const invite = field || (location.hash || '').replace(/^.*[#?&]pair=/, '')\n try {\n status('Pairing over the tunnel (this can take a few seconds)…')\n const s = await id.pairWithInvite(invite)\n status('Signed in as ' + (s.handle ? '@' + s.handle : s.did) + '.', 'ok')\n await render()\n setTimeout(() => { window.location.href = '/' }, 600)\n } catch (err) {\n status(err.message || 'pairing failed', 'err')\n await render()\n }\n}\n\nasync function onSignIn () {\n status('Signing in…')\n try {\n await id.signIn()\n status('Signed in.', 'ok')\n setTimeout(() => { window.location.href = '/' }, 400)\n } catch (err) {\n status(err.message || 'sign-in failed', 'err')\n // A stale/expired delegation can't be recovered by retrying — surface the unpair option.\n show($('#pair-box'), true)\n }\n}\n\nasync function onLogout () {\n await id.logout()\n status('Signed out.')\n await render()\n}\n\nasync function onUnpair () {\n id.unpair()\n status('Unpaired this browser.')\n await render()\n}\n\nasync function onCopy () {\n try { await navigator.clipboard.writeText(id.subKeyHex()); status('Sub-key copied.', 'ok') } catch { status('Copy failed — select it manually.', 'err') }\n}\n\nwindow.addEventListener('DOMContentLoaded', () => {\n // Prefill the invite from a `…/login#pair=<invite>` link the hpp printed.\n const fromHash = (location.hash || '').replace(/^.*[#?&]pair=/, '')\n const inviteField = $('#pair-invite'); if (inviteField && fromHash && fromHash !== location.hash) inviteField.value = fromHash\n const pairForm = $('#pair-form'); if (pairForm) pairForm.addEventListener('submit', onPair)\n const signinBtn = $('#signin-btn'); if (signinBtn) signinBtn.addEventListener('click', onSignIn)\n const logoutBtn = $('#logout-btn'); if (logoutBtn) logoutBtn.addEventListener('click', onLogout)\n const unpairBtn = $('#unpair-btn'); if (unpairBtn) unpairBtn.addEventListener('click', onUnpair)\n const copyBtn = $('#copy-subkey'); if (copyBtn) copyBtn.addEventListener('click', onCopy)\n render().catch((e) => status(e.message, 'err'))\n})\n",
"notifications.js": "// Notifications — fetched BY THE BROWSER, never by the server.\n//\n// WHY IT LIVES HERE AND NOT ON THE SERVER. hpp's get-service-auth has two gates, and the second is\n// `sessions.verifyAccess(token)` — a bearer from a real login. Its comment says why in as many words:\n// without it \"an allowlisted bridge could mint tokens for anyone at any time with nobody present\". A\n// minted token authenticates AS the DID, signed by the same #atproto key that signs every repo commit,\n// so it is a signing oracle and it is right that it needs a present user.\n//\n// That leaves the question of where the bearer lives. Putting it on favela-web would mean the server\n// holds a credential for you, which is exactly what \"favela never holds anything of yours\" is supposed\n// to rule out. So the BROWSER holds it — the same place that already holds your delegated sub-key\n// secret — and favela-web stays keyless and credential-free. It renders the shell and nothing else;\n// every authenticated byte here moves between your browser and the PDS.\n//\n// ⚠ THE PASSWORD IS NEVER STORED. It is used once, in memory, to mint a session, and then dropped. Only\n// the JWTs persist, and they expire. An app password is also revocable independently of your account\n// password, which is the point of app passwords.\n//\n// This is the one page in favela that requires JavaScript. Notifications are inherently authenticated\n// and per-viewer; there is no meaningful server-rendered version when the server deliberately holds no\n// credential. Everything else still works with scripting off.\n\nconst PDS = document.querySelector('meta[name=\"favela-pds\"]')?.content || 'https://pds.7rnx.net'\nconst KEY = 'favela.notif.session'\n\nconst $ = (id) => document.getElementById(id)\nconst out = $('notif-list')\nconst status = $('notif-status')\nconst form = $('notif-login')\n\nfunction say (msg, cls) { if (status) { status.textContent = msg; status.className = cls || '' } }\n\nfunction loadSession () {\n try { return JSON.parse(localStorage.getItem(KEY) || 'null') } catch { return null }\n}\nfunction saveSession (s) {\n try { localStorage.setItem(KEY, JSON.stringify(s)) } catch { /* private mode — session lasts this page */ }\n}\nfunction clearSession () {\n try { localStorage.removeItem(KEY) } catch {}\n}\n\nasync function xrpc (nsid, { method = 'GET', body = null, bearer = null, params = null } = {}) {\n const url = new URL(`${PDS.replace(/\\/$/, '')}/xrpc/${nsid}`)\n if (params) for (const [k, v] of Object.entries(params)) url.searchParams.set(k, String(v))\n const headers = {}\n if (body) headers['content-type'] = 'application/json'\n if (bearer) headers.authorization = `Bearer ${bearer}`\n const res = await fetch(url, { method, headers, body: body ? JSON.stringify(body) : undefined })\n const text = await res.text()\n let json = null\n try { json = text ? JSON.parse(text) : null } catch { /* non-JSON error body */ }\n if (!res.ok) throw Object.assign(new Error((json && (json.message || json.error)) || `HTTP ${res.status}`), { status: res.status })\n return json\n}\n\n// A refresh token is used with the REFRESH token as bearer, not the access one — a detail that silently\n// fails as a 401 loop if you get it wrong.\nasync function refresh (session) {\n const r = await xrpc('com.atproto.server.refreshSession', { method: 'POST', bearer: session.refreshJwt })\n const next = { ...session, accessJwt: r.accessJwt, refreshJwt: r.refreshJwt }\n saveSession(next)\n return next\n}\n\nconst REASON = {\n like: 'liked your post', repost: 'reposted your post', follow: 'followed you',\n mention: 'mentioned you', reply: 'replied to you', quote: 'quoted your post',\n 'starterpack-joined': 'joined via your starter pack'\n}\n\nfunction rowFor (n) {\n const el = document.createElement('article')\n el.className = 'notif' + (n.isRead ? '' : ' unread')\n const who = (n.author && (n.author.displayName || n.author.handle)) || (n.author && n.author.did) || 'someone'\n const av = document.createElement('span')\n if (n.author && typeof n.author.avatar === 'string' && /^https:\\/\\//i.test(n.author.avatar)) {\n const img = document.createElement('img')\n img.className = 'pfp'; img.src = n.author.avatar; img.alt = ''; img.width = 36; img.height = 36\n img.referrerPolicy = 'no-referrer'\n av.replaceWith(img); el.appendChild(img)\n } else {\n av.className = 'pfp none'; av.setAttribute('aria-hidden', 'true'); el.appendChild(av)\n }\n const txt = document.createElement('div')\n txt.className = 'ntext'\n const strong = document.createElement('strong')\n strong.textContent = who\n txt.appendChild(strong)\n // textContent throughout: these are remote strings and must never be parsed as markup.\n txt.appendChild(document.createTextNode(' ' + (REASON[n.reason] || n.reason)))\n if (n.record && typeof n.record.text === 'string' && n.record.text) {\n const q = document.createElement('div')\n q.className = 'nquote'\n q.textContent = n.record.text.slice(0, 180)\n txt.appendChild(q)\n }\n const meta = document.createElement('div')\n meta.className = 'meta'\n if (n.indexedAt) {\n const t = document.createElement('time')\n t.dateTime = n.indexedAt\n t.textContent = new Date(n.indexedAt).toLocaleString()\n meta.appendChild(t)\n }\n // Link to OUR view of the subject where we can address it — the uri identifies the record, and favela\n // renders it if this node holds it.\n const subject = n.reasonSubject || n.uri\n const m = /^at:\\/\\/(did:[^/]+)\\/[^/]+\\/(.+)$/.exec(String(subject || ''))\n if (m) {\n const a = document.createElement('a')\n a.href = `/post/${encodeURIComponent(m[1])}/${encodeURIComponent(m[2])}`\n a.textContent = ' open here'\n meta.appendChild(a)\n }\n txt.appendChild(meta)\n el.appendChild(txt)\n return el\n}\n\nasync function load (session) {\n say('loading…')\n let s = session\n let body\n try {\n body = await xrpc('app.bsky.notification.listNotifications', { bearer: s.accessJwt, params: { limit: 40 } })\n } catch (e) {\n if (e.status !== 401) throw e\n // One refresh attempt, then give up rather than loop.\n s = await refresh(s)\n body = await xrpc('app.bsky.notification.listNotifications', { bearer: s.accessJwt, params: { limit: 40 } })\n }\n const list = (body && body.notifications) || []\n out.innerHTML = ''\n if (!list.length) { say('nothing new', ''); return }\n for (const n of list) out.appendChild(rowFor(n))\n const unread = list.filter((n) => !n.isRead).length\n say(`${list.length} notification${list.length === 1 ? '' : 's'}${unread ? ` · ${unread} unread` : ''}`, 'ok')\n}\n\nasync function boot () {\n const s = loadSession()\n if (!s || !s.accessJwt) { if (form) form.hidden = false; say('sign in to load notifications'); return }\n if (form) form.hidden = true\n try { await load(s) } catch (e) {\n // A dead session is not an error to shout about — offer the form again.\n clearSession()\n if (form) form.hidden = false\n say((e && e.message) || 'could not load notifications', 'err')\n }\n}\n\nif (form) {\n form.addEventListener('submit', async (e) => {\n e.preventDefault()\n const identifier = $('notif-handle').value.trim()\n const password = $('notif-pass').value\n if (!identifier || !password) { say('handle and app password required', 'err'); return }\n say('signing in…')\n try {\n const r = await xrpc('com.atproto.server.createSession', { method: 'POST', body: { identifier, password } })\n // Only the JWTs are kept. The password is not stored, not logged, and goes out of scope here.\n saveSession({ did: r.did, handle: r.handle, accessJwt: r.accessJwt, refreshJwt: r.refreshJwt })\n $('notif-pass').value = ''\n form.hidden = true\n await load(loadSession())\n } catch (err) {\n say((err && err.message) || 'sign-in failed', 'err')\n }\n })\n}\n\nconst signout = $('notif-signout')\nif (signout) {\n signout.addEventListener('click', () => {\n clearSession()\n out.innerHTML = ''\n if (form) form.hidden = false\n say('signed out of notifications on this browser')\n })\n}\n\nboot()\n",
"report.js": "// Report a post or account to a moderation service (com.atproto.moderation.createReport).\n//\n// SAME CREDENTIAL RULE AS NOTIFICATIONS, for the same reason: createReport is user-scoped, so it needs a\n// bearer, and favela-web deliberately holds none. The browser sends it, reusing the session notifications\n// already established — one login, not two. If there is no session, the control says so and points at\n// /notifications rather than silently doing nothing.\n//\n// Reports go to the PDS's moderation service. They are NOT public records and nothing is written to the\n// user's repo; this is a message to a moderator, which is why it needs no signing key.\nconst PDS = document.querySelector('meta[name=\"favela-pds\"]')?.content || 'https://pds.7rnx.net'\nconst KEY = 'favela.notif.session'\n\nconst REASONS = [\n ['com.atproto.moderation.defs#reasonSpam', 'spam'],\n ['com.atproto.moderation.defs#reasonViolation', 'rule violation'],\n ['com.atproto.moderation.defs#reasonMisleading', 'misleading'],\n ['com.atproto.moderation.defs#reasonSexual', 'unwanted sexual content'],\n ['com.atproto.moderation.defs#reasonRude', 'rude or harassing'],\n ['com.atproto.moderation.defs#reasonOther', 'other']\n]\n\nconst session = () => { try { return JSON.parse(localStorage.getItem(KEY) || 'null') } catch { return null } }\n\nfunction panel (uri, cid, anchor) {\n const box = document.createElement('form')\n box.className = 'reportbox'\n const s = session()\n if (!s || !s.accessJwt) {\n box.innerHTML = '<p class=\"empty\">Reporting needs a signed-in session. <a href=\"/notifications\">sign in →</a></p>'\n return box\n }\n const sel = document.createElement('select')\n sel.setAttribute('aria-label', 'reason')\n for (const [v, label] of REASONS) {\n const o = document.createElement('option'); o.value = v; o.textContent = label; sel.appendChild(o)\n }\n const note = document.createElement('input')\n note.type = 'text'; note.placeholder = 'anything the moderator should know (optional)'\n note.setAttribute('aria-label', 'details')\n const send = document.createElement('button')\n send.type = 'submit'; send.textContent = 'send report'\n const status = document.createElement('span')\n status.className = 'empty'; status.setAttribute('aria-live', 'polite')\n box.append(sel, note, send, status)\n\n box.addEventListener('submit', async (e) => {\n e.preventDefault()\n send.disabled = true\n status.textContent = 'sending…'\n try {\n // A post is reported as a strongRef (uri AND cid); an account as a repoRef with just the did.\n const subject = cid\n ? { $type: 'com.atproto.repo.strongRef', uri, cid }\n : { $type: 'com.atproto.admin.defs#repoRef', did: uri }\n const res = await fetch(`${PDS.replace(/\\/$/, '')}/xrpc/com.atproto.moderation.createReport`, {\n method: 'POST',\n headers: { 'content-type': 'application/json', authorization: `Bearer ${s.accessJwt}` },\n body: JSON.stringify({ reasonType: sel.value, reason: note.value || undefined, subject })\n })\n const body = await res.json().catch(() => null)\n if (!res.ok) throw new Error((body && (body.message || body.error)) || `HTTP ${res.status}`)\n status.textContent = 'reported — thank you'\n sel.disabled = note.disabled = true\n } catch (err) {\n status.textContent = (err && err.message) || 'report failed'\n send.disabled = false\n }\n })\n return box\n}\n\ndocument.addEventListener('click', (e) => {\n const btn = e.target.closest && e.target.closest('.reportbtn')\n if (!btn) return\n e.preventDefault()\n e.stopPropagation() // never trigger the post's whole-entry navigation\n const existing = btn.parentElement.querySelector('.reportbox')\n if (existing) { existing.remove(); return } // a second click closes it\n btn.parentElement.appendChild(panel(btn.dataset.uri, btn.dataset.cid || null, btn))\n})\n",
"resize-plan.js": "// Should this picture be re-encoded before upload, and to what size?\n//\n// ⛔ THE LIMIT IS THE LEXICON'S, NOT OURS. app.bsky.embed.images specifies\n//\n// \"blob\": { \"accept\": [\"image/*\"], \"maxSize\": 2000000 }\n//\n// so 2,000,000 bytes is the ceiling, with at most 4 images per post and `alt` required. compose.js used\n// to enforce 1024*1024 — about half — with no justification anywhere in the source, and it REFUSED\n// oversize files rather than shrinking them. Once the phone's composer could open the camera (2026-08-18)\n// that made \"attach a photo\" a dead end by construction: a phone camera JPEG is 3–12 MB, so it failed\n// both the old cap and the real one. Raising the number alone fixes nothing; the picture has to shrink.\n//\n// Pure and DOM-free on purpose. The canvas work that acts on this lives in compose.js, where a browser is\n// required to test it; the decision — which is where the arithmetic errors live — is tested under node\n// (test/resize-plan.test.mjs).\n\n// The protocol ceiling, quoted rather than chosen.\nexport const MAX_BYTES = 2000000\n\n// Longest edge after scaling. Not from the lexicon — there is no dimension limit in atproto — but a\n// 4000px photo carries far more detail than any feed renders, and shrinking pixels is what makes the\n// byte ceiling reachable without destroying quality through compression alone.\nexport const MAX_EDGE = 2000\n\n/**\n * @param {{width:number, height:number, bytes:number, type:string}} file\n * @returns {{needed:boolean, width:number, height:number, reason:string}}\n */\nexport function resizePlan ({ width = 0, height = 0, bytes = 0, type = '' } = {}) {\n const isImage = String(type).startsWith('image/')\n // Canvas re-encoding a non-image produces a broken picture silently, so decline and let the caller\n // reject it with a real message.\n if (!isImage) return { needed: false, width, height, reason: 'not an image' }\n\n const longest = Math.max(width, height)\n const overEdge = longest > MAX_EDGE && longest > 0\n const overBytes = bytes > MAX_BYTES\n\n if (!overEdge && !overBytes) return { needed: false, width, height, reason: 'within limits' }\n\n // Scale only when the picture is genuinely oversized in PIXELS. When it is merely heavy — a large JPEG\n // at modest dimensions — keep every pixel and let re-encoding do the work; throwing away detail to fix\n // a compression problem degrades the image for no reason.\n if (!overEdge) return { needed: true, width, height, reason: 'over the byte ceiling' }\n\n const scale = MAX_EDGE / longest\n return {\n needed: true,\n width: Math.max(1, Math.round(width * scale)),\n height: Math.max(1, Math.round(height * scale)),\n reason: overBytes ? 'oversized and over the byte ceiling' : 'oversized'\n }\n}\n\n/**\n * The `aspectRatio` field of app.bsky.embed.images#image — what bsky lays the image out with, and what\n * we have never sent, so every favela image has been laid out from a guess.\n *\n * Null rather than a zero ratio when the dimensions are unknown: no claim beats a wrong one.\n */\nexport function aspectRatioOf (width, height) {\n const w = Math.round(Number(width) || 0)\n const h = Math.round(Number(height) || 0)\n if (w <= 0 || h <= 0) return null\n return { width: w, height: h }\n}\n",
"settings.js": "// Avatar upload — progressive enhancement for /settings.\n//\n// This exists because a lost avatar is otherwise UNRECOVERABLE: the blob bytes are gone from every\n// node (cdn 404, get-blob null, blobs/ empty), so nothing can restore it except the owner re-uploading.\n// Doing so also lands the first blob in the content-addressed OverlayBlobStore, so the migration off\n// the legacy Hyperbee blob store happens as a side effect.\n//\n// The bytes are POSTed RAW (no multipart): the server reads them binary-safe and derives the mime from\n// magic bytes, so there is no parser and no header to trust. favela never holds a key — it relays to\n// the local hpp, which stores the blob and signs the profile update.\n\nconst file = document.getElementById('avatar-file')\nconst status = document.getElementById('avatar-status')\nconst img = document.querySelector('.avatar')\n\nconst MAX = 1024 * 1024\n\nfunction say (msg, cls) {\n if (!status) return\n status.textContent = msg\n status.className = cls || ''\n}\n\nif (file) {\n file.addEventListener('change', async () => {\n const f = file.files && file.files[0]\n if (!f) return\n // Check client-side too so the user gets an instant, specific message instead of a 413 round-trip.\n if (f.size > MAX) { say(`that image is ${Math.round(f.size / 1024)} KB — the limit is 1024 KB`, 'err'); file.value = ''; return }\n say('uploading…')\n try {\n const res = await fetch('/api/avatar', {\n method: 'POST',\n headers: { 'content-type': f.type || 'application/octet-stream' },\n body: f\n })\n const body = await res.json().catch(() => ({}))\n if (!res.ok) { say(body.error || `upload failed (${res.status})`, 'err'); return }\n say('avatar updated — your hpp signed the profile record', 'ok')\n // Show the new image immediately. Blobs are content-addressed and served immutable, so the new\n // cid is a genuinely different URL — no cache-busting hack needed.\n if (img && body.cid) {\n const url = `/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(body.did)}&cid=${encodeURIComponent(body.cid)}`\n if (img.tagName === 'IMG') { img.src = url } else {\n const n = document.createElement('img')\n n.className = 'avatar'; n.src = url; n.alt = 'current avatar'\n img.replaceWith(n)\n }\n }\n } catch (e) {\n say('upload failed: ' + e.message, 'err')\n } finally {\n file.value = ''\n }\n })\n}\n\n// ── Devices: mint a join code, and revoke/restore paired browsers ────────────────────────────────\n//\n// `favela-web pair` could already mint an invite; the browser could only CONSUME one. So adding a\n// second device meant shell access to the box running favela-web — the wrong requirement for \"sign in\n// on my phone\". This closes the loop entirely in the UI: generate here, paste at /login there.\n//\n// The list itself is server-rendered, so with JS off you can still SEE your devices; only the two\n// actions live here. Same posture as the avatar upload above.\n\nconst devStatus = document.getElementById('dev-status')\nconst pairBtn = document.getElementById('pair-new')\nconst pairOut = document.getElementById('pair-out')\nconst pairUrl = document.getElementById('pair-url')\nconst pairCode = document.getElementById('pair-code')\nconst pairCopy = document.getElementById('pair-copy')\nconst pairLabel = document.getElementById('pair-label')\n\nfunction devSay (msg, cls) {\n if (!devStatus) return\n devStatus.textContent = msg\n devStatus.className = cls || ''\n}\n\nif (pairBtn) {\n pairBtn.addEventListener('click', async () => {\n pairBtn.disabled = true\n devSay('minting a join code — your hpp is signing it…')\n try {\n const res = await fetch('/api/devices/pair', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ label: pairLabel ? pairLabel.value : '' })\n })\n const body = await res.json().catch(() => ({}))\n if (!res.ok) { devSay(body.error || `could not mint a join code (${res.status})`, 'err'); return }\n pairCode.textContent = body.inviteHex\n pairUrl.textContent = body.loginUrl\n pairUrl.href = body.loginUrl\n pairOut.hidden = false\n // State the expiry as a duration rather than a timestamp — the code dies on first use OR at the\n // TTL, and \"5 minutes\" is the fact that changes what the user does next.\n const mins = Math.max(1, Math.round((body.expiresAt - Date.now()) / 60000))\n devSay(`join code ready — valid for ~${mins} min, or until it is used once`, 'ok')\n } catch (e) {\n devSay('could not mint a join code: ' + e.message, 'err')\n } finally {\n pairBtn.disabled = false\n }\n })\n}\n\nif (pairCopy) {\n pairCopy.addEventListener('click', async () => {\n try {\n await navigator.clipboard.writeText(pairCode.textContent || '')\n devSay('join code copied', 'ok')\n } catch {\n // Clipboard is permission-gated and absent over plain http on some browsers. The code is on the\n // page either way, so say so rather than failing silently.\n devSay('could not copy — select the code above and copy it manually', 'err')\n }\n })\n}\n\n// Revoke/restore. Delegated from the list so it keeps working if rows are ever re-rendered.\ndocument.querySelectorAll('.dev .devbtn').forEach((btn) => {\n btn.addEventListener('click', async () => {\n const row = btn.closest('.dev')\n const subKey = row && row.dataset.subkey\n if (!subKey) return\n const restore = btn.dataset.action === 'restore'\n btn.disabled = true\n devSay(restore ? 'restoring…' : 'revoking…')\n try {\n const res = await fetch('/api/devices/revoke', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ subKey, restore })\n })\n const body = await res.json().catch(() => ({}))\n if (!res.ok) { devSay(body.error || `failed (${res.status})`, 'err'); return }\n // Flip the row in place. A revoke is persistent + replicated, so the next page load agrees —\n // this is just avoiding a reload, not inventing state.\n row.classList.toggle('revoked', !restore)\n btn.dataset.action = restore ? 'revoke' : 'restore'\n btn.textContent = restore ? 'revoke' : 'restore'\n devSay(restore ? 'device restored — it can sign in again' : 'device revoked — its sessions are dead', 'ok')\n } catch (e) {\n devSay('failed: ' + e.message, 'err')\n } finally {\n btn.disabled = false\n }\n })\n})\n\n// ── name + bio counters ──────────────────────────────────────────────────────────────────────────\n//\n// The form itself is a plain POST that works with scripting off; these counters are enhancement only.\n// atproto caps displayName at 64 GRAPHEMES and description at 256 — counted the same way the composer\n// does, because .length over-counts emoji and would warn about a legal value.\nconst pfSeg = (typeof Intl !== 'undefined' && Intl.Segmenter) ? new Intl.Segmenter(undefined, { granularity: 'grapheme' }) : null\nconst pfCount = (s) => pfSeg ? [...pfSeg.segment(s)].length : [...String(s)].length\n\nfunction wireCounter (fieldId, ccId, limit) {\n const el = document.getElementById(fieldId)\n const cc = document.getElementById(ccId)\n if (!el || !cc) return\n const upd = () => {\n const left = limit - pfCount(el.value)\n cc.textContent = left > Math.min(20, limit / 4) ? '' : left + ' left'\n cc.classList.toggle('over', left < 0)\n }\n el.addEventListener('input', upd)\n upd()\n return () => limit - pfCount(el.value) >= 0\n}\n\nconst nameOk = wireCounter('pf-name', 'pf-name-cc', 64)\nconst bioOk = wireCounter('pf-bio', 'pf-bio-cc', 256)\nconst proform = document.querySelector('.proform')\nif (proform) {\n // Block a submit that the server would reject anyway, so the user sees it before a round-trip.\n proform.addEventListener('submit', (e) => {\n if ((nameOk && !nameOk()) || (bioOk && !bioOk())) e.preventDefault()\n })\n}\n\n// ── banner ───────────────────────────────────────────────────────────────────────────────────────\n// Same flow as the avatar above, against /api/banner. Kept as its own handler and its own route rather\n// than a shared one with a flag, so a bug in either cannot write the other's profile field.\nconst bfile = document.getElementById('banner-file')\nconst bstatus = document.getElementById('banner-status')\n\nfunction bsay (msg, cls) {\n if (!bstatus) return\n bstatus.textContent = msg\n bstatus.className = cls || ''\n}\n\nif (bfile) {\n bfile.addEventListener('change', async () => {\n const f = bfile.files && bfile.files[0]\n if (!f) return\n if (f.size > MAX) { bsay(`that image is ${Math.round(f.size / 1024)} KB — the limit is 1024 KB`, 'err'); bfile.value = ''; return }\n bsay('uploading…')\n try {\n const res = await fetch('/api/banner', {\n method: 'POST',\n headers: { 'content-type': f.type || 'application/octet-stream' },\n body: f\n })\n const body = await res.json().catch(() => ({}))\n if (!res.ok) { bsay(body.error || `upload failed (${res.status})`, 'err'); return }\n bsay('banner updated — your hpp signed the profile record', 'ok')\n // Content-addressed, so the new cid is a different URL — nothing to cache-bust.\n if (body.cid) {\n const url = `/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(body.did)}&cid=${encodeURIComponent(body.cid)}`\n let holder = document.querySelector('.profbanner.small')\n if (!holder) {\n holder = document.createElement('div')\n holder.className = 'profbanner small'\n const anchor = bfile.closest('section')\n if (anchor) anchor.insertBefore(holder, anchor.querySelector('p:has(.imgpick)') || anchor.lastElementChild)\n }\n holder.innerHTML = ''\n const n = document.createElement('img')\n n.src = url; n.alt = 'current banner'\n holder.appendChild(n)\n }\n } catch (e) {\n bsay((e && e.message) || 'upload failed', 'err')\n }\n })\n}\n",
"toast.js": "// toast — the one place a failure gets SAID.\n//\n// THE PROBLEM. Every client-side write already handles its own failure correctly: actions.js puts the\n// original state back rather than leaving a lie on screen. But the only place it says WHY is a `title`\n// attribute and a 1.2s red flash. A `title` needs a hover, so on touch it does not exist at all, and no\n// screen reader announces it. So the honest state of things was: the write failed, the UI silently\n// reverted, and the user's reasonable reading is \"my tap didn't register\" — they tap again.\n//\n// Silent failure is the worst class of bug in this codebase and we keep re-learning it (hyper-social #24,\n// #26: \"everything we control reports success\"). A control that reverts without saying why is the same\n// mistake at the UI layer.\n//\n// SHAPE. One shared live region, appended once, reused. `role=\"status\"` + `aria-live=\"polite\"` so it is\n// announced without stealing focus; assertive is reserved for errors, which ARE interruptions.\n//\n// Deliberately NOT a dependency of the no-JS path: this file only ever runs as progressive enhancement,\n// and nothing renders differently when it is absent. If it fails to load, the pre-existing flash + title\n// behaviour is still there — this is additive.\n\nlet host = null\n\nfunction ensureHost () {\n if (host && host.isConnected) return host\n host = document.createElement('div')\n host.className = 'toasts'\n // The region must exist in the DOM BEFORE the message is written into it, or assistive tech may not\n // announce a node that appeared already-populated. Hence a persistent host with children swapped in.\n host.setAttribute('role', 'status')\n host.setAttribute('aria-live', 'polite')\n document.body.appendChild(host)\n return host\n}\n\n// msg — plain text, never HTML (it carries server error strings, which are untrusted)\n// kind — 'error' | 'info'. Errors are assertive and linger; info is polite and brief.\nexport function toast (msg, { kind = 'error', ms = null } = {}) {\n const text = String(msg == null ? '' : msg).trim()\n if (!text) return\n const h = ensureHost()\n h.setAttribute('aria-live', kind === 'error' ? 'assertive' : 'polite')\n\n const el = document.createElement('div')\n el.className = 'toast ' + (kind === 'error' ? 'toast-err' : 'toast-info')\n el.textContent = text // textContent, NOT innerHTML — this string comes off the wire\n\n // A dismiss control, because an error that vanishes on a timer is unreadable to anyone who reads\n // slowly, and one that never vanishes is litter. Both: it auto-dismisses, and you can kill it early.\n const close = document.createElement('button')\n close.className = 'toast-x'\n close.setAttribute('aria-label', 'dismiss')\n close.textContent = '✕'\n close.addEventListener('click', () => el.remove())\n el.appendChild(close)\n\n h.appendChild(el)\n const life = Number.isFinite(ms) ? ms : (kind === 'error' ? 6000 : 3000)\n setTimeout(() => { el.remove() }, life)\n // Never let the region grow without bound if something fails in a loop.\n while (h.children.length > 4) h.removeChild(h.firstChild)\n}\n\nexport default toast\n",
"vendor/noble-secp256k1.js": "/*! noble-secp256k1 - MIT License (c) 2019 Paul Miller (paulmillr.com) */\n/**\n * 4KB JS implementation of secp256k1 signatures & ECDH. Compliant with RFC6979.\n * @module\n */\nconst B256 = 2n ** 256n;\nconst P = B256 - 0x1000003d1n; // curve's field prime\nconst N = B256 - 0x14551231950b75fc4402da1732fc9bebfn; // curve (group) order\nconst Gx = 0x79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798n; // base point x\nconst Gy = 0x483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8n; // base point y\n/**\n * secp256k1 curve parameters. Equation is x³ + ax + b.\n * Gx and Gy are generator coordinates. p is field order, n is group order.\n */\nconst CURVE = {\n p: P, n: N, a: 0n, b: 7n, Gx, Gy\n}; // exported variables incl. a, b\nconst fLen = 32; // field / group byte length\nconst curve = (x) => M(M(x * x) * x + CURVE.b); // x³ + ax + b weierstrass formula; a=0\nconst err = (m = '') => { throw new Error(m); }; // error helper, messes-up stack trace\nconst isB = (n) => typeof n === 'bigint'; // is big integer\nconst isS = (s) => typeof s === 'string'; // is string\nconst fe = (n) => isB(n) && 0n < n && n < P; // is field element (invertible)\nconst ge = (n) => isB(n) && 0n < n && n < N; // is group element\nconst isu8 = (a) => (a instanceof Uint8Array || (ArrayBuffer.isView(a) && a.constructor.name === 'Uint8Array'));\nconst au8 = (a, l) => // assert is Uint8Array (of specific length)\n !isu8(a) || (typeof l === 'number' && l > 0 && a.length !== l) ?\n err('Uint8Array expected') : a;\nconst u8n = (data) => new Uint8Array(data); // creates Uint8Array\nconst toU8 = (a, len) => au8(isS(a) ? h2b(a) : u8n(au8(a)), len); // norm(hex/u8a) to u8a\nconst M = (a, b = P) => {\n const r = a % b;\n return r >= 0n ? r : b + r;\n};\nconst aPoint = (p) => (p instanceof Point ? p : err('Point expected')); // is 3d point\n/** Point in 3d xyz projective coordinates. 3d takes less inversions than 2d. */\nclass Point {\n constructor(px, py, pz) {\n this.px = px;\n this.py = py;\n this.pz = pz;\n Object.freeze(this);\n }\n /** Create 3d xyz point from 2d xy. (0, 0) => (0, 1, 0), not (0, 0, 1) */\n static fromAffine(p) {\n return ((p.x === 0n) && (p.y === 0n)) ? I : new Point(p.x, p.y, 1n);\n }\n /** Convert Uint8Array or hex string to Point. */\n static fromHex(hex) {\n hex = toU8(hex); // convert hex string to Uint8Array\n let p = undefined;\n const head = hex[0], tail = hex.subarray(1); // first byte is prefix, rest is data\n const x = slc(tail, 0, fLen), len = hex.length; // next 32 bytes are x coordinate\n if (len === 33 && [0x02, 0x03].includes(head)) { // compressed points: 33b, start\n if (!fe(x))\n err('Point hex invalid: x not FE'); // with byte 0x02 or 0x03. Check if 0<x<P\n let y = sqrt(curve(x)); // x³ + ax + b is right side of equation\n const isYOdd = (y & 1n) === 1n; // y² is equivalent left-side. Calculate y²:\n const headOdd = (head & 1) === 1; // y = √y²; there are two solutions: y, -y\n if (headOdd !== isYOdd)\n y = M(-y); // determine proper solution\n p = new Point(x, y, 1n); // create point\n } // Uncompressed points: 65b, start with 0x04\n if (len === 65 && head === 0x04)\n p = new Point(x, slc(tail, fLen, 2 * fLen), 1n);\n return p ? p.ok() : err('Point invalid: not on curve'); // Verify the result\n }\n /** Create point from a private key. */\n static fromPrivateKey(k) { return G.mul(toPriv(k)); }\n get x() { return this.aff().x; } // .x, .y will call expensive toAffine:\n get y() { return this.aff().y; } // should be used with care.\n /** Equality check: compare points P&Q. */\n equals(other) {\n const { px: X1, py: Y1, pz: Z1 } = this;\n const { px: X2, py: Y2, pz: Z2 } = aPoint(other); // isPoint() checks class equality\n const X1Z2 = M(X1 * Z2), X2Z1 = M(X2 * Z1);\n const Y1Z2 = M(Y1 * Z2), Y2Z1 = M(Y2 * Z1);\n return X1Z2 === X2Z1 && Y1Z2 === Y2Z1;\n }\n /** Flip point over y coordinate. */\n negate() { return new Point(this.px, M(-this.py), this.pz); }\n /** Point doubling: P+P, complete formula. */\n double() { return this.add(this); }\n /**\n * Point addition: P+Q, complete, exception-free formula\n * (Renes-Costello-Batina, algo 1 of [2015/1060](https://eprint.iacr.org/2015/1060)).\n * Cost: 12M + 0S + 3*a + 3*b3 + 23add.\n */\n add(other) {\n const { px: X1, py: Y1, pz: Z1 } = this;\n const { px: X2, py: Y2, pz: Z2 } = aPoint(other);\n const { a, b } = CURVE;\n let X3 = 0n, Y3 = 0n, Z3 = 0n;\n const b3 = M(b * 3n);\n let t0 = M(X1 * X2), t1 = M(Y1 * Y2), t2 = M(Z1 * Z2), t3 = M(X1 + Y1); // step 1\n let t4 = M(X2 + Y2); // step 5\n t3 = M(t3 * t4);\n t4 = M(t0 + t1);\n t3 = M(t3 - t4);\n t4 = M(X1 + Z1);\n let t5 = M(X2 + Z2); // step 10\n t4 = M(t4 * t5);\n t5 = M(t0 + t2);\n t4 = M(t4 - t5);\n t5 = M(Y1 + Z1);\n X3 = M(Y2 + Z2); // step 15\n t5 = M(t5 * X3);\n X3 = M(t1 + t2);\n t5 = M(t5 - X3);\n Z3 = M(a * t4);\n X3 = M(b3 * t2); // step 20\n Z3 = M(X3 + Z3);\n X3 = M(t1 - Z3);\n Z3 = M(t1 + Z3);\n Y3 = M(X3 * Z3);\n t1 = M(t0 + t0); // step 25\n t1 = M(t1 + t0);\n t2 = M(a * t2);\n t4 = M(b3 * t4);\n t1 = M(t1 + t2);\n t2 = M(t0 - t2); // step 30\n t2 = M(a * t2);\n t4 = M(t4 + t2);\n t0 = M(t1 * t4);\n Y3 = M(Y3 + t0);\n t0 = M(t5 * t4); // step 35\n X3 = M(t3 * X3);\n X3 = M(X3 - t0);\n t0 = M(t3 * t1);\n Z3 = M(t5 * Z3);\n Z3 = M(Z3 + t0); // step 40\n return new Point(X3, Y3, Z3);\n }\n mul(n, safe = true) {\n if (!safe && n === 0n)\n return I; // in unsafe mode, allow zero\n if (!ge(n))\n err('scalar invalid'); // must be 0 < n < CURVE.n\n if (this.equals(G))\n return wNAF(n).p; // use precomputes for base point\n let p = I, f = G; // init result point & fake point\n for (let d = this; n > 0n; d = d.double(), n >>= 1n) { // double-and-add ladder\n if (n & 1n)\n p = p.add(d); // if bit is present, add to point\n else if (safe)\n f = f.add(d); // if not, add to fake for timing safety\n }\n return p;\n }\n mulAddQUns(R, u1, u2) {\n return this.mul(u1, false).add(R.mul(u2, false)).ok(); // Unsafe: do NOT use for stuff related\n } // to private keys. Doesn't use Shamir trick\n /** Convert point to 2d xy affine point. (x, y, z) ∋ (x=x/z, y=y/z) */\n toAffine() {\n const { px: x, py: y, pz: z } = this;\n if (this.equals(I))\n return { x: 0n, y: 0n }; // fast-path for zero point\n if (z === 1n)\n return { x, y }; // if z is 1, pass affine coordinates as-is\n const iz = inv(z, P); // z^-1: invert z\n if (M(z * iz) !== 1n)\n err('inverse invalid'); // (z * z^-1) must be 1, otherwise bad math\n return { x: M(x * iz), y: M(y * iz) }; // x = x*z^-1; y = y*z^-1\n }\n /** Checks if the point is valid and on-curve. */\n assertValidity() {\n const { x, y } = this.aff(); // convert to 2d xy affine point.\n if (!fe(x) || !fe(y))\n err('Point invalid: x or y'); // x and y must be in range 0 < n < P\n return M(y * y) === curve(x) ? // y² = x³ + ax + b, must be equal\n this : err('Point invalid: not on curve');\n }\n multiply(n) { return this.mul(n); } // Aliases to compress code\n aff() { return this.toAffine(); }\n ok() { return this.assertValidity(); }\n toHex(isCompressed = true) {\n const { x, y } = this.aff(); // convert to 2d xy affine point\n const head = isCompressed ? ((y & 1n) === 0n ? '02' : '03') : '04'; // 0x02, 0x03, 0x04 prefix\n return head + n2h(x) + (isCompressed ? '' : n2h(y)); // prefix||x and ||y\n }\n toRawBytes(isCompressed = true) {\n return h2b(this.toHex(isCompressed)); // re-use toHex(), convert hex to bytes\n }\n}\n/** Generator / base point */\nPoint.BASE = new Point(Gx, Gy, 1n);\n/** Identity / zero point */\nPoint.ZERO = new Point(0n, 1n, 0n);\nconst { BASE: G, ZERO: I } = Point; // Generator, identity points\nconst padh = (n, pad) => n.toString(16).padStart(pad, '0');\nconst b2h = (b) => Array.from(au8(b)).map(e => padh(e, 2)).join(''); // bytes to hex\nconst C = { _0: 48, _9: 57, A: 65, F: 70, a: 97, f: 102 }; // ASCII characters\nconst _ch = (ch) => {\n if (ch >= C._0 && ch <= C._9)\n return ch - C._0; // '2' => 50-48\n if (ch >= C.A && ch <= C.F)\n return ch - (C.A - 10); // 'B' => 66-(65-10)\n if (ch >= C.a && ch <= C.f)\n return ch - (C.a - 10); // 'b' => 98-(97-10)\n return;\n};\nconst h2b = (hex) => {\n const e = 'hex invalid';\n if (!isS(hex))\n return err(e);\n const hl = hex.length, al = hl / 2;\n if (hl % 2)\n return err(e);\n const array = u8n(al);\n for (let ai = 0, hi = 0; ai < al; ai++, hi += 2) { // treat each char as ASCII\n const n1 = _ch(hex.charCodeAt(hi)); // parse first char, multiply it by 16\n const n2 = _ch(hex.charCodeAt(hi + 1)); // parse second char\n if (n1 === undefined || n2 === undefined)\n return err(e);\n array[ai] = n1 * 16 + n2; // example: 'A9' => 10*16 + 9\n }\n return array;\n};\nconst b2n = (b) => BigInt('0x' + (b2h(b) || '0')); // bytes to number\nconst slc = (b, from, to) => b2n(b.slice(from, to)); // slice bytes num\nconst n2b = (num) => {\n return isB(num) && num >= 0n && num < B256 ? h2b(padh(num, 2 * fLen)) : err('bigint expected');\n};\nconst n2h = (num) => b2h(n2b(num)); // number to 32b hex\nconst concatB = (...arrs) => {\n const r = u8n(arrs.reduce((sum, a) => sum + au8(a).length, 0)); // create u8a of summed length\n let pad = 0; // walk through each array,\n arrs.forEach(a => { r.set(a, pad); pad += a.length; }); // ensure they have proper type\n return r;\n};\nconst inv = (num, md) => {\n if (num === 0n || md <= 0n)\n err('no inverse n=' + num + ' mod=' + md); // no neg exponent for now\n let a = M(num, md), b = md, x = 0n, y = 1n, u = 1n, v = 0n;\n while (a !== 0n) { // uses euclidean gcd algorithm\n const q = b / a, r = b % a; // not constant-time\n const m = x - u * q, n = y - v * q;\n b = a, a = r, x = u, y = v, u = m, v = n;\n }\n return b === 1n ? M(x, md) : err('no inverse'); // b is gcd at this point\n};\nconst sqrt = (n) => {\n let r = 1n; // So, a special, fast case. Paper: \"Square Roots from 1;24,51,10 to Dan Shanks\".\n for (let num = n, e = (P + 1n) / 4n; e > 0n; e >>= 1n) { // powMod: modular exponentiation.\n if (e & 1n)\n r = (r * num) % P; // Uses exponentiation by squaring.\n num = (num * num) % P; // Not constant-time.\n }\n return M(r * r) === n ? r : err('sqrt invalid'); // check if result is valid\n};\nconst toPriv = (p) => {\n if (!isB(p))\n p = b2n(toU8(p, fLen)); // convert to bigint when bytes\n return ge(p) ? p : err('private key invalid 3'); // check if bigint is in range\n};\nconst high = (n) => n > (N >> 1n); // if a number is bigger than CURVE.n/2\n/** Creates 33/65-byte public key from 32-byte private key. */\nconst getPublicKey = (privKey, isCompressed = true) => {\n return Point.fromPrivateKey(privKey).toRawBytes(isCompressed);\n};\n/** ECDSA Signature class. Supports only compact 64-byte representation, not DER. */\nclass Signature {\n constructor(r, s, recovery) {\n this.r = r;\n this.s = s;\n this.recovery = recovery;\n this.assertValidity(); // recovery bit is optional when\n } // constructed outside.\n /** Create signature from 64b compact (r || s) representation. */\n static fromCompact(hex) {\n hex = toU8(hex, 64); // compact repr is (32b r)||(32b s)\n return new Signature(slc(hex, 0, fLen), slc(hex, fLen, 2 * fLen));\n }\n assertValidity() { return ge(this.r) && ge(this.s) ? this : err(); } // 0 < r or s < CURVE.n\n /** Create new signature, with added recovery bit. */\n addRecoveryBit(rec) {\n return new Signature(this.r, this.s, rec);\n }\n hasHighS() { return high(this.s); }\n normalizeS() {\n return high(this.s) ? new Signature(this.r, M(-this.s, N), this.recovery) : this;\n }\n /** ECDSA public key recovery. Requires msg hash and recovery id. */\n recoverPublicKey(msgh) {\n const { r, s, recovery: rec } = this; // secg.org/sec1-v2.pdf 4.1.6\n if (![0, 1, 2, 3].includes(rec))\n err('recovery id invalid'); // check recovery id\n const h = bits2int_modN(toU8(msgh, fLen)); // Truncate hash\n const radj = rec === 2 || rec === 3 ? r + N : r; // If rec was 2 or 3, q.x is bigger than n\n if (radj >= P)\n err('q.x invalid'); // ensure q.x is still a field element\n const head = (rec & 1) === 0 ? '02' : '03'; // head is 0x02 or 0x03\n const R = Point.fromHex(head + n2h(radj)); // concat head + hex repr of r\n const ir = inv(radj, N); // r^-1\n const u1 = M(-h * ir, N); // -hr^-1\n const u2 = M(s * ir, N); // sr^-1\n return G.mulAddQUns(R, u1, u2); // (sr^-1)R-(hr^-1)G = -(hr^-1)G + (sr^-1)\n }\n /** Uint8Array 64b compact (r || s) representation. */\n toCompactRawBytes() { return h2b(this.toCompactHex()); }\n /** Hex string 64b compact (r || s) representation. */\n toCompactHex() { return n2h(this.r) + n2h(this.s); }\n}\nconst bits2int = (bytes) => {\n const delta = bytes.length * 8 - 256; // RFC suggests optional truncating via bits2octets\n if (delta > 1024)\n err('msg invalid'); // our CUSTOM check, \"just-in-case\"\n const num = b2n(bytes); // FIPS 186-4 4.6 suggests the leftmost min(nBitLen, outLen) bits, which\n return delta > 0 ? num >> BigInt(delta) : num; // matches bits2int. bits2int can produce res>N.\n};\nconst bits2int_modN = (bytes) => {\n return M(bits2int(bytes), N); // with 0: BAD for trunc as per RFC vectors\n};\nconst i2o = (num) => n2b(num); // int to octets\nconst cr = () => // We support: 1) browsers 2) node.js 19+ 3) deno, other envs with crypto\n typeof globalThis === 'object' && 'crypto' in globalThis ? globalThis.crypto : undefined;\nlet _hmacSync; // Can be redefined by use in utils; built-ins don't provide it\nconst optS = { lowS: true }; // opts for sign()\nconst optV = { lowS: true }; // standard opts for verify()\nconst prepSig = (msgh, priv, opts = optS) => {\n if (['der', 'recovered', 'canonical'].some(k => k in opts))\n err('option not supported'); // legacy opts\n let { lowS } = opts; // generates low-s sigs by default\n if (lowS == null)\n lowS = true; // RFC6979 3.2: we skip step A\n const h1i = bits2int_modN(toU8(msgh)); // msg bigint\n const h1o = i2o(h1i); // msg octets\n const d = toPriv(priv); // validate private key, convert to bigint\n const seed = [i2o(d), h1o]; // Step D of RFC6979 3.2\n let ent = opts.extraEntropy; // RFC6979 3.6: additional k' (optional)\n if (ent) // K = HMAC_K(V || 0x00 || int2octets(x) || bits2octets(h1) || k')\n seed.push(ent === true ? etc.randomBytes(fLen) : toU8(ent)); // true == fetch from CSPRNG\n const m = h1i; // convert msg to bigint\n const k2sig = (kBytes) => {\n const k = bits2int(kBytes); // RFC6979 method.\n if (!ge(k))\n return; // Check 0 < k < CURVE.n\n const ik = inv(k, N); // k^-1 mod n, NOT mod P\n const q = G.mul(k).aff(); // q = Gk\n const r = M(q.x, N); // r = q.x mod n\n if (r === 0n)\n return; // r=0 invalid\n const s = M(ik * M(m + M(d * r, N), N), N); // s = k^-1(m + rd) mod n\n if (s === 0n)\n return; // s=0 invalid\n let normS = s; // normalized S\n let rec = (q.x === r ? 0 : 2) | Number(q.y & 1n); // recovery bit\n if (lowS && high(s)) { // if lowS was passed, ensure s is always\n normS = M(-s, N); // in the bottom half of CURVE.n\n rec ^= 1;\n }\n return new Signature(r, normS, rec); // use normS, not s\n };\n return { seed: concatB(...seed), k2sig };\n};\nfunction hmacDrbg(asynchronous) {\n let v = u8n(fLen); // Minimal non-full-spec HMAC-DRBG from NIST 800-90 for RFC6979 sigs.\n let k = u8n(fLen); // Steps B, C of RFC6979 3.2: set hashLen, in our case always same\n let i = 0; // Iterations counter, will throw when over 1000\n const reset = () => { v.fill(1); k.fill(0); i = 0; };\n const _e = 'drbg: tried 1000 values';\n if (asynchronous) { // asynchronous=true\n const h = (...b) => etc.hmacSha256Async(k, v, ...b); // hmac(k)(v, ...values)\n const reseed = async (seed = u8n()) => {\n k = await h(u8n([0x00]), seed); // k = hmac(K || V || 0x00 || seed)\n v = await h(); // v = hmac(K || V)\n if (seed.length === 0)\n return;\n k = await h(u8n([0x01]), seed); // k = hmac(K || V || 0x01 || seed)\n v = await h(); // v = hmac(K || V)\n };\n const gen = async () => {\n if (i++ >= 1000)\n err(_e);\n v = await h(); // v = hmac(K || V)\n return v;\n };\n return async (seed, pred) => {\n reset(); // the returned fn, don't, it's: 1. slower (JIT). 2. unsafe (async race conditions)\n await reseed(seed); // Steps D-G\n let res = undefined; // Step H: grind until k is in [1..n-1]\n while (!(res = pred(await gen())))\n await reseed(); // test predicate until it returns ok\n reset();\n return res;\n };\n }\n else {\n const h = (...b) => {\n const f = _hmacSync;\n if (!f)\n err('etc.hmacSha256Sync not set');\n return f(k, v, ...b); // hmac(k)(v, ...values)\n };\n const reseed = (seed = u8n()) => {\n k = h(u8n([0x00]), seed); // k = hmac(k || v || 0x00 || seed)\n v = h(); // v = hmac(k || v)\n if (seed.length === 0)\n return;\n k = h(u8n([0x01]), seed); // k = hmac(k || v || 0x01 || seed)\n v = h(); // v = hmac(k || v)\n };\n const gen = () => {\n if (i++ >= 1000)\n err(_e);\n v = h(); // v = hmac(k || v)\n return v;\n };\n return (seed, pred) => {\n reset();\n reseed(seed); // Steps D-G\n let res = undefined; // Step H: grind until k is in [1..n-1]\n while (!(res = pred(gen())))\n reseed(); // test predicate until it returns ok\n reset();\n return res;\n };\n }\n}\n;\n/** ECDSA signature generation. via secg.org/sec1-v2.pdf 4.1.2 + RFC6979 deterministic k. */\n/**\n * Sign a msg hash using secp256k1. Async.\n * It is advised to use `extraEntropy: true` (from RFC6979 3.6) to prevent fault attacks.\n * Worst case: if randomness source for extraEntropy is bad, it would be as secure as if\n * the option has not been used.\n * @param msgh - message HASH, not message itself e.g. sha256(message)\n * @param priv - private key\n * @param opts - `lowS: true` to prevent malleability (s >= CURVE.n/2), `extraEntropy: boolean | Hex` to improve sig security.\n */\nconst signAsync = async (msgh, priv, opts = optS) => {\n const { seed, k2sig } = prepSig(msgh, priv, opts); // Extract arguments for hmac-drbg\n return hmacDrbg(true)(seed, k2sig); // Re-run drbg until k2sig returns ok\n};\n/**\n * Sign a msg hash using secp256k1.\n * It is advised to use `extraEntropy: true` (from RFC6979 3.6) to prevent fault attacks.\n * Worst case: if randomness source for extraEntropy is bad, it would be as secure as if\n * the option has not been used.\n * @param msgh - message HASH, not message itself e.g. sha256(message)\n * @param priv - private key\n * @param opts - `lowS: true` to prevent malleability (s >= CURVE.n/2), `extraEntropy: boolean | Hex` to improve sig security.\n * @example\n * const sig = sign(sha256('hello'), privKey, { extraEntropy: true }).toCompactRawBytes();\n */\nconst sign = (msgh, priv, opts = optS) => {\n const { seed, k2sig } = prepSig(msgh, priv, opts); // Extract arguments for hmac-drbg\n return hmacDrbg(false)(seed, k2sig); // Re-run drbg until k2sig returns ok\n};\n/**\n * Verify a signature using secp256k1.\n * @param sig - signature, 64-byte or Signature instance\n * @param msgh - message HASH, not message itself e.g. sha256(message)\n * @param pub - public key\n * @param opts - { lowS: true } is default, prohibits s >= CURVE.n/2 to prevent malleability\n */\nconst verify = (sig, msgh, pub, opts = optV) => {\n let { lowS } = opts; // ECDSA signature verification\n if (lowS == null)\n lowS = true; // Default lowS=true\n if ('strict' in opts)\n err('option not supported'); // legacy param\n let sig_, h, P; // secg.org/sec1-v2.pdf 4.1.4\n const rs = sig && typeof sig === 'object' && 'r' in sig; // Previous ver supported DER sigs. We\n if (!rs && (toU8(sig).length !== 2 * fLen)) // throw error when DER is suspected now.\n err('signature must be 64 bytes');\n try {\n sig_ = rs ? new Signature(sig.r, sig.s).assertValidity() : Signature.fromCompact(sig);\n h = bits2int_modN(toU8(msgh)); // Truncate hash\n P = pub instanceof Point ? pub.ok() : Point.fromHex(pub); // Validate public key\n }\n catch (e) {\n return false;\n } // Check sig for validity in both cases\n if (!sig_)\n return false;\n const { r, s } = sig_;\n if (lowS && high(s))\n return false; // lowS bans sig.s >= CURVE.n/2\n let R;\n try {\n const is = inv(s, N); // s^-1\n const u1 = M(h * is, N); // u1 = hs^-1 mod n\n const u2 = M(r * is, N); // u2 = rs^-1 mod n\n R = G.mulAddQUns(P, u1, u2).aff(); // R = u1⋅G + u2⋅P\n }\n catch (error) {\n return false;\n }\n if (!R)\n return false; // stop if R is identity / zero point\n const v = M(R.x, N); // R.x must be in N's field, not P's\n return v === r; // mod(R.x, n) == r\n};\n/**\n * Elliptic Curve Diffie-Hellman (ECDH) on secp256k1.\n * Result is **NOT hashed**. Use hash on it if you need.\n * @param privA private key A\n * @param pubB public key B\n * @param isCompressed 33-byte or 65-byte output\n * @returns public key C\n */\nconst getSharedSecret = (privA, pubB, isCompressed = true) => {\n return Point.fromHex(pubB).mul(toPriv(privA)).toRawBytes(isCompressed); // ECDH\n};\nconst hashToPrivateKey = (hash) => {\n hash = toU8(hash); // produces private keys with modulo bias\n if (hash.length < fLen + 8 || hash.length > 1024)\n err('expected 40-1024b'); // being neglible.\n const num = M(b2n(hash), N - 1n); // takes n+8 bytes\n return n2b(num + 1n); // returns (hash mod n-1)+1\n};\n/** Math, hex, byte helpers. Not in `utils` because utils share API with noble-curves. */\nconst etc = {\n hexToBytes: h2b,\n bytesToHex: b2h,\n concatBytes: concatB,\n bytesToNumberBE: b2n,\n numberToBytesBE: n2b,\n mod: M,\n invert: inv, // math utilities\n hmacSha256Async: async (key, ...msgs) => {\n const c = cr(); // async HMAC-SHA256, no sync built-in!\n const s = c && c.subtle; // For React Native support, see README.\n if (!s)\n return err('etc.hmacSha256Async or crypto.subtle must be defined'); // Uses webcrypto built-in cryptography.\n const k = await s.importKey('raw', key, { name: 'HMAC', hash: { name: 'SHA-256' } }, false, ['sign']);\n return u8n(await s.sign('HMAC', k, concatB(...msgs)));\n },\n hmacSha256Sync: _hmacSync, // For TypeScript. Actual logic is below\n hashToPrivateKey: hashToPrivateKey,\n randomBytes: (len = 32) => {\n const crypto = cr(); // Must be shimmed in node.js <= 18 to prevent error. See README.\n if (!crypto || !crypto.getRandomValues)\n err('crypto.getRandomValues must be defined');\n return crypto.getRandomValues(u8n(len));\n },\n};\n/** Curve-specific utilities for private keys. */\nconst utils = {\n normPrivateKeyToScalar: toPriv,\n isValidPrivateKey: (key) => { try {\n return !!toPriv(key);\n }\n catch (e) {\n return false;\n } },\n randomPrivateKey: () => hashToPrivateKey(etc.randomBytes(fLen + 16)), // FIPS 186 B.4.1.\n precompute: (w = 8, p = G) => { p.multiply(3n); w; return p; }, // no-op\n};\nObject.defineProperties(etc, { hmacSha256Sync: {\n configurable: false, get() { return _hmacSync; }, set(f) { if (!_hmacSync)\n _hmacSync = f; },\n } });\nconst W = 8; // Precomputes-related code. W = window size\nconst precompute = () => {\n const points = []; // 10x sign(), 2x verify(). To achieve this,\n const windows = 256 / W + 1; // app needs to spend 40ms+ to calculate\n let p = G, b = p; // a lot of points related to base point G.\n for (let w = 0; w < windows; w++) { // Points are stored in array and used\n b = p; // any time Gx multiplication is done.\n points.push(b); // They consume 16-32 MiB of RAM.\n for (let i = 1; i < 2 ** (W - 1); i++) {\n b = b.add(p);\n points.push(b);\n }\n p = b.double(); // Precomputes don't speed-up getSharedKey,\n } // which multiplies user point by scalar,\n return points; // when precomputes are using base point\n};\nlet Gpows = undefined; // precomputes for base point G\nconst wNAF = (n) => {\n // Compared to other point mult methods,\n const comp = Gpows || (Gpows = precompute()); // stores 2x less points using subtraction\n const neg = (cnd, p) => { let n = p.negate(); return cnd ? n : p; }; // negate\n let p = I, f = G; // f must be G, or could become I in the end\n const windows = 1 + 256 / W; // W=8 17 windows\n const wsize = 2 ** (W - 1); // W=8 128 window size\n const mask = BigInt(2 ** W - 1); // W=8 will create mask 0b11111111\n const maxNum = 2 ** W; // W=8 256\n const shiftBy = BigInt(W); // W=8 8\n for (let w = 0; w < windows; w++) {\n const off = w * wsize;\n let wbits = Number(n & mask); // extract W bits.\n n >>= shiftBy; // shift number by W bits.\n if (wbits > wsize) {\n wbits -= maxNum;\n n += 1n;\n } // split if bits > max: +224 => 256-32\n const off1 = off, off2 = off + Math.abs(wbits) - 1; // offsets, evaluate both\n const cnd1 = w % 2 !== 0, cnd2 = wbits < 0; // conditions, evaluate both\n if (wbits === 0) {\n f = f.add(neg(cnd1, comp[off1])); // bits are 0: add garbage to fake point\n }\n else { // ^ can't add off2, off2 = I\n p = p.add(neg(cnd2, comp[off2])); // bits are 1: add to result point\n }\n }\n return { p, f }; // return both real and fake points for JIT\n}; // !! you can disable precomputes by commenting-out call of the wNAF() inside Point#mul()\nexport { getPublicKey, sign, signAsync, verify, CURVE, // Remove the export to easily use in REPL\ngetSharedSecret, etc, utils, Point as ProjectivePoint, Signature }; // envs like browser console\n",
"vendor/sha256-pure.js": "/**\n * Pure-JS SHA-256 — public domain implementation.\n * Used as a fallback when crypto.subtle is unavailable (plain HTTP).\n *\n * Input: Uint8Array\n * Output: Uint8Array (32 bytes)\n *\n * Adapted from the well-known compact SHA-256 reference (FIPS 180-4).\n */\n\nexport function sha256 (bytes) {\n // Initial hash values (FIPS 180-4 §5.3.3)\n const H = new Uint32Array([\n 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,\n 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19\n ])\n\n // Round constants (FIPS 180-4 §4.2.2)\n const K = new Uint32Array([\n 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,\n 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,\n 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,\n 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,\n 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,\n 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,\n 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,\n 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2\n ])\n\n // 1. Pad the message (FIPS 180-4 §5.1.1): append 0x80, zero-pad, append 64-bit BE length.\n const bitLen = bytes.length * 8\n // k such that (L + 1 + k) ≡ 448 (mod 512) where L = bytes.length * 8\n const padded = new Uint8Array(Math.ceil((bytes.length + 9) / 64) * 64)\n padded.set(bytes, 0)\n padded[bytes.length] = 0x80\n // 64-bit BE length in the last 8 bytes\n const dv = new DataView(padded.buffer)\n // JS numbers can't hold 64 bits exactly for big inputs, but for our\n // sub-4GB inputs we can set hi=0 and lo=bitLen directly.\n dv.setUint32(padded.length - 8, Math.floor(bitLen / 0x100000000), false)\n dv.setUint32(padded.length - 4, bitLen >>> 0, false)\n\n // 2. Process each 512-bit (64-byte) chunk.\n const W = new Uint32Array(64)\n for (let chunk = 0; chunk < padded.length; chunk += 64) {\n for (let i = 0; i < 16; i++) {\n W[i] = dv.getUint32(chunk + i * 4, false)\n }\n for (let i = 16; i < 64; i++) {\n const s0 = rotr(W[i - 15], 7) ^ rotr(W[i - 15], 18) ^ (W[i - 15] >>> 3)\n const s1 = rotr(W[i - 2], 17) ^ rotr(W[i - 2], 19) ^ (W[i - 2] >>> 10)\n W[i] = (W[i - 16] + s0 + W[i - 7] + s1) >>> 0\n }\n\n let a = H[0], b = H[1], c = H[2], d = H[3]\n let e = H[4], f = H[5], g = H[6], h = H[7]\n\n for (let i = 0; i < 64; i++) {\n const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)\n const ch = (e & f) ^ (~e & g)\n const t1 = (h + S1 + ch + K[i] + W[i]) >>> 0\n const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)\n const maj = (a & b) ^ (a & c) ^ (b & c)\n const t2 = (S0 + maj) >>> 0\n\n h = g\n g = f\n f = e\n e = (d + t1) >>> 0\n d = c\n c = b\n b = a\n a = (t1 + t2) >>> 0\n }\n\n H[0] = (H[0] + a) >>> 0\n H[1] = (H[1] + b) >>> 0\n H[2] = (H[2] + c) >>> 0\n H[3] = (H[3] + d) >>> 0\n H[4] = (H[4] + e) >>> 0\n H[5] = (H[5] + f) >>> 0\n H[6] = (H[6] + g) >>> 0\n H[7] = (H[7] + h) >>> 0\n }\n\n // 3. Emit 32-byte big-endian digest.\n const out = new Uint8Array(32)\n const outDv = new DataView(out.buffer)\n for (let i = 0; i < 8; i++) outDv.setUint32(i * 4, H[i], false)\n return out\n}\n\nfunction rotr (x, n) {\n return ((x >>> n) | (x << (32 - n))) >>> 0\n}\n"
}