bliss/chat-rearchitecture.md
Your Name 465cc74796 docs
2026-08-08 16:28:12 -04:00

31 KiB
Raw Permalink Blame History

Chat Rearchitecture — "AIM 2005" edition

Living design doc and build log. Section 18 is the plan; Section 10 is the running notes I update as I build. Keep it honest: record what actually happened, not what was supposed to.


0. Prime directive (read this first)

We are building fun interfaces at the expense of security and safety. This is a hypermedia playground, not a hardened product. Concretely:

  • Messages may contain arbitrary HTML/JS and we render it unescaped, forever. <%~ it.content %> (Eta raw) stays. A message can <script>, restyle the page, animate, embed another Bliss route, whatever. This is a feature and the single most important invariant to preserve. Do not "fix" it. Do not sanitize.
  • We optimize for looseness, composability, and delight over correctness guarantees. When a choice trades safety for a more slippery/joyful interface, take the fun one and leave a note.

Everything below serves that directive.


1. Goals (what the user asked for)

  1. Look like AOL Instant Messenger, ~2005. Really copy it. The current UI is a generic 98.css window with lime-green message area and huge text — wrong. Blue-vs-red color-coded screen names, timestamps, silver beveled chrome, Times New Roman, smiley toolbar. Text is currently way too big — shrink it.
  2. Reliable, quiet presence. Login/logout ("signed in / signed out") notices are unreliable and noisy today. Make them consistent and calm.
  3. Remember who you are. Today it forgets your identity constantly. Should persist across reloads via localStorage, and default to the logged-in Bliss user's username when there's no saved handle.
  4. Web-push notifications that actually work. Today they're half-broken: push is tied to the browser installation, but identity churns, so you get push notifications for your own messages. Fix the self-notify bug and make delivery consistent.
  5. Clean rearchitecture with stricter template separation — pieces that are well-isolated and independently embeddable.
  6. Event-driven composition like the sheepgpt demo. Components listen for a message-send event and emit a message-received event, scoped to their own little uuid/zone (optional query param on the whole-page GET, propagated down to sub-components).
  7. Use individual pieces in isolation:
    • a live chat view with no compose form,
    • a compose form with no live chat,
    • a frozen chat (a fixed time range / snapshot),
    • a single message — a bona fide Bliss route you hx-get.
  8. When a new message arrives, the WebSocket should just tell clients to hx-get that message's route and render it — the socket is a notification bus, not a renderer. (First render may hx-get everything; that's fine.)
  9. Overall feel: loose, slippy, beautiful, simple, composed of small parts, and perfect.

2. Current chat (structure 18, prefix /chat) — inventory & diagnosis

Read from the live brb.city instance via the bliss CLI. Snapshot of what exists and why it misbehaves.

Routes

id verb path role
25 GET / full page (chat template)
22 GET /room (legacy poll)
34 WS /room the socket: message / join / catchup, push fan-out
21 GET /message/:id render one message (getMessageById is broken, see below)
23 POST /message mislabeled — actually saves a push subscription
45 POST /push-subscribe saves push subscription onto alias_id
24 GET /send-box a compose box fragment
47 GET /messages list fragment
50 POST /username rename alias
51 GET /edit settings window (rename + enable notifs)
48 GET /manifest.json PWA manifest
58 GET /service-worker.js push SW
27/28/33 WS/GET /testing,/wstest,/sdfsdf dead scaffolding

Templates

chat (page), room, message, message-raw, messages, send-box, input, join input, sign in message, success, edit-username, catch up messages, secret recent message form, secret auto login form (empty!), say bridge.

DB (chat, db id 2)

aliases(alias_id, user_id, username, created_at, updated_at, push_subscription) and messages(message_id, content, alias_id, created_at, updated_at). Library helpers: createAlias, getOrCreateAlias, createMessage, getAllMessages(before, after, limit), getMessageById (bug), updateUsername, updateAliasSubscription, getAliasSubscription, etc.

