422 lines
22 KiB
Markdown
422 lines
22 KiB
Markdown
# Chat Rearchitecture — "AIM 2005" edition
|
||
|
||
> Living design doc **and** build log. Section 1–8 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** (~12–13px). 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).
|