chat.posta.no — browser chat client for posta-server (PRD) #2

Open
opened 2026-05-14 22:56:32 +02:00 by arne · 0 comments
Owner

PRD — chat.posta.no (browser chat client for posta-server)

Problem Statement

Arne and his friends each run a personal posta-server instance
(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 acts
as 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 by posta-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-get re-renders). The render path on update is the same as on first
paint, so the UI has one consistent rendering surface.

User Stories

Admin (operator of chat.posta.no)

  1. As the admin, I want to mint a one-shot invite link from a CLI on
    the host, so that I can provision specific friends without exposing
    a public signup form.
  2. As the admin, I want to attach a human-readable name to each invite,
    so that I can tell at a glance who an invite was for.
  3. As the admin, I want to list outstanding and consumed invites, so
    that I can see who has registered and who is pending.
  4. As the admin, I want to revoke an unused invite, so that I can pull
    back an invite I sent in error.
  5. As the admin, I want invites to expire after 24 hours, so that stale
    invite links can't be used long after they were sent.
  6. As the admin, I want to list registered users, so that I can audit
    who has accounts.
  7. As the admin, I want to delete a user, so that I can off-board
    someone who should no longer have access.
  8. As the admin, I want the chat.posta.no service to be a single
    binary deployable to a Caddy-fronted VPS, so that operations match
    posta-server and the marketing site.

Invitee (registering for the first time)

  1. As an invitee, I want to open my invite URL in a browser, so that I
    can begin registration without installing anything.
  2. As an invitee, I want a single text field that accepts the
    posta+v1://host#token=… URI from my posta-server CLI output, so
    that I don't have to enter the URL and token separately.
  3. As an invitee, I want chat.posta.no to verify that my pasted URI
    works against my posta-server before consuming my invite, so that a
    typo doesn't burn my one-shot link.
  4. As an invitee, I want to register a passkey as part of the same
    flow, so that subsequent logins require no token re-entry.
  5. As an invitee, I want my bearer token stored encrypted so that
    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.
  6. As an invitee, I want a failed registration (passkey ceremony
    cancelled, token rejected) to leave my invite reusable, so that I
    can try again without going back to the admin.

Returning user (sign-in)

  1. As a returning user, I want to sign in with my passkey from any of
    my devices that has it synced, so that I don't have to re-enter a
    token or password.
  2. As a returning user, I want my session to last across casual page
    refreshes without re-tapping my passkey, so that the app feels like
    a normal site.
  3. As a returning user, I want my session to expire after 30 minutes
    of idleness, so that a walked-away laptop loses access reasonably
    quickly.
  4. As a returning user, I want my session to expire absolutely after 7
    days regardless of activity, so that long-lived sessions can't drift
    indefinitely.
  5. As a returning user, I want to explicitly log out, so that I can
    end my session deliberately on a shared computer.
  6. As a returning user, I want to know when chat.posta.no has
    restarted and re-authentication is required, so that I'm not
    confused by being kicked to the login page.

Conversations (the main UI)

  1. As a signed-in user, I want a sidebar listing my contacts ordered
    by most-recent activity, so that the people I talk to most are at
    the top.
  2. As a signed-in user, I want each contact to show an unread badge
    with a count, so that I can see at a glance which conversations
    need attention.
  3. As a signed-in user, I want unread counts to drop to zero the
    moment I open a conversation, so that the badge accurately reflects
    my attention.
  4. As a signed-in user, I want inbound messages to a thread I'm
    currently viewing to not increment the unread count, so that the
    badge doesn't blink up and back down.
  5. As a signed-in user, I want my read state to sync across devices,
    so that reading on my iPhone clears the badge on my laptop's
    browser without action.
  6. As a signed-in user, I want my contacts list to render with
    fallback avatars (2-letter initials in a deterministically-coloured
    circle) when a peer has no avatar set, so that the sidebar is
    visually consistent.
  7. As a signed-in user, I want to land on my most-recent thread when
    I log in, so that the app picks up where I left off.
  8. As a brand-new user, I want a clear empty-state hint when I have
    no conversations yet, so that I know how to start one.