Root-cause diagnosis (the bugs, precisely)

  • Forgets you / new identity every join. The join event calls createAlias(username) — a brand-new row every time, stored in req.session.alias_id. There is no localStorage, and the secret auto login form template that was meant to re-join is literally empty. So every fresh session (new tab, cleared cookie, expired session) = a new stranger.
  • Self-notify push bug. Push subscription is written onto whatever alias_id is in session at subscribe time. Later your session's alias_id changes (see above), so sendPushNotifications(msg, senderAliasId) excludes the new alias while your subscription still lives on the old alias → the server pushes your own message back to you. Push is keyed by ephemeral identity instead of by a stable device/endpoint.
  • Noisy/unreliable presence. broadcastSigning fires on every socket open/close. With per-embed sockets, reconnects, and multiple tabs, you get a storm of "guest signed in / signed out". Presence is tied to raw socket lifecycle, not to a debounced notion of a person being present.
  • getMessageById is broken SQL (message.id should be messages.message_id, missing message_id/created_at selects) — so the "render a single message by route" primitive (goal #8) doesn't actually work.
  • Text too big. chat uses text-xl; message area is bg-lime-400; message bubbles are bg-black text-white rounded-lg — a modern chat-bubble look, the opposite of AIM.
  • Weak separation. The page hard-codes /chat/... URLs, mixes transport + UI + PWA + push all in one chat template, and has no zone/uuid scoping, so you can't drop "just the live view" or "just the compose box" somewhere else cleanly, and two instances on one page would collide on DOM ids (#chat_room, #form, ...).

3. The AIM 2005 aesthetic (design target)

Reference: classic AIM IM window. Defining traits (from memory + research):

  • Silver/gray beveled window chrome, blue gradient title bar reading something like "Instant Message" with a little running-man logo vibe.
  • Message transcript area: white background, small text, Times New Roman (or the browser serif). Each line: **ScreenName** (h:mm:ss AM/PM): message — the screen name is bold and color-coded: blue for you, red for the other person(s), timestamp in gray.
  • A formatting toolbar strip (A font, size, B I U, color swatch, smiley face) above the compose box — mostly decorative but should look right. Text emoticons (:-), ;-), :-P) → classic yellow smileys is a nice-to-have.
  • Compose area: a bordered text box + a chunky Send button (Enter sends).
  • Small system lines for presence: ScreenName signed on. / signed off. in gray italics, not bubbles.
  • Small text everywhere (~1213px). This directly fixes "text too large".

Implementation: hand-rolled CSS in the page template's <head> (self-contained, no external CDN beyond what Bliss already injects). Keep it a single small stylesheet so embeds inherit it, or scope it so isolated embeds can bring their own. Decide in Section 7.


4. Architecture — small parts, one event bus, one uuid

4.1 The zone (uuid)

