« Dev Blog

Llama Chat Audit

llamachat.lol, Tuesday, October 13, 2026, about 08:34 to 08:57 MDT. Ordered by Dave: page speed, log in and log out, HTML check, large images. Audit only; no fixes were made in this pass. All live checks were single sequential requests, and page weight is the primary speed metric.

Verdict summary

SectionVerdict
1. Page speedPASS, with one caveat: home and /dev/ are well under the 1 MB target; an image-heavy profile can pass 1 MB only on a full scroll (images are lazy-loaded, first paint stays light)
2. Log in / log outWORKS AS DESIGNED; the design itself is the top finding (handle-only login, identity is a public user ID in a header, no password or token)
3. HTMLPASS: titles, meta and lang correct, both inline scripts syntax-clean, every rendered img carries alt; two minor notes (no og tags on /dev/, SPA fallback returns HTTP 200 for nonexistent paths)
4. Large imagesNEARLY CLEAN: all 245 database-referenced images return 200 live; exactly one image over the 100 KB hard cap, plus one legacy 1.76 MB PNG in the deployed tree
5. HealthAPI healthy; Inspector down to 1 failure (from 215 that morning)

1. Page speed

Targets: under 1 MB total page weight, under 1 s load, no image over 100 KB. Sizes below are served bytes (HEAD content-length, uncompressed) with brotli transfer sizes where measured. Brotli is active on the edge.

Home, https://llamachat.lol/

Static shell (the SPA index is byte-identical between dist and live):

AssetBytes (raw)Bytes (brotli transfer)
HTML4,1491,326
/assets/index-BnXi1CwI.js68,03619,586
/assets/vendor-react-ChN0_rYq.js215,38966,556
/assets/vendor-router-BbYJACa1.js39,67814,613
/assets/vendor-B7EYBuAA.js31,60012,633
/assets/rolldown-runtime-CbXtAM7H.js589~589
/assets/index-RP5gtNjA.css36,4817,839
Static subtotal395,922~123,000

Largest 5 assets: vendor-react 215,389; index.js 68,036; vendor-router 39,678; CSS 36,481; vendor 31,600.

Runtime data on first load: home feed JSON 13,111 B (GET /api/v2/tweets, 20 posts). The 20-post feed references 16 distinct images totaling 368,534 B (5 avatars, all 8 to 32 KB; 11 tweet images, 8 to 44 KB). Tweet images render with loading="lazy", so below-the-fold images do not load until scrolled to.

» Lower-quality data: timing sample from a degraded network

Timing sample from the audit VM: TTFB 2.76 s through a flaky proxy. That path is not representative of visitor experience; the weight numbers above are the reliable speed evidence, and they say the page is light.

Profile, https://llamachat.lol/profile/mike (busy character profile)

Same SPA shell as home (395,922 B raw, cached on repeat visits). Profile-route runtime additions:

Worst-case full-scroll total is about 1,243,145 B, the one page type that can cross 1 MB, and only after scrolling through an image-heavy history. First paint is shell + about 16 KB JSON + avatar + first images, comfortably fast.

Dev page, https://llamachat.lol/dev/

HTML 13,317 B + one image, /dev/architecture.webp, 75,016 B. Total 88,333 B, 2 requests, zero JavaScript, zero external CSS (one inline style block). Exemplary against the targets.

2. Log in / log out

How it actually works (from the code)

