OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web / web / static-assets.js

22 lines · javascript ref:
// 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"
}