OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web commits / 9e10147

0.3.61: resize pictures instead of refusing them
The composer refused anything over 1024 KB with an alert. That number is ours, not the protocol's — app.bsky.embed.images specifies "blob": { "accept": ["image/*"], "maxSize": 2000000 } — and unlike the 4-image cap, which cites bsky rejecting a longer array, the byte cap carried no justification anywhere in the source. It became a dead end today. The phone's composer can open the camera (the chooser offers it with no native code, confirmed on the handset), and a phone camera JPEG is 3–12 MB. So every photo taken through favela bounced — and raising the number would not have helped, because 12 MB exceeds the real ceiling too. The fix is to do what every other client does and shrink the picture: cap the long edge at 2000px, then step JPEG quality down only as far as needed, stopping at the first size that fits. Images that already fit are passed through UNTOUCHED — nothing small is ever re-encoded. The size check survives as a last resort, and now says "still N KB after resizing" rather than blaming the file for being large. Also finally sends aspectRatio. writer.js has documented `images = [{ blob, alt?, aspectRatio? }]` and written the field since it was added; this client never sent it, so every favela image has been laid out by bsky from a guess. The decision is a pure module tested under node (18 assertions, incl. the portrait case and "exactly at maxSize is allowed"); the canvas work that uses it needs a browser and is verifiable on the handset. ⚠ Re-encodes to JPEG, so transparency is lost on a PNG large enough to need shrinking. Accepted: >2 MB PNG is a photo or a screenshot, not an icon. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
9e101479ffb6e64d046bffcd44b3d177e4693386
by plankatron <fleeky@gmail.com> · 2026-08-18T15:11:30.000Z

Changed files (5) +206 -8