Thread rendering

  1. As a signed-in user, I want messages displayed as bubbles with my
    own messages right-aligned and inbound messages left-aligned, so
    that I can see at a glance who said what.
  2. As a signed-in user, I want consecutive messages from the same
    sender within 2 minutes visually clustered (single avatar, tight
    spacing), so that a conversation reads as turns, not flat rows.
  3. As a signed-in user, I want date dividers between messages
    crossing a calendar boundary, so that I can orient by day.
  4. As a signed-in user, I want outbound messages to show a status
    indicator (clock for pending/sending, check for delivered, warning
    for failed), so that I know whether my words reached the peer.
  5. As a signed-in user, I want failed sends to be visually distinct
    even though I cannot retry them in v1, so that I know to act on
    them outside the UI.
  6. As a signed-in user, I want URLs in plain-text messages auto-linked,
    so that I can click them without copy-pasting.
  7. As a signed-in user, I want newlines in plain-text bodies preserved,
    so that paragraph structure survives.

Composing text

  1. As a signed-in user, I want a text composer at the bottom of the
    thread, so that I can type without looking for it.
  2. As a signed-in user, I want Enter to send and Shift+Enter to
    insert a newline, so that my muscle memory from every other chat
    app works here.
  3. As a signed-in user, I want pressing Enter on an empty/whitespace
    composer to do nothing, so that I can't ship phantom messages.
  4. As a signed-in user, I want the composer to auto-grow up to a few
    lines and scroll beyond, so that I can compose multi-paragraph
    messages without the layout collapsing.

Composing images

  1. As a signed-in user, I want to attach an image by clicking a file
    picker button, so that I can choose a file from my disk.
  2. As a signed-in user, I want to attach an image by pasting from
    the clipboard, so that screenshots ship in one gesture.
  3. As a signed-in user, I want to attach an image by dragging-and-
    dropping onto the composer, so that the natural drag gesture works.
  4. As a signed-in user, I want the composer to switch to "image
    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.
  5. As a signed-in user, I want sending an image to clear the composer
    and return focus to the textarea, so that I can naturally type a
    caption as the next message.
  6. As a signed-in user, I want to discard an attached image without
    sending it, so that "I changed my mind" works without leaving
    orphan bytes on my posta-server.
  7. As a signed-in user, I want inbound image messages rendered inline
    in the bubble at bubble-width with a sensible height cap, so that
    images don't blow the layout.
  8. As a signed-in user, I want to click an image to open the full
    resolution in a new tab, so that I can see detail or save the file.
  9. As a signed-in user, I want broken-image references handled
    gracefully (small placeholder, peer hostname caption), so that a
    dead peer doesn't break my UI.
  10. As a signed-in user, I want screen-reader alt text for images
    based on the payload's alt or name field with a sensible
    fallback, so that images are accessible.

Starting a new conversation

  1. As a signed-in user, I want a "+" button in the sidebar to start
    a new conversation, so that the affordance is obvious.
  2. As a signed-in user, I want to enter just a peer URL to start
    a conversation, so that I don't have to wade through a multi-field
    form.
  3. As a signed-in user, I want chat.posta.no to look up the peer's
    actor doc and show me their display name before I commit, so that
    I know I have the right URL.
  4. As a signed-in user, I want the new contact created implicitly
    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

  1. As a signed-in user, I want new inbound messages to appear in the
    open thread within a second of the peer sending them, so that the
    app feels alive.
  2. As a signed-in user, I want the sidebar to update in real time
    when a new peer messages me, so that I notice without polling.
  3. As a signed-in user, I want my state consistent across multiple
    open tabs of chat.posta.no, so that reading on one tab updates
    the others.
  4. As a signed-in user, I want chat.posta.no to handle a posta-server
    restart transparently (reconnect, resync), so that I don't have
    to refresh manually.

Mobile

  1. As a mobile user, I want the layout to collapse to a single column
    below ~768px wide, so that I can use the app on my phone.
  2. As a mobile user, I want a back chevron in the thread view to
    return me to the contact list, so that I have an explicit
    navigation affordance.
  3. As a mobile user, I want the on-screen keyboard's "send" button
    to actually send, so that I don't have to reach for a separate
    send button.
  4. As a mobile user, I want the composer pinned above the on-screen
    keyboard, so that I can see what I'm typing.
  5. As a mobile user, I want tap targets (bubbles, buttons, contact
    rows) sized for fingers, so that I don't mistap.

