bliss/chat-rearchitecture.md
2026-08-06 14:36:21 -04:00

422 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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>U</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):
```html
<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_id` travels with the `chat: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).
---
## 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).