chat.posta.no — browser chat client for posta-server (PRD) #2
Labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
posta/chat#2
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
PRD — chat.posta.no (browser chat client for posta-server)
Problem Statement
Arne and his friends each run a personal
posta-serverinstance(arne.posta.no, marcus.posta.no, …) and exchange messages over the posta
wire protocol. They have a native iOS app and a terminal TUI, but no
browser-based way to read or send messages.
That gap hurts in three concrete ways. Non-Apple users (Android phones,
Linux laptops) have no posta UI at all. Apple users away from their own
device — borrowed laptop, work computer, family-member's iPad without
the app installed — can't reach their inbox. And there is no answer for
"I'd like to use this from a browser even when I do have the app
installed."
Solution
chat.posta.no— a small Go service that runs at a single host and actsas a multi-tenant Backend-For-Frontend over each user's posta-server.
The browser speaks HTMX-flavoured HTML to chat.posta.no; chat.posta.no
holds the user's session and proxies the posta-server REST + SSE API on
their behalf. Browsers never call posta-server directly, which avoids
CORS configuration on every peer and keeps bearer tokens out of
JavaScript reach.
Authentication is passkey-only. A user is provisioned by the admin: the
admin mints a one-shot invite, the recipient pastes the
posta+v1://host#token=…URI printed byposta-server token create,the URI is validated against the user's own posta-server, and a passkey
is registered. The user's posta bearer token is encrypted at rest with a
key derived from a WebAuthn PRF extension output — chat.posta.no stores
ciphertext only and cannot decrypt any user's token without a live
passkey assertion.
Real-time updates flow as opaque trigger events (posta-server SSE →
chat.posta.no fan-out → htmx-sse extension in the browser → normal
hx-getre-renders). The render path on update is the same as on firstpaint, so the UI has one consistent rendering surface.
User Stories
Admin (operator of chat.posta.no)
the host, so that I can provision specific friends without exposing
a public signup form.
so that I can tell at a glance who an invite was for.
that I can see who has registered and who is pending.
back an invite I sent in error.
invite links can't be used long after they were sent.
who has accounts.
someone who should no longer have access.
binary deployable to a Caddy-fronted VPS, so that operations match
posta-server and the marketing site.
Invitee (registering for the first time)
can begin registration without installing anything.
posta+v1://host#token=…URI from my posta-server CLI output, sothat I don't have to enter the URL and token separately.
works against my posta-server before consuming my invite, so that a
typo doesn't burn my one-shot link.
flow, so that subsequent logins require no token re-entry.
chat.posta.no cannot use it without me being present (passkey
assertion required), so that a compromise of the chat.posta.no
database alone does not grant access to my posta-server.
cancelled, token rejected) to leave my invite reusable, so that I
can try again without going back to the admin.
Returning user (sign-in)
my devices that has it synced, so that I don't have to re-enter a
token or password.
refreshes without re-tapping my passkey, so that the app feels like
a normal site.
of idleness, so that a walked-away laptop loses access reasonably
quickly.
days regardless of activity, so that long-lived sessions can't drift
indefinitely.
end my session deliberately on a shared computer.
restarted and re-authentication is required, so that I'm not
confused by being kicked to the login page.
Conversations (the main UI)
by most-recent activity, so that the people I talk to most are at
the top.
with a count, so that I can see at a glance which conversations
need attention.
moment I open a conversation, so that the badge accurately reflects
my attention.
currently viewing to not increment the unread count, so that the
badge doesn't blink up and back down.
so that reading on my iPhone clears the badge on my laptop's
browser without action.
fallback avatars (2-letter initials in a deterministically-coloured
circle) when a peer has no avatar set, so that the sidebar is
visually consistent.
I log in, so that the app picks up where I left off.
no conversations yet, so that I know how to start one.
Thread rendering
own messages right-aligned and inbound messages left-aligned, so
that I can see at a glance who said what.
sender within 2 minutes visually clustered (single avatar, tight
spacing), so that a conversation reads as turns, not flat rows.
crossing a calendar boundary, so that I can orient by day.
indicator (clock for pending/sending, check for delivered, warning
for failed), so that I know whether my words reached the peer.
even though I cannot retry them in v1, so that I know to act on
them outside the UI.
so that I can click them without copy-pasting.
so that paragraph structure survives.
Composing text
thread, so that I can type without looking for it.
insert a newline, so that my muscle memory from every other chat
app works here.
composer to do nothing, so that I can't ship phantom messages.
lines and scroll beyond, so that I can compose multi-paragraph
messages without the layout collapsing.
Composing images
picker button, so that I can choose a file from my disk.
the clipboard, so that screenshots ship in one gesture.
dropping onto the composer, so that the natural drag gesture works.
mode" the moment an image is attached (text field hidden, image
preview shown), so that the UI accurately reflects that one
posta message is one payload kind.
and return focus to the textarea, so that I can naturally type a
caption as the next message.
sending it, so that "I changed my mind" works without leaving
orphan bytes on my posta-server.
in the bubble at bubble-width with a sensible height cap, so that
images don't blow the layout.
resolution in a new tab, so that I can see detail or save the file.
gracefully (small placeholder, peer hostname caption), so that a
dead peer doesn't break my UI.
based on the payload's
altornamefield with a sensiblefallback, so that images are accessible.
Starting a new conversation
a new conversation, so that the affordance is obvious.
a conversation, so that I don't have to wade through a multi-field
form.
actor doc and show me their display name before I commit, so that
I know I have the right URL.
on first send (not when I type their URL), so that abandoned
"I'll talk to them later" intents don't clutter my sidebar.
Live updates
open thread within a second of the peer sending them, so that the
app feels alive.
when a new peer messages me, so that I notice without polling.
open tabs of chat.posta.no, so that reading on one tab updates
the others.
restart transparently (reconnect, resync), so that I don't have
to refresh manually.
Mobile
below ~768px wide, so that I can use the app on my phone.
return me to the contact list, so that I have an explicit
navigation affordance.
to actually send, so that I don't have to reach for a separate
send button.
keyboard, so that I can see what I'm typing.
rows) sized for fingers, so that I don't mistap.
Visuals
identity of posta.no (cream/forest/sky/terracotta palette, Crimson
Pro + IBM Plex), so that it feels like part of the posta family.
chat.posta.no respects my system preference.
Implementation Decisions
Architecture
The browser only talks to chat.posta.no. chat.posta.no is a Go binary
behind Caddy that holds session state and proxies a typed subset of the
posta-server REST API + SSE event stream per logged-in user. Per-user
SSE-to-posta-server connections are opened lazily on first browser tab
subscription, refcounted across that user's tabs, and torn down after
a short grace period when refcount falls to zero. Posta-server SSE
events are translated into small opaque trigger events fanned out to
each subscribed browser tab; the browser's htmx layer responds by
firing a normal
hx-getagainst chat.posta.no, which re-renders therelevant HTML fragment. There is one canonical render path per fragment,
used identically on first paint and on live update.
Modules
cmd/chat-posta— cobra root. Subcommands:serve,invite create,invite list,invite revoke,user list,user delete,version.internal/web— HTTP handlers,html/templatefragments, htmxrouting. Distinguishes full-page from partial renders via the
HX-Requestheader. Persistent sidebar chrome; main-pane swapsdriven by
hx-push-url.internal/auth— WebAuthn (passkey) ceremonies with the PRFextension; AES-GCM token encryption using a key derived from the
PRF output; session lookup against an in-memory store keyed by an
opaque random cookie sessionID. 30-minute idle eviction, 7-day
absolute cap, server restart wipes.
internal/posta— typed Go client for posta-server's REST API.Methods per endpoint (identity, contacts, messages, uploads,
events). Streams upload bodies through (no buffering). Holds the
bearer token for the per-request
Authorizationheader only;never persists it.
internal/sse— per-user posta-server SSE multiplexer. Refcountssubscribers, holds Last-Event-ID for resumption, handles
posta-server's
resyncevent by forwarding a "re-render now"trigger to all subscribed tabs.
internal/store— SQLite schema and queries for users(one row per registered user, holding
serverURL,credentialID,PRF salt, token ciphertext) and invites (random code, name, TTL,
consumed flag).
internal/admin— invite/user CRUD callable from the cobrasubcommands.
internal/config— TOML + env + flag layering (precedence: flag >env > config > default). Mirrors posta-server's config pattern.
Schema
Two SQLite tables:
users:id,name(label set by admin at invite time),server_url,credential_id,credential_public_key,prf_salt,token_ciphertext,token_nonce,created_at,last_seen_at.invites:code,name,created_at,expires_at,consumed_at(null if outstanding),
user_id(null until consumed).Sessions are RAM only — not in SQLite. Map keyed by opaque sessionID
cookie value to a struct containing
{userID, plaintextToken, serverURL, createdAt, lastActivity}.Crypto and auth
prfextension on both registrationand authentication. A single static PRF salt (configured per
deployment) provides domain separation. The 32-byte PRF output is
used directly as an AES-256-GCM key. A fresh per-message random
nonce is generated for each encryption. Both ciphertext and nonce
are stored.
(iCloud Keychain, Bitwarden, 1Password, Google Password Manager).
Multi-credential per user is not supported in v1.
encoded as base64url. No HMAC needed because the cookie carries no
encoded state — it's an opaque lookup key.
Signup flow
chat-posta invite create --name "Marcus".https://chat.posta.no/register?code=<random>. Admin sendsthis to the recipient out-of-band.
posta-server token createon their own host, getsa
posta+v1://host#token=…URI.asking for the URI plus a "Continue" button.
(serverURL, token), makes aGET {serverURL}/api/v1/identitywith the bearer token. On 200(or whatever success-meaning response): proceeds. On 401: shows
"token rejected by your posta-server." On non-2xx: shows
"couldn't reach your posta-server."
extension; chat.posta.no validates the attestation and stores the
credential plus the encrypted token; the invite is marked consumed.
Render dispatch
Handlers detect
HX-Request: trueand render only the relevantfragment; without that header, the same handler renders the full page
(layout chrome + fragment) for refresh/direct-link/cold-load semantics.
The sidebar is included in the layout chrome on full-page renders and
not re-emitted on htmx partial renders, so navigation between threads
swaps only the main pane and the sidebar stays mounted with stable
SSE-triggered refresh state.
Live-update wire shape
Browser tabs open
GET /events(cookie-authenticated) and use thehtmx
sseextension. The backend's per-tab SSE writer emits smalltrigger events corresponding to posta-server SSE events:
thread-updated(peer URL in data) → main-pane re-renders if thatpeer is currently open.
contact-changed→ sidebar re-renders.contact-removed→ sidebar re-renders.read-watermark-changed→ sidebar re-renders (unread counts).identity-changed→ sidebar header re-renders.resync→ all panes re-render.The HTML element layout uses
hx-trigger="sse:<event-name> from:body"on each component to react independently to the SSE stream.
Read watermark
Opening a thread marks all visible inbound messages read by POSTing
/api/v1/contacts/readwith the highest visiblerowId. While a tabhas a thread open, the backend records that peer as the tab's "active
peer" (a small per-session bookkeeping field) and auto-advances the
watermark whenever an
inboundevent for that peer arrives, so theunread count for the open thread never blinks up.
Image messages
Composition for an image uses
multipart/form-datato the backend; thebackend streams the bytes through to posta-server's
POST /api/v1/uploadsand, on success, takes the returned URL andissues a
POST /api/v1/messageswith aposta.link/v1payloadreferencing it. Image upload and message send are not atomic from the
posta-server perspective, but they are atomic from the user's: the
backend reports failure if either step fails, and never sends the
posta.link/v1message if the upload failed.Inbound images render as
<img>withloading="lazy",decoding="async", anonerrorhandler that swaps to a.brokenstate, and a parent
<a target="_blank" rel="noopener">so clickopens the original URL. The image bytes are served by the peer's
posta-server with
Cache-Control: public, max-age=31536000, immutableso the browser caches them directly — chat.posta.no never proxies the
image read path.
Visual design
The
:rootCSS custom properties from/web/static/style.cssarelifted verbatim (palette, type scale, radii, shadows) plus
@font-facedeclarations for Crimson Pro and IBM Plex Sans/Mono. Thedark-mode variants under
@media (prefers-color-scheme: dark)arealso lifted. The marketing site's hero gradients and paper-grain
overlay are not lifted — they're wrong for a dense interactive UI.
Chat-specific layouts (bubbles, sidebar, composer, mobile responsive
breakpoints at 768px) are written fresh on top of those tokens.
Operational layout
/usr/local/bin/chat-posta./var/lib/chat-posta/state.db./etc/chat-posta/config.toml(overridable byCHAT_POSTA_*env vars and--flags).:80behind Caddy that handles TLS for chat.posta.no.user must be re-invited and re-paste their token (the PRF-encrypted
ciphertexts are gone, and PRF outputs are useless without the
matching ciphertext).
Testing Decisions
A good test in this codebase exercises external behavior, not
implementation details. The store doesn't get tested by reading
back struct fields; it gets tested by inserting a user, encrypting
a token, decrypting it again, and asserting the plaintext round-trips
through the schema. Auth doesn't get tested by mocking the WebAuthn
library; it gets tested by feeding the auth module a canned PRF output
and asserting that the same output decrypts the same ciphertext
across a "register → store → retrieve → authenticate" round trip.
Unit-tested modules:
internal/store— schema migrations apply cleanly to a freshin-memory SQLite; CRUD operations on
usersandinviteswork asspecified; invite consumption is atomic; uniqueness constraints
reject duplicate credentialIDs.
internal/auth— PRF-derived AES-GCM round trip (encrypt with keyK, decrypt with key K, plaintext matches). Ceremony state machine
rejects malformed challenges. Session map honors 30-minute idle
eviction and 7-day absolute cap. Concurrent reads/writes are
race-free.
internal/sse— refcount lifecycle (open on first subscribe, closeon last unsubscribe after grace period), Last-Event-ID resumption,
resyncforwarding, behavior on posta-server SSE connection failure(reconnect with backoff).
Integration-tested (via
httptestand a fake posta-server):internal/webhandlers — end-to-end request/response throughcookie session, posta client, and template rendering. Includes
htmx partial-vs-full render dispatch behavior.
internal/adminCLI commands — exercise the cobra subcommandsagainst a real SQLite test database.
Not unit-tested:
internal/posta(covered byinternal/webintegration tests against the fake posta-server);
cmd/chat-posta(wiring code).
Prior art for test shape lives in posta-server's
internal/api/*_test.goandinternal/store/*_test.go— sametable-driven style, same
httptestpatterns.Out of Scope
The following are deliberately deferred past v1:
own posta-server CLI.
PUT /api/v1/identity/avataris not surfaced.Users with no avatar render with initials fallback.
shows the failure but no in-UI retry is provided. Users invoke
posta-serverdirectly.who want to scrub a contact use the posta-server CLI.
GET /api/v1/searchis not surfaced. Userssearch by scrolling or via the TUI/iOS app.
foreground SSE only.
without alt. Inbound alt is read from the payload if present.
app is the offline-capable client.
authenticators that don't sync require a fresh invite per device.
host CLI only.
separate posta messages, sent in sequence by the user.
Further Notes
file on disk. No background workers, no cron, no external services
beyond Caddy in front.
never calls posta-server directly, no
Access-Control-Allow-Originconfiguration is needed on any posta-server instance.
re-invited and re-pastes their pairing URI. No secrets are stranded
because the PRF outputs that decrypt the lost ciphertexts can no
longer reach matching ciphertexts. Backups are still recommended for
UX (saves the re-invite cycle).
the user's ciphertext on chat.posta.no becomes unrecoverable. The
user revokes the affected posta-server token (
posta-server token revoke) for hygiene and asks the admin for a fresh invite. Posta'stoken design treats this as a cheap re-pair, not a data-loss event.
embed.FSinto the binary for self-contained deployment, matching posta-server
and the marketing site's pattern.
code.bas.es/posta/chat.