feat: add current user to req

This commit is contained in:
Your Name 2026-08-06 14:36:21 -04:00
parent 48ac1249c8
commit 5299bd34ee
3 changed files with 447 additions and 0 deletions

422
chat-rearchitecture.md Normal file
View file

@ -0,0 +1,422 @@
# 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).

5
db.js
View file

@ -55,6 +55,10 @@ function getUser(username) {
return db.prepare("SELECT * from users where username = ?").get(username);
}
function getUserById(id) {
return db.prepare("SELECT id, username FROM users WHERE id = ?").get(id);
}
// _ _ _______ ______ ___ _ _______ __ __ _______ _______
// | | _ | || || _ | | | | || || | | || || |
// | || || || _ || | || | |_| || _____|| |_| || _ || _ |
@ -714,6 +718,7 @@ module.exports = {
getDbInstance,
getAllRoutes,
getUser,
getUserById,
createUser,
getStructures,
getStructure,

View file

@ -135,6 +135,20 @@ function makeLibs(structureId, console) {
};
}
// A small, safe projection of the logged-in user for user-route sandboxes.
// Returns null when nobody is logged in. Never leak the password hash or the
// raw session — hand structures exactly what they need to say "hi <name>".
function currentUserFor(req) {
const id = req && req.session && req.session.userId;
if (!id) return null;
try {
const u = model.getUserById(id);
return u ? { id: u.id, username: u.username } : null;
} catch (e) {
return null;
}
}
function bootstrapContext(structureId, routeId, initContext) {
const structure = model.getStructure(structureId);
const console = makeConsole(structureId, routeId);
@ -246,6 +260,7 @@ wsRouter.ws("*", (ws, req) => {
});
ws.clients = clients;
req.currentUser = currentUserFor(req);
ws.render = (template, context = {}) => {
const structure = model.getStructure(route.structure_id);
context.route = makeRoute(structure);
@ -867,6 +882,11 @@ app.all("*", async (req, res) => {
req.params = routeMatch.params;
const route = routeMatch.route;
// Minimal, handcrafted view of the logged-in Bliss user for user routes.
// Deliberately NOT the raw session/user object — just {id, username} —
// so structures can greet whoever is logged in without exposing internals.
req.currentUser = currentUserFor(req);
const structure = model.getStructure(route.structure_id);
try {