Every whole-page render accepts an optional ?zone=<uuid> query param (generate one if absent). The zone is:

  • a DOM-id namespace — every element id is suffixed -<zone> so two chat instances can share a page without colliding (this is what the current code can't do), and
  • an event namespace — the custom events are chat:send:<zone> / chat:message:<zone> or carry detail.zone and listeners filter. (Leaning toward detail.zone filtering + a bare chat:send / chat:message name, so cross-zone bridging is possible on purpose. TBD in build.)

The page GET propagates zone down into every embedded sub-component's hx-get URL, exactly like sheepgpt propagates zone into the sheep embed.

4.2 The event model (like sheepgpt's chat:say)

Two DOM custom events, dispatched on document.body, bubbling:

  • chat:send — "please send this message." detail: { zone, name?, content }. The compose form dispatches it; anything can dispatch it (that's the composability port — same idea as the existing say bridge / sheepfriend chat:say). A hidden ws-send bridge catches it and pushes it over the socket. → We keep a chat:say alias for backwards-compat with sheepfriend.
  • chat:message — "a message was received/rendered." detail: { zone, message_id, name, content }. Emitted by the client when a new message lands in the transcript (so sibling embeds / bots can react — e.g. sheep hears it). This is the message-received event the user asked for.

So: listen for send, emit received. A live view with no compose box still emits chat:message. A compose form with no live view still dispatches chat:send. They only need to share a zone (or a socket) to talk.

4.3 The socket is a notification bus, not a renderer

New-message flow (goal #8):

  1. Client dispatches chat:send → hidden ws-send form → socket.
  2. Server WS handler: dedup, persist via createMessage, get message_id.
  3. Server broadcasts to all room clients a tiny fragment that is just an hx-get of /message/:id (out-of-band swap appending into the transcript):
    <div id="transcript-<zone>" hx-swap-oob="beforeend">
      <div hx-get="/aim/message/ID?zone=ZONE" hx-trigger="load" hx-swap="outerHTML"></div>
    </div>
    
    The socket never ships message markup — only a pointer. The /message/:id route is the single source of truth for how a message looks.
  4. /message/:id renders the message (unescaped content!) and, on load, dispatches chat:message for that zone.

First page render is allowed to hx-get everything (each message its own lazy hx-get, or one messages batch — batch on first paint for speed, individual hx-get for live arrivals). This makes "a single message" a real, linkable, independently-editable Bliss route — the whole point.

4.4 The isolated, embeddable pieces (each a GET route)

route what it renders form? live?
GET / full IM window (chrome + transcript + toolbar + compose) yes yes
GET /live transcript only, ws-connected, no compose no yes
GET /compose compose form only (+ chat:send bridge), no transcript yes no
GET /frozen?before=&after= a static snapshot of a time/id range, no ws no no
GET /message/:id exactly one message no no

All accept ?zone=. All are droppable into any other template via the standard Bliss embed (<div hx-get="/aim/live?zone=abc" hx-trigger="load"></div>), which is what the inspector's 📋 copy button already produces. Compose that owns no transcript still works because chat:send rides the shared socket/zone.


5. Identity, "remember me", localStorage

Identity resolution order when the page loads (highest priority first):

  1. localStorage aim:handle (what you last called yourself), and aim:client_id (a stable per-browser UUID minted once).
  2. Logged-in Bliss user's username (default when no saved handle). Requires exposing the current user to the sandbox — see Section 6.
  3. "guest" fallback.

Rules:

  • One stable alias per handle, via getOrCreateAlias(username) (already exists) — never createAlias on every join. Renaming updates the row.
  • The client mints aim:client_id (UUID) once and sends it with every send and with every push-subscribe. This is the device/identity key that push exclusion uses, decoupled from the churny alias_id. Fixes self-notify.
  • Auto-rejoin on load: if aim:handle exists, silently register it into the session (no visible "join" form unless the user is truly new and not logged in). The empty secret auto login form template gets a real implementation.
  • Changing your name writes aim:handle and updates the alias; presence and push keep working because they key on client_id, not the name.

6. Push notifications — rearchitecture

Problems: keyed on ephemeral alias_id; self-notify; no dedup by endpoint.

Design:

  • Subscriptions keyed by client_id (stable) and deduped by push endpoint. New table (or reuse aliases.push_subscription but add a push_subs table): push_subs(client_id TEXT, endpoint TEXT PRIMARY KEY, subscription TEXT, handle TEXT, updated_at). Endpoint is globally unique per browser+push service, so re-subscribing updates in place instead of duplicating.
  • On new message, fan out to every subscription **whose client_id != the sender's client_id.** The sender's client_idtravels with thechat:send` payload. → You never get pushed your own message, even if your display name changed, session reset, whatever.
  • Keep the service worker's "app is visible → suppress + clear notifications" behavior (it's good), but make the visibility ping reliable.
  • Payload: { title: name, body: content, zone } so the SW can render Name: message (AIM-ish) and optionally focus the right zone on click.
  • Handle 410 Gone → delete that endpoint row (self-cleaning).

Exposing the logged-in user (needed for §5 default): the sandbox context (bootstrapContext in index.js) currently exposes req, res, eta, vapidPublicKey, etc. req.session.userId is present but the username is not resolvable inside a structure (no access to the main users table). Plan: inject a read-only req.currentUser = { id, username } (looked up via model.getUser) in the app.all("*") handler and the WS dispatch, so every structure can greet the logged-in user. This is the one index.js change and it benefits the whole platform (restart required to pick it up). Decision pending — see Open Questions.


7. Template separation — beefed up

Principles:

  • One concern per template. No template both connects a socket and paints a message and registers push. Split: page-chrome, transcript, message, compose, toolbar, presence-line, ws-bridge, push-setup, identity-boot (localStorage/auto-login), styles.
  • Every rendered id is zone-suffixed. No bare #chat_room / #form.
  • Templates take explicit args, never reach into globals. A template that needs the zone gets it.zone; one that needs a message gets the message fields. This is what makes them embeddable in isolation.
  • URL building goes through route() (already available) so prefix changes don't break embeds; never hard-code /chat/....
  • styles is one self-contained AIM stylesheet, included by full-page renders; isolated embeds (/live, /compose) include a slim shared style partial so they look right on their own too.
  • Keep raw/unescaped message rendering isolated in exactly one place (message template) so the "arbitrary HTML/JS" power is obvious and auditable (auditable for fun, not for locking down).

8. Proposed new structure — route/template map

Build as a new structure (working name aim, prefix /aim) so the live /chat keeps working until this is proven. Clone the chat db (or attach it) so history carries over — decide whether to share the same SQLite file or start clean (see Open Questions).

Routes

  • GET / — full IM window. Accepts ?zone=.
  • GET /live — transcript-only live view (ws, no compose).
  • GET /compose — compose-only (dispatches chat:send).
  • GET /frozen — static range snapshot (?before=&after= or ?since=).
  • GET /message/:id — one message (fixes getMessageById).
  • WS /room — socket bus: send(→persist→broadcast hx-get pointer), presence, catchup. Dedup named/bot messages.
  • POST /push-subscribe — upsert push_subs by endpoint, keyed by client_id.
  • POST /rename — set handle (updates alias, echoes to localStorage client-side).
  • GET /manifest.json, GET /service-worker.js — PWA/push.

Templates page, styles, transcript, message, compose, toolbar, presence-line, ws-bridge (catches chat:send, ships over socket), msg-pointer (the tiny hx-get fragment the socket broadcasts), identity-boot (localStorage + auto-login + emits chat:message wiring), push-setup.

DB — extend chat library with: getMessageById (fixed), messagesInRange, upsertPushSub(client_id, endpoint, sub, handle), deletePushSub(endpoint), subsExcludingClient(client_id). Add push_subs table via a migration fn in the library (the existing pattern: idempotent CREATE TABLE helpers).


9. Phased implementation plan

  • Phase 0 — Doc & scaffold. This file. Create aim structure, attach/clone chat db. ← you are here
  • Phase 1 — Data & single message. Fix getMessageById, add range + push-sub helpers + push_subs migration. Ship GET /message/:id + message template with correct AIM line formatting and unescaped content. Verify a single message renders in isolation.
  • Phase 2 — Live view + socket-as-bus. GET /live + transcript + WS /room broadcasting only msg-pointer hx-gets. Confirm new messages appear by the client hx-getting /message/:id. Emit chat:message on arrival.
  • Phase 3 — Compose + event bus. GET /compose + ws-bridge listening for chat:send (and legacy chat:say). Confirm compose-only and live-only work separately, and together via shared zone.
  • Phase 4 — Identity. identity-boot: localStorage handle + client_id, auto-rejoin, default to logged-in Bliss user. POST /rename. (Depends on the req.currentUser decision.)
  • Phase 5 — Presence. Debounced, deduped signed-on/off lines keyed by person, not socket. Quiet and consistent.
  • Phase 6 — Push. Rework subscription storage + self-notify fix + SW payload. Test on two devices / two client_ids.
  • Phase 7 — AIM chrome & polish. Full styles, toolbar, smileys, small text. GET / assembles all pieces. frozen view.
  • Phase 8 — Compose demo. A page that nests /live + /compose + maybe the sheep, proving the parts compose (an aimgpt analog to sheepgpt).

Each phase: build with the bliss CLI, then verify against brb.city (or local), then log results in Section 10 before moving on.


10. Build log / running notes

Newest entries at the bottom. Record commands, ids, surprises, decisions.

  • 2026-08-06 — Phase 0 kickoff.

    • Instance under edit: brb.city (live; CLI sticky target). Building a new structure rather than mutating #18 so production /chat stays up.
    • Fully inventoried chat #18 (routes/templates/db) and the sheepgpt #42 + sheepfriend #26 event pattern. Root-caused all four reported bugs (forgets-you, self-notify push, noisy presence, + found broken getMessageById). Written up in §2.
    • No standalone "bliss MCP" server exists in this session; driving Bliss via the bliss CLI per project convention.
    • Next: resolve Open Questions (esp. shared vs. fresh DB, and the req.currentUser index.js change), then Phase 1.
  • 2026-08-06 — Decisions locked + scaffold built.

    • Decisions: new aim structure, fresh DB, minimal handcrafted req.currentUser ({id, username}) in index.js (see §11).
    • Created structure aim = id 44, prefix /aim, db chat = id 12.
    • Route id map: GET /=145, GET /live=146, GET /compose=147, GET /frozen=148, GET /message/:id=149, WS /room=150, POST /push-subscribe=151, POST /rename=152, GET /manifest.json=153, GET /service-worker.js=154.
    • Template id map: styles=124, message=125, transcript=126, compose=127, toolbar=128, presence-line=129, ws-bridge=130, msg-pointer=131, identity-boot=132, push-setup=133, page=134, frozen=135.
    • DB library written & smoke-tested (Phase 1 data layer): fresh schema (aliases, messages, push_subs), getMessageById fixed, range/catchup helpers, push-sub upsert keyed by endpoint + subsExcludingClient. Verified alias stability + verbatim HTML storage via REPL, then cleared test rows.
    • Next: styles + message template + GET /message/:id (single-message primitive).
  • 2026-08-06 — Phases 17 built & backend verified on brb.city.

    • All 16 templates + 10 routes written (ids in the maps above). Structure aim #44 is live at https://brb.city/aim/.
    • Design pivot (recorded so §46 match reality): identity is fully client-side. localStorage holds aim:handle + aim:client_id (a stable per-browser UUID). The socket carries them on an identify event (presence) and each send (authorship + push exclusion). The server trusts the client's declared identity — correct for a fun playground, and it kills the "forgets who you are" + session-churn class of bugs outright. No server-side alias/session juggling remains.
    • The four reported bugs, resolved:
      • Forgets you → localStorage handle + one stable alias per handle (getOrCreateAlias), auto-seeded from the logged-in Bliss user.
      • Self-notify push → subscriptions keyed by endpoint, owned by stable client_id; fan-out is subsExcludingClient(senderClientId). Your own device is structurally excluded regardless of name/session changes.
      • Noisy presence → counts kept per person, not per socket, with a 4s debounce on sign-off, so tabs/reconnects don't spam. (arrive/depart in the WS handler.)
      • Broken single-messagegetMessageById rewritten with the correct join; it's now the backbone of the socket-as-pointer-bus design.
    • Socket-as-bus confirmed live via a Node ws client: identify → presence-line broadcast; send → persisted (message #4) + a msg-pointer broadcast of exactly <div hx-get="/aim/message/4?zone=z9" hx-trigger="load" hx-swap="outerHTML"> — no message markup crosses the socket. GET /aim/message/1 renders the AIM line (red bold name, gray timestamp) with unescaped <b>/<marquee> intact. First-render null-slice bug (Eta ASI: an eval block must not start with () found & fixed in transcript.
    • req.currentUser added (minimal {id, username}) in local index.js + getUserById in db.js (syntax-checked). ⚠️ This repo is the local copy; brb.city runs from /home/nodejs/bliss2 on another host — the index.js/db.js change needs deploy + restart there to take effect. Until then templates fall back to "Guest" (graceful; nothing breaks).
    • Seeded one welcome message; cleared all test rows.

Verified vs. remaining

  • Fresh schema + library (alias stability, verbatim HTML, range/catchup/push helpers)

  • GET /message/:id single-message primitive (unescaped content)

  • WS /room: identify→presence, send→persist→pointer broadcast, catchup

  • Full page, /live, /compose, /frozen render without error

  • Socket ships pointers only; message route is the single render source

  • Browser check (Playwright MCP wasn't available this session): the client paths — compose→chat:send→ws-bridge ship, chat:message emit on arrival, localStorage default handle, rename, autoscroll, push subscribe — are written but not yet driven in a real browser. Do this next session.

  • Deploy local index.js/db.js to brb.city + restart (pm2 foo) to enable the logged-in-user default.

  • Phase 8: an aimgpt-style demo page nesting /live + /compose (+ the sheep) to show the parts compose.

  • 2026-08-06 — Browser verification + the send-path bug (found via Playwright).

    • Got Playwright MCP working (it wanted chromium build 1237; symlinked the installed chromium-1234chromium-1237 in ~/.cache/ms-playwright).
    • Sending was broken — real bug, now fixed. Eta's <%= %> HTML-escapes, so var dflt = <%= JSON.stringify('Guest') %>; rendered as var dflt = &quot;Guest&quot;; inside the <script> → "Unexpected token '&'" → the entire ws-bridge script threw → window.aimId undefined → no chat:send listener → messages dispatched into the void (textarea cleared, nothing shipped). Fix: use <%~ %> (raw) for JSON in every script context (ws-bridge, identity-boot, push-setup).
    • Also fixed: _hyperscript has no ternary (?:) — the screen-name span set my innerHTML to (window.aimId ? … : …) threw. Replaced with a plain-JS .aim-screenname painter in identity-boot.
    • Verified in a real browser (0 console errors): typed a message, pressed Enter → full round trip (compose → chat:send → ws-bridge ship → persist → msg-pointer broadcast → hx-get /message/2 → rendered). Presence line "Guest signed on." shows. AIM aesthetic confirmed via screenshot (aim-working.png): blue title bar, silver bevel, small serif transcript, color-coded bold names, gray timestamps, toolbar, compose. <marquee> runs.
    • Note: the Bliss inspector 🔍 button floats over the Send button (Enter still sends). Handle still defaults to "Guest" until the req.currentUser change is deployed to brb.city.
  • 2026-08-06 — Mobile pass.

    • Root cause of "text too small on mobile": no <meta viewport> — page rendered at desktop width and scaled down. Added a mobile head-injection on structure 44 (viewport with user-scalable=no + interactive-widget=resizes-content, theme-color, apple PWA metas, touch-action:manipulation to kill double-tap zoom, favicon + apple-touch-icon links).
    • Bumped fonts (16px base / 13px timestamps / 13px titlebar), enlarged compose (3 rows, min-height 84px, 16px to avoid iOS focus-zoom), safe-area insets on titlebar/compose.
    • AIM favicon: wget'd the real AIM running-man logo (user OK'd for the test instance), generated 32/180/192/512 + .ico via ImageMagick, uploaded to structure 44 (served brb.city/44/*), wired manifest icons + head links.
    • Keyboard resize (user chose PURE CSS, zero JS): layout already shrinks via the flex column (.aim-transcript { flex:1; min-height:0; overflow }) + height:100dvh; interactive-widget=resizes-content makes Android shrink the viewport with no JS. iOS Safari has no CSS-only fix, so it falls back to the browser's shift-up. (Briefly tried a visualViewport script — verified it shrank 844→544px — then removed it per the clean-CSS decision.)
  • 2026-08-06 — File upload (frontend compression).

    • Architecture: compression is 100% frontend (the handler sandbox exposes no fs/sharp/ffmpeg). Images: canvas downscale + JPEG quality loop to <1MB (verified: crushed a 21MB PNG). Videos: ffmpeg.wasm transcode to H.264/AAC mp4 targeting a bitrate from clip duration (verified: 4.6MB → 986KB, playable 640×360). Other types: uploaded as-is → inline <a> link. Images/videos embed as <img>/<video>. Toolbar attach control.
    • Upload path = disk. POST /uploadrequire('files').saveFilepublic/<sid>/<name>, served at /<sid>/<name>; returns same-origin path. Found + fixed a framework bug: saveFile did path.join(__dirname,"public", structureId) with a numeric id (only worked from the workshop route which passes a string) → String() coercion in index.js (reproduced before/after). ⚠️ Needs the index.js deploy to brb.city for uploads to persist live (same deploy as req.currentUser).
    • ffmpeg.wasm hosting: the 5 assets (ffmpeg.js, 814.ffmpeg.js worker, util, core.js, 32MB core.wasm) are uploaded to the structure's files (/44/*, same-origin) and lazy-loaded on first video; the service worker caches them so they load once. Key gotcha (cost several iterations): in ffmpeg.js 0.12, passing classWorkerURL forces a module worker, but the UMD worker chunk is classic (importScripts) → "failed to import ffmpeg-core.js". Fix: load ffmpeg.js same-origin and do NOT pass classWorkerURL — webpack auto publicPath resolves its default (classic, same-origin) worker to /44/814.ffmpeg.js. core/wasm handed in as toBlobURL blobs.
    • iOS caveat: ffmpeg.wasm is memory-bound in WKWebView; short clips fine, very large/long ones may fail. Documented, acceptable for the playground.

How to embed the pieces (the payoff)

<!-- live transcript, no compose -->
<div hx-get="/aim/live?zone=abc"     hx-trigger="load"></div>
<!-- compose only, no transcript -->
<div hx-get="/aim/compose?zone=abc"  hx-trigger="load"></div>
<!-- a frozen slice of history -->
<div hx-get="/aim/frozen?after=0&before=50" hx-trigger="load"></div>
<!-- one message -->
<div hx-get="/aim/message/1?zone=abc" hx-trigger="load"></div>

Rule: one zone == one ws-bridge (socket). Compose raw pieces together only with distinct zones, or just use GET /aim/ (which includes exactly one). Cross-structure port preserved: dispatch chat:say/chat:send on body and a bridge ships it (sheepfriend/sheepgpt keep working).


11. Decisions (resolved 2026-08-06)

  1. New structure aim (prefix /aim). Production /chat #18 stays live and untouched; switch over later once proven.
  2. Fresh DB. Start clean — no message/alias history carried over. Schema built from scratch for this design (messages, aliases, push_subs).
  3. Expose a minimal, handcrafted req.currentUser in index.js. Do not pass the raw Express/session user object into the sandbox. Construct a small read-only plain object — { id, username } (add fields only as a concrete need appears) — looked up from the main users table via model.getUser. Inject it in the app.all("*") handler (and WS dispatch). Requires a restart of the live brb.city process (pm2 foo).
  4. Event scoping: single event name + detail.zone filter (lets cross-zone bridges like the sheep listen broadly). Confirm ergonomics during Phase 3.
  5. PWA/manifest/service-worker: keep existing behavior; only change the push payload and keying (client_id/endpoint).