eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

πŸ€– verify β€” drive the web app end-to-end against a local build

How to observe a web/chat change in a real browser, fast (no fixtures in the repo; everything is provisioned over the real protocol).

Build + run

go build -o /tmp/lchat .
LCHAT_ADMIN_TOKEN=verifytok LCHAT_DB=/tmp/verify.db ADDR=127.0.0.1:18711 /tmp/lchat &

Provision a member + thread from Node (no UI clicking)

web/static/crypto.js runs under Node. (Node β‰₯20 already HAS globalThis.crypto and it's a getter-only property β€” assigning webcrypto to it throws TypeError: Cannot set property crypto. Just import crypto.js by ABSOLUTE path and use it.)

  1. randomSecret() β†’ deriveInvite(T) β†’ {id, claimHash, claimSecret}.
  2. POST /api/admin/key-invites (Bearer admin) {label, id, wrapped:"", claim_hash}.
  3. POST /api/key-invite/{id}/claim {claim_secret} β†’ {token}.
  4. POST /api/pubkey {token, public_key} β€” generateUserKeypair() returns {publicKey, privateKey} (base64url), not {pub, priv}.
  5. GET /api/roster?token= β†’ {roster: [...]} β€” find yourself by public_key.
  6. generateKey() β†’ {key, b64}; wrapThreadKey(b64, myPub); POST /api/threads {token, kind:"group", name_blob: encrypt(key,{name}), members:[{user_id, wrapped}]} β€” a group of one is valid and mounts the real chat engine (a self thread mounts a mini-app instead).

⚠️ The thread-id field names differ by direction, and getting one wrong fails SILENTLY as an empty result, not an error:

  • POST /api/threads returns {thread_id} (not id).
  • GET /api/messages?token=…&thread=… takes thread (not thread_id); a wrong/absent one 403s {"error":"not a thread member"}, so a naive (r.messages || []).length reads as 0 messages and looks like your feature failed to post. Assert on the count of messages only after checking the response isn't an error β€” or read the DB (sqlite3 /tmp/verify.db "select thread_id, count(*) from messages group by thread_id;"), which never lies.

Drive it in Chromium (Playwright)

Seed the session before page scripts run, then assert on the live DOM:

await ctx.addInitScript(([sess, tid]) => {
  localStorage.setItem("lchat_session", JSON.stringify(sess)); // {token,name,userPub,userPriv}
  localStorage.setItem("lchat_active", tid);
}, [session, threadId]);
  • Live selectors: .chatapp-root .composer .composer-input, .chatapp-root .send-btn, .chatapp-root .composer input[type=file], .chatapp-feed .msg, .composer-preview .preview-chip. (The id-based #feed/#text skeleton is the retired classic chat β€” always empty.)
  • The composer is a ProseMirror contenteditable, not a <textarea> β€” so .fill() doesn't work (the locator never resolves and you get a 30s timeout). Click it and type: await page.locator(".composer .composer-input").click(); await page.keyboard.type("/gif cats"); await page.keyboard.press("Enter");
  • Panels are dismissed, not removed: after panel.close() the .cmd-panel element survives as an empty container β€” assert isVisible() === false (or that its body is gone), never count() === 0.
  • Reset the DB between runs. State persists, so a second run of a "first-launch asks for X" test finds X already set and the form never appears.
  • External APIs: ctx.route("**://*.giphy.com/**", …) to fulfil a fake page + bytes β€” lets the whole tap-to-post path run with no real key, and the route handler can record the api_key each call used (which is how you prove which key a search read).
  • Slow/failing uploads: page.route("**/api/upload*", …) with a delay or a 500 β€” perfect for in-flight-upload races.
  • alert() paths: register page.on("dialog", …) or the page wedges.
  • Test users render as "Someone" unless they publish encrypted labels β€” harmless.
  • A known Playwright install lives at ~/.npm/_npx/e41f203b7505f1fb/node_modules/playwright/index.mjs (browsers in ~/Library/Caches/ms-playwright); npx playwright works too.

Worked example (provision + 14-assertion drive): the scripts from the send-waits-for-upload verification β€” re-create from this recipe.

⚠️ Fixtures must never be a fresh space's first boot

The FIRST member to boot a fresh instance mints its shared instance key (#785). A throwaway harness member doing that β€” then vanishing β€” orphans the key: real members who join later stay keyless forever (invites break, names don't decrypt). So when a canary will be handed to a person afterwards, let THEM claim first; or reset the instance DB before handover; or check GET /api/instance-key shows your fixture isn't the sole holder you're about to destroy.