Visuals

  1. As a signed-in user, I want chat.posta.no to share the visual
    identity of posta.no (cream/forest/sky/terracotta palette, Crimson
    Pro + IBM Plex), so that it feels like part of the posta family.
  2. As a signed-in user, I want OS-driven dark mode, so that
    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-get against chat.posta.no, which re-renders the
relevant 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/template fragments, htmx
    routing. Distinguishes full-page from partial renders via the
    HX-Request header. Persistent sidebar chrome; main-pane swaps
    driven by hx-push-url.
  • internal/auth — WebAuthn (passkey) ceremonies with the PRF
    extension; 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 Authorization header only;
    never persists it.
  • internal/sse — per-user posta-server SSE multiplexer. Refcounts
    subscribers, holds Last-Event-ID for resumption, handles
    posta-server's resync event 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 cobra
    subcommands.
  • 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

  • WebAuthn relying party uses the prf extension on both registration
    and 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.
  • Synced passkeys only. Multi-device works via authenticator sync
    (iCloud Keychain, Bitwarden, 1Password, Google Password Manager).
    Multi-credential per user is not supported in v1.
  • The cookie sessionID is 32 bytes of cryptographically random data
    encoded as base64url. No HMAC needed because the cookie carries no
    encoded state — it's an opaque lookup key.

Signup flow

  1. Admin runs chat-posta invite create --name "Marcus".
  2. Output: https://chat.posta.no/register?code=<random>. Admin sends
    this to the recipient out-of-band.
  3. Recipient runs posta-server token create on their own host, gets
    a posta+v1://host#token=… URI.
  4. Recipient opens the invite URL; the page shows a single textarea
    asking for the URI plus a "Continue" button.
  5. On submit, chat.posta.no parses URI → (serverURL, token), makes a
    GET {serverURL}/api/v1/identity with 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."
  6. Browser initiates passkey registration ceremony with the PRF
    extension; chat.posta.no validates the attestation and stores the
    credential plus the encrypted token; the invite is marked consumed.
  7. Redirect to the inbox.

Render dispatch

Handlers detect HX-Request: true and render only the relevant
fragment; 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 the
htmx sse extension. The backend's per-tab SSE writer emits small
trigger events corresponding to posta-server SSE events:

  • thread-updated (peer URL in data) → main-pane re-renders if that
    peer 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/read with the highest visible rowId. While a tab
has 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 inbound event for that peer arrives, so the
unread count for the open thread never blinks up.

Image messages

Composition for an image uses multipart/form-data to the backend; the
backend streams the bytes through to posta-server's
POST /api/v1/uploads and, on success, takes the returned URL and
issues a POST /api/v1/messages with a posta.link/v1 payload
referencing 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/v1 message if the upload failed.

Inbound images render as <img> with loading="lazy",
decoding="async", an onerror handler that swaps to a .broken
state, and a parent <a target="_blank" rel="noopener"> so click
opens the original URL. The image bytes are served by the peer's
posta-server with Cache-Control: public, max-age=31536000, immutable
so the browser caches them directly — chat.posta.no never proxies the
image read path.

Visual design

The :root CSS custom properties from /web/static/style.css are
lifted verbatim (palette, type scale, radii, shadows) plus
@font-face declarations for Crimson Pro and IBM Plex Sans/Mono. The
dark-mode variants under @media (prefers-color-scheme: dark) are
also 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

  • Single binary at /usr/local/bin/chat-posta.
  • SQLite database at /var/lib/chat-posta/state.db.
  • Config at /etc/chat-posta/config.toml (overridable by
    CHAT_POSTA_* env vars and -- flags).
  • Listens on :80 behind Caddy that handles TLS for chat.posta.no.
  • Logs to stderr (captured by systemd journal).
  • Backups: the SQLite file. Loss is recoverable but painful — every
    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 fresh
    in-memory SQLite; CRUD operations on users and invites work as
    specified; invite consumption is atomic; uniqueness constraints
    reject duplicate credentialIDs.
  • internal/auth — PRF-derived AES-GCM round trip (encrypt with key
    K, 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, close
    on last unsubscribe after grace period), Last-Event-ID resumption,
    resync forwarding, behavior on posta-server SSE connection failure
    (reconnect with backoff).