package.json +2 -2
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-web",
3 "version": "0.3.60",
3 "version": "0.3.61",
44 "description": "favela-web — the web/SSR surface of Favela, the native P2P-local, community-owned social client + appview over the self-custody atproto stack (hyper-proto-peer). One of the Favela surfaces (alongside favela-cli and favela-native). bsky is an optional POSSE export, not the home. See DESIGN.md.",
55 "type": "module",
66 "main": "bin/favela-web.js",
……@@ -55,7 +55,7 @@
5555 "spike:seam": "node spike/atcute-seam.mjs",
5656 "spike:p2p": "node spike/p2p-read.mjs",
5757 "web": "bare bin/favela-web.js",
58 "test": "node test/render.test.mjs && node test/static-assets.test.mjs && node test/ap-followers.test.mjs && node test/reach.test.mjs && node test/strip-vectors.test.mjs && node test/edit.test.mjs && node test/facets.test.mjs && node test/mutes.test.mjs && node test/search.test.mjs && node test/threadgate.test.mjs && node test/bookmarks.test.mjs && node test/postgate.test.mjs && node test/lists.test.mjs && node test/cli-reachable.test.mjs && node test/strongref.test.mjs && node test/lens-import.test.mjs && node test/lens-thread-extract.test.mjs && node test/fronts-diag.test.mjs && node test/gate-policy.test.mjs && node test/sessions.test.mjs && node test/check.test.mjs && node test/auth.test.mjs && node test/browser-auth-client.test.mjs && node test/communities.test.mjs && node test/feedgen.test.mjs && node test/feed-reactor.test.mjs && node test/labeler-export.test.mjs && node test/label-firehose.test.mjs && node test/pairing.test.mjs && node test/writer.test.mjs && node test/blob-serve.test.mjs && node test/blobs.test.mjs && node test/spacedust.test.mjs && node test/spacedust-failclosed.test.mjs && node test/link-drain.test.mjs && node test/record-verify.test.mjs && node test/constellation.test.mjs && node test/retention.test.mjs && node test/hosted-dids.test.mjs && node test/repair-bytes.test.mjs && node test/repo-sync.test.mjs && node test/blob-refs.test.mjs && node test/inbound.test.mjs && node test/followers.test.mjs && node test/repair-strongrefs.test.mjs && node test/conformance.test.mjs",
58 "test": "node test/render.test.mjs && node test/static-assets.test.mjs && node test/resize-plan.test.mjs && node test/ap-followers.test.mjs && node test/reach.test.mjs && node test/strip-vectors.test.mjs && node test/edit.test.mjs && node test/facets.test.mjs && node test/mutes.test.mjs && node test/search.test.mjs && node test/threadgate.test.mjs && node test/bookmarks.test.mjs && node test/postgate.test.mjs && node test/lists.test.mjs && node test/cli-reachable.test.mjs && node test/strongref.test.mjs && node test/lens-import.test.mjs && node test/lens-thread-extract.test.mjs && node test/fronts-diag.test.mjs && node test/gate-policy.test.mjs && node test/sessions.test.mjs && node test/check.test.mjs && node test/auth.test.mjs && node test/browser-auth-client.test.mjs && node test/communities.test.mjs && node test/feedgen.test.mjs && node test/feed-reactor.test.mjs && node test/labeler-export.test.mjs && node test/label-firehose.test.mjs && node test/pairing.test.mjs && node test/writer.test.mjs && node test/blob-serve.test.mjs && node test/blobs.test.mjs && node test/spacedust.test.mjs && node test/spacedust-failclosed.test.mjs && node test/link-drain.test.mjs && node test/record-verify.test.mjs && node test/constellation.test.mjs && node test/retention.test.mjs && node test/hosted-dids.test.mjs && node test/repair-bytes.test.mjs && node test/repo-sync.test.mjs && node test/blob-refs.test.mjs && node test/inbound.test.mjs && node test/followers.test.mjs && node test/repair-strongrefs.test.mjs && node test/conformance.test.mjs",
5959 "test:ws": "bare test/subscribe-labels-ws.test.mjs",
6060 "test:bare": "bare test/appview.test.mjs && bare test/appview-ingest.test.mjs && bare test/appview-dedup.test.mjs && bare test/appview-no-clobber.test.mjs && bare test/appview-blocks.test.mjs && bare test/appview-thread.test.mjs && bare test/appview-author-filter.test.mjs && bare test/appview-collections.test.mjs",
6161 "spike:pack": "bare spike/mobile-pack-probe.mjs",
test/resize-plan.test.mjs +79 -0
……@@ -0,0 +1,79 @@
1// What to do with a picture before uploading it.
2//
3// ⛔ THE CAMERA IS THE CASE. Since 2026-08-18 the phone's composer can open the camera (the chooser
4// offers it with no native code), and a phone camera JPEG is 3–12 MB. compose.js REFUSED anything over
5// 1024 KB with an alert — so "attach a photo" was a dead end by construction, and raising the cap alone
6// would not have helped: 12 MB exceeds the protocol limit too.
7//
8// The protocol limit is not ours to choose. app.bsky.embed.images specifies
9// "blob": { "accept": ["image/*"], "maxSize": 2000000 }
10// so 2,000,000 bytes is the real ceiling, 4 images per post, alt required. Our old 1024*1024 was roughly
11// half that and carried no justification in the source — unlike the 4-image cap, which cites bsky
12// rejecting a longer array.
13//
14// Pure so it runs under node with the rest of the suite; the canvas work that USES it lives in
15// compose.js, where it cannot be tested without a browser.
16import { MAX_BYTES, MAX_EDGE, resizePlan, aspectRatioOf } from '../web/static/resize-plan.js'
17
18let pass = 0
19const ok = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1) } console.log(' ok -', m); pass++ }
20const eq = (a, b, m) => ok(a === b, `${m} (got ${JSON.stringify(a)}, want ${JSON.stringify(b)})`)
21
22// The ceiling is the LEXICON's, quoted here so lowering it silently fails the suite with the reason.
23eq(MAX_BYTES, 2000000, 'the ceiling is app.bsky.embed.images maxSize, not a number we picked')
24
25// A small picture is left completely alone — re-encoding a 200 KB image would lose quality for nothing.
26{
27 const p = resizePlan({ width: 800, height: 600, bytes: 200000, type: 'image/jpeg' })
28 eq(p.needed, false, 'a small image is uploaded untouched')
29}
30
31// The camera case: 12 MP, 8 MB. Scaled to the long-edge cap, aspect ratio preserved exactly.
32{
33 const p = resizePlan({ width: 4000, height: 3000, bytes: 8000000, type: 'image/jpeg' })
34 eq(p.needed, true, 'a camera photo must be resized')
35 eq(p.width, MAX_EDGE, 'longest edge is capped')
36 eq(p.height, 1500, 'and the other edge scales proportionally')
37 ok(Math.abs((p.width / p.height) - (4000 / 3000)) < 0.001, 'aspect ratio is preserved')
38}
39
40// Portrait — the orientation a phone actually shoots in. The CAP applies to the long edge, not to width.
41{
42 const p = resizePlan({ width: 3000, height: 4000, bytes: 8000000, type: 'image/jpeg' })
43 eq(p.height, MAX_EDGE, 'portrait caps the height')
44 eq(p.width, 1500, 'and scales the width')
45}
46
47// Over the byte ceiling but modest dimensions: re-encode at the same size rather than shrink it.
48// Throwing away pixels when the fix is compression would degrade the image for no reason.
49{
50 const p = resizePlan({ width: 1200, height: 900, bytes: 3000000, type: 'image/jpeg' })
51 eq(p.needed, true, 'over the ceiling, so something must happen')
52 eq(p.width, 1200, 'dimensions kept')
53 eq(p.height, 900, 'dimensions kept')
54}
55
56// Exactly at the limit is not over it.
57{
58 eq(resizePlan({ width: 100, height: 100, bytes: MAX_BYTES, type: 'image/jpeg' }).needed, false,
59 'exactly at maxSize is allowed — the lexicon says maxSize, not "less than"')
60 eq(resizePlan({ width: 100, height: 100, bytes: MAX_BYTES + 1, type: 'image/jpeg' }).needed, true,
61 'one byte over is not')
62}
63
64// A non-image is never re-encoded — canvas would silently produce a broken picture.
65{
66 eq(resizePlan({ width: 0, height: 0, bytes: 9000000, type: 'application/pdf' }).needed, false,
67 'a non-image is left for the caller to reject, not mangled')
68}
69
70// aspectRatio is what bsky lays the image out with, and we have never sent it. Integers, per the lexicon.
71{
72 const ar = aspectRatioOf(2000, 1500)
73 eq(ar.width, 2000, 'aspectRatio carries the width')
74 eq(ar.height, 1500, 'and the height')
75 ok(Number.isInteger(ar.width) && Number.isInteger(ar.height), 'both are integers')
76 eq(aspectRatioOf(0, 0), null, 'unknown dimensions produce NO claim rather than a zero ratio')
77}
78
79console.log(`\nRESIZE-PLAN OK: ${pass} assertions`)
web/static-assets.js +2 -1
……@@ -8,11 +8,12 @@ export const STATIC_ASSETS = {
88 "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",
99 "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",
1010 "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",
11 "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\nconst MAX_IMAGES = 4\nconst MAX_BYTES = 1024 * 1024\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 if (f.size > MAX_BYTES) { alert(`${f.name} is ${Math.round(f.size / 1024)} KB — the limit is 1024 KB`); continue }\n try {\n const res = await fetch('/api/upload', { method: 'POST', headers: { 'content-type': f.type || 'application/octet-stream' }, body: f })\n const body = await res.json().catch(() => ({}))\n if (!res.ok) { alert(body.error || `upload failed (${res.status})`); continue }\n attached.push({ blob: body.blob, alt: '', name: f.name, url: URL.createObjectURL(f) })\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 })) })\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",
11 "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",
1212 "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",
1313 "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",
1414 "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",
1515 "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",
16 "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",
1617 "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",
1718 "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",
1819 "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",
web/static/compose.js +57 -5
……@@ -9,8 +9,41 @@
99// bsky caps app.bsky.embed.images at 4 and REJECTS a longer array outright, so the cap is enforced here
1010// too: a record the relay refuses would federate as nothing while looking fine locally.
1111
12import { MAX_BYTES, resizePlan, aspectRatioOf } from './resize-plan.js'
13
1214const MAX_IMAGES = 4
13const MAX_BYTES = 1024 * 1024
15
16// Shrink a picture to something the lexicon accepts, and report the dimensions it ended up with.
17//
18// ⛔ REFUSING WAS THE BUG. This used to alert "the limit is 1024 KB" and skip the file — half the real
19// ceiling, and hopeless once the phone's composer could open the camera on 2026-08-18, because a phone
20// camera JPEG is 3–12 MB. Every photo taken through favela bounced. The fix is not a bigger number; it
21// is doing what every other client does and resizing the picture.
22//
23// ⚠ Re-encodes to JPEG, so transparency is lost on a PNG large enough to need shrinking. Accepted: the
24// byte ceiling is a hard protocol limit and a >2 MB PNG is a photo or a screenshot, not an icon. Images
25// that already fit are passed through UNTOUCHED, so nothing small is ever re-encoded.
26async function prepare (file) {
27 const bitmap = await createImageBitmap(file)
28 const { width, height } = bitmap
29 const plan = resizePlan({ width, height, bytes: file.size, type: file.type })
30 if (!plan.needed) { bitmap.close(); return { blob: file, width, height } }
31
32 const canvas = document.createElement('canvas')
33 canvas.width = plan.width
34 canvas.height = plan.height
35 canvas.getContext('2d').drawImage(bitmap, 0, 0, plan.width, plan.height)
36 bitmap.close()
37
38 // Step the quality down only as far as needed. Stops at the first size that fits, so a picture barely
39 // over the line keeps almost all of its quality.
40 let out = null
41 for (const q of [0.9, 0.82, 0.72, 0.6, 0.45]) {
42 out = await new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', q))
43 if (out && out.size <= MAX_BYTES) break
44 }
45 return { blob: out || file, width: plan.width, height: plan.height }
46}
1447
1548const form = document.querySelector('form.composer')
1649if (form) {
……@@ -46,12 +79,31 @@ if (form) {
4679 picker.value = ''
4780 for (const f of files) {
4881 if (attached.length >= MAX_IMAGES) { alert(`bsky allows at most ${MAX_IMAGES} images per post`); break }
49 if (f.size > MAX_BYTES) { alert(`${f.name} is ${Math.round(f.size / 1024)} KB — the limit is 1024 KB`); continue }
5082 try {
51 const res = await fetch('/api/upload', { method: 'POST', headers: { 'content-type': f.type || 'application/octet-stream' }, body: f })
83 // Resize BEFORE the size check, so the check is a last resort rather than the policy.
84 let up = f
85 let ratio = null
86 try {
87 const prepared = await prepare(f)
88 up = prepared.blob
89 ratio = aspectRatioOf(prepared.width, prepared.height)
90 } catch (e) {
91 alert(`could not read ${f.name}: ${e.message}`)
92 continue
93 }
94 // Only reachable if even the lowest quality could not fit — say so honestly rather than
95 // blaming the file for being large.
96 if (up.size > MAX_BYTES) {
97 alert(`${f.name} is still ${Math.round(up.size / 1024)} KB after resizing — the limit is ${Math.round(MAX_BYTES / 1024)} KB`)
98 continue
99 }
100 const res = await fetch('/api/upload', { method: 'POST', headers: { 'content-type': up.type || f.type || 'application/octet-stream' }, body: up })
52101 const body = await res.json().catch(() => ({}))
53102 if (!res.ok) { alert(body.error || `upload failed (${res.status})`); continue }
54 attached.push({ blob: body.blob, alt: '', name: f.name, url: URL.createObjectURL(f) })
103 // aspectRatio is what bsky lays the image out with. writer.js has always accepted it
104 // (`images = [{ blob, alt?, aspectRatio? }]`) and this client never sent it, so every favela
105 // image has been laid out from a guess.
106 attached.push({ blob: body.blob, alt: '', name: f.name, url: URL.createObjectURL(up), aspectRatio: ratio })
55107 draw()
56108 } catch (e) { alert('upload failed: ' + e.message) }
57109 }
……@@ -67,7 +119,7 @@ if (form) {
67119 const res = await fetch('/api/post', {
68120 method: 'POST',
69121 headers: { 'content-type': 'application/json' },
70 body: JSON.stringify({ text, scope, images: attached.map((a) => ({ blob: a.blob, alt: a.alt })) })
122 body: JSON.stringify({ text, scope, images: attached.map((a) => ({ blob: a.blob, alt: a.alt, ...(a.aspectRatio ? { aspectRatio: a.aspectRatio } : {}) })) })
71123 })
72124 const body = await res.json().catch(() => ({}))
73125 if (!res.ok) { alert(body.error || `post failed (${res.status})`); return }
web/static/resize-plan.js +66 -0
……@@ -0,0 +1,66 @@
1// Should this picture be re-encoded before upload, and to what size?
2//
3// ⛔ THE LIMIT IS THE LEXICON'S, NOT OURS. app.bsky.embed.images specifies
4//
5// "blob": { "accept": ["image/*"], "maxSize": 2000000 }
6//
7// so 2,000,000 bytes is the ceiling, with at most 4 images per post and `alt` required. compose.js used
8// to enforce 1024*1024 — about half — with no justification anywhere in the source, and it REFUSED
9// oversize files rather than shrinking them. Once the phone's composer could open the camera (2026-08-18)
10// that made "attach a photo" a dead end by construction: a phone camera JPEG is 3–12 MB, so it failed
11// both the old cap and the real one. Raising the number alone fixes nothing; the picture has to shrink.
12//
13// Pure and DOM-free on purpose. The canvas work that acts on this lives in compose.js, where a browser is
14// required to test it; the decision — which is where the arithmetic errors live — is tested under node
15// (test/resize-plan.test.mjs).
16
17// The protocol ceiling, quoted rather than chosen.
18export const MAX_BYTES = 2000000
19
20// Longest edge after scaling. Not from the lexicon — there is no dimension limit in atproto — but a
21// 4000px photo carries far more detail than any feed renders, and shrinking pixels is what makes the
22// byte ceiling reachable without destroying quality through compression alone.
23export const MAX_EDGE = 2000
24
25/**
26 * @param {{width:number, height:number, bytes:number, type:string}} file
27 * @returns {{needed:boolean, width:number, height:number, reason:string}}
28 */
29export function resizePlan ({ width = 0, height = 0, bytes = 0, type = '' } = {}) {
30 const isImage = String(type).startsWith('image/')
31 // Canvas re-encoding a non-image produces a broken picture silently, so decline and let the caller
32 // reject it with a real message.
33 if (!isImage) return { needed: false, width, height, reason: 'not an image' }
34
35 const longest = Math.max(width, height)
36 const overEdge = longest > MAX_EDGE && longest > 0
37 const overBytes = bytes > MAX_BYTES
38
39 if (!overEdge && !overBytes) return { needed: false, width, height, reason: 'within limits' }
40
41 // Scale only when the picture is genuinely oversized in PIXELS. When it is merely heavy — a large JPEG
42 // at modest dimensions — keep every pixel and let re-encoding do the work; throwing away detail to fix
43 // a compression problem degrades the image for no reason.
44 if (!overEdge) return { needed: true, width, height, reason: 'over the byte ceiling' }
45
46 const scale = MAX_EDGE / longest
47 return {
48 needed: true,
49 width: Math.max(1, Math.round(width * scale)),
50 height: Math.max(1, Math.round(height * scale)),
51 reason: overBytes ? 'oversized and over the byte ceiling' : 'oversized'
52 }
53}
54
55/**
56 * The `aspectRatio` field of app.bsky.embed.images#image — what bsky lays the image out with, and what
57 * we have never sent, so every favela image has been laid out from a guess.
58 *
59 * Null rather than a zero ratio when the dimensions are unknown: no claim beats a wrong one.
60 */
61export function aspectRatioOf (width, height) {
62 const w = Math.round(Number(width) || 0)
63 const h = Math.round(Number(height) || 0)
64 if (w <= 0 || h <= 0) return null
65 return { width: w, height: h }
66}