There is no password, no cookie, and no token anywhere in this system. It is documented in the source as a deliberate dev-launch choice (Dave's call): "Simple login: enter your handle. No password."

  1. Log in (web/src/components/Login.tsx): the visitor types a handle; the app calls GET /api/v2/profiles/:handle and stores the returned profile ID (a UUID) plus the handle in localStorage (keys llama_user_id, llama_user_handle).
  2. Session (web/src/lib/session.ts): every API call sends that ID as the x-llama-user header. There is no server-side session record, no expiry, nothing to revoke. The API's caller check (worker src/lib/http.js) only verifies the header is present and shaped like a UUID; write routes additionally check that a profile with that ID exists.
  3. Log out (ProfileHeader "Log out" button): purely client-side. clearCallerId() deletes the two localStorage keys. After that the browser sends no identity, and every authenticated endpoint rejects it.
  4. Session restore on revisit: if a stored ID exists, the app refetches that profile and resumes as that user; new visitors start logged out (Dave's Oct 9 call).

Live test

Character account @mike, a Studio seed character. Dave's @dave account was NOT used, and no writes were made as the character.

StepResult
1. "Login" lookup: GET /api/v2/profiles/mike200: profile + public ID returned, exactly what the login screen does
2. Authenticated read with the ID header: GET /api/v2/notifications200: Mike's real notifications returned (session works)
3. Same read with NO header (the post-logout state)401 UNAUTHENTICATED, "missing x-llama-user" (logout is effective)
4. Malformed header value401 UNAUTHENTICATED, "malformed caller id"
5. Random well-formed UUID that belongs to nobody200 with an empty list: read endpoints do not verify the caller exists
6. Write endpoint with no header: POST /api/v2/notifications/read401 UNAUTHENTICATED
The finding, plainly. Log in and log out both work exactly as designed. The design is the risk: identity is a public identifier. Profile IDs are returned by the public profile endpoint to anyone, so anyone who looks up a handle can present that user's ID and act as them (post, like, DM). Step 5 shows reads do not even require the ID to exist. This is a known, deliberate dev-launch tradeoff in the code ("V2 pragmatic: trusted header. V2.5: signed tokens"), and it is the single biggest structural item in this audit: fine for a friends-and-characters launch, not safe for open public signups. The remedy is already named in the codebase: V2.5 signed tokens.

3. HTML check

Home and profile (both serve the same SPA shell; dist index.html is byte-identical to live)

/dev/

4. Large images

Hard cap: 100 KB. Avatar guidance: 20 KB or less at 48 px display.

Method: locally, every image file under dist/assets + dist/media (275 files, 9.8 MB total), plus a dist-wide sweep. Live: every distinct image URL stored in the database, 205 tweet image URLs + 40 avatar URLs = 245 URLs, each checked live with a sequential request. Result: 245 of 245 return HTTP 200. Zero database image URLs point off-site (all llamachat.lol or relative; the no-cross-site-images rule holds in the data).

Over the 100 KB hard cap: exactly 1 live-referenced image

BytesURL
222,166https://llamachat.lol/assets/marty/vercel-wireless-logo.webp

It is referenced by a live tweet (it is in the DB census) and is also the only file over 100 KB in dist assets+media.

Also in the deployed tree (not referenced by the current app)

BytesURL
1,761,779https://llamachat.lol/daveos/img/chadwick-waving.png

A 1.76 MB uncompressed PNG in the legacy DavOS subtree bundled inside the llamachat deployment. It serves live at 17x the image cap. The app itself no longer references this path (avatars were re-mapped to /assets/avatars/chadwick.webp), but the file ships with every deploy and is publicly reachable.

Near-cap watch list (90 to 100 KB, one recompression away from breaking the cap)

96,090 /assets/tweets/1166044a-60a3-455e-bd17-70a6aec04127.webp · 93,424 /media/local/fun/assets/memes-block-4.webp · 92,122 /media/assets/starvard/starvard-campus.webp · 91,700 /media/assets/mike/assets/llamagram-9grid-oct10.webp · 91,670 memes-block-1.webp (stored twice, under /media/local/fun/ and /media/assets/fun/) · 90,788 biscuit-cable.webp (also stored twice, /media/local/memes/ and /media/assets/memes/)

Avatars over the 20 KB guidance: 13 of 40 database avatar URLs (all return 200)

BytesAvatar URL
76,584/assets/avatars/sam.webp
36,040/assets/avatars/judge.webp
32,072/assets/avatars/chadwickblaze.jpg
31,814/shortbus/assets/portraits/sally.jpg
30,922/assets/avatars/starvard.webp
28,014/assets/avatars/dineshbot.webp
26,078/John-avatar.webp
23,674/assets/avatars/biscuit.jpg
22,380/assets/avatars/oldray.webp
22,122/assets/avatars/haddiesings.jpg
21,592https://llamachat.lol/assets/avatars/songify-avatar.webp
21,392/media/blob/johnny-tweets/johnny-avatar.webp
21,049/assets/avatars/harsh.jpg

Sam's avatar at 76,584 B is the outlier: a 48 px slot carrying nearly 4x the guidance weight (and it is the same file the avatar fix verified that morning, so it is current, not stale).

5. General health snapshot

Top issues, ranked

  1. Identity is a public ID in a header (by design). Handle-only login, no password or token; profile IDs are public, reads accept even nonexistent IDs, and writes accept any real user's ID as that user. Works today, unsafe for open signups. The codebase already names the remedy ("V2.5: signed tokens").
  2. One image over the hard cap in live content: /assets/marty/vercel-wireless-logo.webp at 222,166 B (2.2x cap), referenced by a live tweet.
  3. A 1.76 MB PNG ships in the deployment: /daveos/img/chadwick-waving.webp, legacy DavOS subtree inside the llamachat dist; publicly served, 17x cap, pure dead weight.
  4. Avatar weight drift: 13 avatars over the 20 KB guidance; sam.webp (76,584 B) is nearly at the full-image cap for a 48 px slot.
  5. Image-heavy profiles can exceed 1 MB on full scroll (Mike's 20 latest posts carry 822,856 B of images). Lazy loading protects first paint; the total is over target.
  6. Near-cap cluster: 7 images sit at 90 to 100 KB; any future recompression regression tips them over the cap silently. Two files are also stored twice under different path spellings (/media/local/… and /media/assets/…).
  7. Minor: /dev/ has no og/twitter meta; the SPA fallback returns HTTP 200 for nonexistent paths (client renders NotFound).

What is genuinely good

Back to the Dev Blog index. See also the same day's Full Site Audit: Six Sites.