Integration-tested (via httptest and a fake posta-server):

  • internal/web handlers — end-to-end request/response through
    cookie session, posta client, and template rendering. Includes
    htmx partial-vs-full render dispatch behavior.
  • internal/admin CLI commands — exercise the cobra subcommands
    against a real SQLite test database.

Not unit-tested: internal/posta (covered by internal/web
integration tests against the fake posta-server); cmd/chat-posta
(wiring code).

Prior art for test shape lives in posta-server's
internal/api/*_test.go and internal/store/*_test.go — same
table-driven style, same httptest patterns.

Out of Scope

The following are deliberately deferred past v1:

  • Identity editing — name, about. Users edit identity via their
    own posta-server CLI.
  • Avatar uploadPUT /api/v1/identity/avatar is not surfaced.
    Users with no avatar render with initials fallback.
  • Retry of failed-pending-user messages — the status indicator
    shows the failure but no in-UI retry is provided. Users invoke
    posta-server directly.
  • Removing contacts — the sidebar lists; it does not delete. Users
    who want to scrub a contact use the posta-server CLI.
  • Full-text searchGET /api/v1/search is not surfaced. Users
    search by scrolling or via the TUI/iOS app.
  • Image lightbox — click-to-new-tab is the v1 affordance.
  • Push notifications and sound alerts — not implemented;
    foreground SSE only.
  • Compose-time alt-text for outbound images — outbound images go
    without alt. Inbound alt is read from the payload if present.
  • PWA installability, offline storage, service worker — the iOS
    app is the offline-capable client.
  • Multi-credential per user — synced passkeys only. Hardware
    authenticators that don't sync require a fresh invite per device.
  • Web-based admin UI — invites and users are managed from the
    host CLI only.
  • Bundled image-with-caption messages — text and image are
    separate posta messages, sent in sequence by the user.

Further Notes

  • The chat.posta.no deployment is a single Go binary plus a SQLite
    file on disk. No background workers, no cron, no external services
    beyond Caddy in front.
  • The CORS situation on posta-server stays as-is. Because the browser
    never calls posta-server directly, no Access-Control-Allow-Origin
    configuration is needed on any posta-server instance.
  • Loss of chat.posta.no's SQLite is recoverable: every user is
    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).
  • Loss of a user's passkey (authenticator wiped, sync chain broken):
    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's
    token design treats this as a cheap re-pair, not a data-loss event.
  • The CSS/template layer should be small enough to embed via embed.FS
    into the binary for self-contained deployment, matching posta-server
    and the marketing site's pattern.
  • The repo lives at code.bas.es/posta/chat.
# PRD — chat.posta.no (browser chat client for posta-server) ## Problem Statement Arne and his friends each run a personal `posta-server` instance (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 acts as 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 by `posta-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-get` re-renders). The render path on update is the same as on first paint, so the UI has one consistent rendering surface. ## User Stories ### Admin (operator of chat.posta.no) 1. As the admin, I want to mint a one-shot invite link from a CLI on the host, so that I can provision specific friends without exposing a public signup form. 2. As the admin, I want to attach a human-readable name to each invite, so that I can tell at a glance who an invite was for. 3. As the admin, I want to list outstanding and consumed invites, so that I can see who has registered and who is pending. 4. As the admin, I want to revoke an unused invite, so that I can pull back an invite I sent in error. 5. As the admin, I want invites to expire after 24 hours, so that stale invite links can't be used long after they were sent. 6. As the admin, I want to list registered users, so that I can audit who has accounts. 7. As the admin, I want to delete a user, so that I can off-board someone who should no longer have access. 8. As the admin, I want the chat.posta.no service to be a single binary deployable to a Caddy-fronted VPS, so that operations match posta-server and the marketing site. ### Invitee (registering for the first time) 9. As an invitee, I want to open my invite URL in a browser, so that I can begin registration without installing anything. 10. As an invitee, I want a single text field that accepts the `posta+v1://host#token=…` URI from my posta-server CLI output, so that I don't have to enter the URL and token separately. 11. As an invitee, I want chat.posta.no to verify that my pasted URI works against my posta-server before consuming my invite, so that a typo doesn't burn my one-shot link. 12. As an invitee, I want to register a passkey as part of the same flow, so that subsequent logins require no token re-entry. 13. As an invitee, I want my bearer token stored encrypted so that 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. 14. As an invitee, I want a failed registration (passkey ceremony cancelled, token rejected) to leave my invite reusable, so that I can try again without going back to the admin. ### Returning user (sign-in) 15. As a returning user, I want to sign in with my passkey from any of my devices that has it synced, so that I don't have to re-enter a token or password. 16. As a returning user, I want my session to last across casual page refreshes without re-tapping my passkey, so that the app feels like a normal site. 17. As a returning user, I want my session to expire after 30 minutes of idleness, so that a walked-away laptop loses access reasonably quickly. 18. As a returning user, I want my session to expire absolutely after 7 days regardless of activity, so that long-lived sessions can't drift indefinitely. 19. As a returning user, I want to explicitly log out, so that I can end my session deliberately on a shared computer. 20. As a returning user, I want to know when chat.posta.no has restarted and re-authentication is required, so that I'm not confused by being kicked to the login page. ### Conversations (the main UI) 21. As a signed-in user, I want a sidebar listing my contacts ordered by most-recent activity, so that the people I talk to most are at the top. 22. As a signed-in user, I want each contact to show an unread badge with a count, so that I can see at a glance which conversations need attention. 23. As a signed-in user, I want unread counts to drop to zero the moment I open a conversation, so that the badge accurately reflects my attention. 24. As a signed-in user, I want inbound messages to a thread I'm currently viewing to not increment the unread count, so that the badge doesn't blink up and back down. 25. As a signed-in user, I want my read state to sync across devices, so that reading on my iPhone clears the badge on my laptop's browser without action. 26. As a signed-in user, I want my contacts list to render with fallback avatars (2-letter initials in a deterministically-coloured circle) when a peer has no avatar set, so that the sidebar is visually consistent. 27. As a signed-in user, I want to land on my most-recent thread when I log in, so that the app picks up where I left off. 28. As a brand-new user, I want a clear empty-state hint when I have no conversations yet, so that I know how to start one. ### Thread rendering 29. As a signed-in user, I want messages displayed as bubbles with my own messages right-aligned and inbound messages left-aligned, so that I can see at a glance who said what. 30. As a signed-in user, I want consecutive messages from the same sender within 2 minutes visually clustered (single avatar, tight spacing), so that a conversation reads as turns, not flat rows. 31. As a signed-in user, I want date dividers between messages crossing a calendar boundary, so that I can orient by day. 32. As a signed-in user, I want outbound messages to show a status indicator (clock for pending/sending, check for delivered, warning for failed), so that I know whether my words reached the peer. 33. As a signed-in user, I want failed sends to be visually distinct even though I cannot retry them in v1, so that I know to act on them outside the UI. 34. As a signed-in user, I want URLs in plain-text messages auto-linked, so that I can click them without copy-pasting. 35. As a signed-in user, I want newlines in plain-text bodies preserved, so that paragraph structure survives. ### Composing text 36. As a signed-in user, I want a text composer at the bottom of the thread, so that I can type without looking for it. 37. As a signed-in user, I want Enter to send and Shift+Enter to insert a newline, so that my muscle memory from every other chat app works here. 38. As a signed-in user, I want pressing Enter on an empty/whitespace composer to do nothing, so that I can't ship phantom messages. 39. As a signed-in user, I want the composer to auto-grow up to a few lines and scroll beyond, so that I can compose multi-paragraph messages without the layout collapsing. ### Composing images 40. As a signed-in user, I want to attach an image by clicking a file picker button, so that I can choose a file from my disk. 41. As a signed-in user, I want to attach an image by pasting from the clipboard, so that screenshots ship in one gesture. 42. As a signed-in user, I want to attach an image by dragging-and- dropping onto the composer, so that the natural drag gesture works. 43. As a signed-in user, I want the composer to switch to "image 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. 44. As a signed-in user, I want sending an image to clear the composer and return focus to the textarea, so that I can naturally type a caption as the next message. 45. As a signed-in user, I want to discard an attached image without sending it, so that "I changed my mind" works without leaving orphan bytes on my posta-server. 46. As a signed-in user, I want inbound image messages rendered inline in the bubble at bubble-width with a sensible height cap, so that images don't blow the layout. 47. As a signed-in user, I want to click an image to open the full resolution in a new tab, so that I can see detail or save the file. 48. As a signed-in user, I want broken-image references handled gracefully (small placeholder, peer hostname caption), so that a dead peer doesn't break my UI. 49. As a signed-in user, I want screen-reader alt text for images based on the payload's `alt` or `name` field with a sensible fallback, so that images are accessible. ### Starting a new conversation 50. As a signed-in user, I want a "+" button in the sidebar to start a new conversation, so that the affordance is obvious. 51. As a signed-in user, I want to enter just a peer URL to start a conversation, so that I don't have to wade through a multi-field form. 52. As a signed-in user, I want chat.posta.no to look up the peer's actor doc and show me their display name before I commit, so that I know I have the right URL. 53. As a signed-in user, I want the new contact created implicitly 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 54. As a signed-in user, I want new inbound messages to appear in the open thread within a second of the peer sending them, so that the app feels alive. 55. As a signed-in user, I want the sidebar to update in real time when a new peer messages me, so that I notice without polling. 56. As a signed-in user, I want my state consistent across multiple open tabs of chat.posta.no, so that reading on one tab updates the others. 57. As a signed-in user, I want chat.posta.no to handle a posta-server restart transparently (reconnect, resync), so that I don't have to refresh manually. ### Mobile 58. As a mobile user, I want the layout to collapse to a single column below ~768px wide, so that I can use the app on my phone. 59. As a mobile user, I want a back chevron in the thread view to return me to the contact list, so that I have an explicit navigation affordance. 60. As a mobile user, I want the on-screen keyboard's "send" button to actually send, so that I don't have to reach for a separate send button. 61. As a mobile user, I want the composer pinned above the on-screen keyboard, so that I can see what I'm typing. 62. As a mobile user, I want tap targets (bubbles, buttons, contact rows) sized for fingers, so that I don't mistap. ### Visuals 63. As a signed-in user, I want chat.posta.no to share the visual identity of posta.no (cream/forest/sky/terracotta palette, Crimson Pro + IBM Plex), so that it feels like part of the posta family. 64. As a signed-in user, I want OS-driven dark mode, so that 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-get` against chat.posta.no, which re-renders the relevant 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/template` fragments, htmx routing. Distinguishes full-page from partial renders via the `HX-Request` header. Persistent sidebar chrome; main-pane swaps driven by `hx-push-url`. - `internal/auth` — WebAuthn (passkey) ceremonies with the PRF extension; 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 `Authorization` header only; never persists it. - `internal/sse` — per-user posta-server SSE multiplexer. Refcounts subscribers, holds Last-Event-ID for resumption, handles posta-server's `resync` event 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 cobra subcommands. - `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 - WebAuthn relying party uses the `prf` extension on both registration and 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. - Synced passkeys only. Multi-device works via authenticator sync (iCloud Keychain, Bitwarden, 1Password, Google Password Manager). Multi-credential per user is not supported in v1. - The cookie sessionID is 32 bytes of cryptographically random data encoded as base64url. No HMAC needed because the cookie carries no encoded state — it's an opaque lookup key. ### Signup flow 1. Admin runs `chat-posta invite create --name "Marcus"`. 2. Output: `https://chat.posta.no/register?code=<random>`. Admin sends this to the recipient out-of-band. 3. Recipient runs `posta-server token create` on their own host, gets a `posta+v1://host#token=…` URI. 4. Recipient opens the invite URL; the page shows a single textarea asking for the URI plus a "Continue" button. 5. On submit, chat.posta.no parses URI → `(serverURL, token)`, makes a `GET {serverURL}/api/v1/identity` with 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." 6. Browser initiates passkey registration ceremony with the PRF extension; chat.posta.no validates the attestation and stores the credential plus the encrypted token; the invite is marked consumed. 7. Redirect to the inbox. ### Render dispatch Handlers detect `HX-Request: true` and render only the relevant fragment; 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 the htmx `sse` extension. The backend's per-tab SSE writer emits small trigger events corresponding to posta-server SSE events: - `thread-updated` (peer URL in data) → main-pane re-renders if that peer 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/read` with the highest visible `rowId`. While a tab has 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 `inbound` event for that peer arrives, so the unread count for the open thread never blinks up. ### Image messages Composition for an image uses `multipart/form-data` to the backend; the backend streams the bytes through to posta-server's `POST /api/v1/uploads` and, on success, takes the returned URL and issues a `POST /api/v1/messages` with a `posta.link/v1` payload referencing 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/v1` message if the upload failed. Inbound images render as `<img>` with `loading="lazy"`, `decoding="async"`, an `onerror` handler that swaps to a `.broken` state, and a parent `<a target="_blank" rel="noopener">` so click opens the original URL. The image bytes are served by the peer's posta-server with `Cache-Control: public, max-age=31536000, immutable` so the browser caches them directly — chat.posta.no never proxies the image read path. ### Visual design The `:root` CSS custom properties from `/web/static/style.css` are lifted verbatim (palette, type scale, radii, shadows) plus `@font-face` declarations for Crimson Pro and IBM Plex Sans/Mono. The dark-mode variants under `@media (prefers-color-scheme: dark)` are also 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 - Single binary at `/usr/local/bin/chat-posta`. - SQLite database at `/var/lib/chat-posta/state.db`. - Config at `/etc/chat-posta/config.toml` (overridable by `CHAT_POSTA_*` env vars and `--` flags). - Listens on `:80` behind Caddy that handles TLS for chat.posta.no. - Logs to stderr (captured by systemd journal). - Backups: the SQLite file. Loss is recoverable but painful — every 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 fresh in-memory SQLite; CRUD operations on `users` and `invites` work as specified; invite consumption is atomic; uniqueness constraints reject duplicate credentialIDs. - `internal/auth` — PRF-derived AES-GCM round trip (encrypt with key K, 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, close on last unsubscribe after grace period), Last-Event-ID resumption, `resync` forwarding, behavior on posta-server SSE connection failure (reconnect with backoff). Integration-tested (via `httptest` and a fake posta-server): - `internal/web` handlers — end-to-end request/response through cookie session, posta client, and template rendering. Includes htmx partial-vs-full render dispatch behavior. - `internal/admin` CLI commands — exercise the cobra subcommands against a real SQLite test database. Not unit-tested: `internal/posta` (covered by `internal/web` integration tests against the fake posta-server); `cmd/chat-posta` (wiring code). Prior art for test shape lives in posta-server's `internal/api/*_test.go` and `internal/store/*_test.go` — same table-driven style, same `httptest` patterns. ## Out of Scope The following are deliberately deferred past v1: - **Identity editing** — name, about. Users edit identity via their own posta-server CLI. - **Avatar upload** — `PUT /api/v1/identity/avatar` is not surfaced. Users with no avatar render with initials fallback. - **Retry of failed-pending-user messages** — the status indicator shows the failure but no in-UI retry is provided. Users invoke `posta-server` directly. - **Removing contacts** — the sidebar lists; it does not delete. Users who want to scrub a contact use the posta-server CLI. - **Full-text search** — `GET /api/v1/search` is not surfaced. Users search by scrolling or via the TUI/iOS app. - **Image lightbox** — click-to-new-tab is the v1 affordance. - **Push notifications and sound alerts** — not implemented; foreground SSE only. - **Compose-time alt-text for outbound images** — outbound images go without alt. Inbound alt is read from the payload if present. - **PWA installability, offline storage, service worker** — the iOS app is the offline-capable client. - **Multi-credential per user** — synced passkeys only. Hardware authenticators that don't sync require a fresh invite per device. - **Web-based admin UI** — invites and users are managed from the host CLI only. - **Bundled image-with-caption messages** — text and image are separate posta messages, sent in sequence by the user. ## Further Notes - The chat.posta.no deployment is a single Go binary plus a SQLite file on disk. No background workers, no cron, no external services beyond Caddy in front. - The CORS situation on posta-server stays as-is. Because the browser never calls posta-server directly, no `Access-Control-Allow-Origin` configuration is needed on any posta-server instance. - Loss of chat.posta.no's SQLite is recoverable: every user is 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). - Loss of a user's passkey (authenticator wiped, sync chain broken): 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's token design treats this as a cheap re-pair, not a data-loss event. - The CSS/template layer should be small enough to embed via `embed.FS` into the binary for self-contained deployment, matching posta-server and the marketing site's pattern. - The repo lives at `code.bas.es/posta/chat`.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
posta/chat#2
No description provided.