# HTTP and WebSocket reference

## HTTP and WebSocket Lives

The OTP application starts an HTTP listener on `127.0.0.1:8080` by default,
alongside SSH on `127.0.0.1:2222`. Its browser assets live in
[`priv/static`](https://github.com/bibleit-labs/bibleit-server/blob/main/priv/static), the OTP application's release-packaged static
resource directory, so one deployable service owns the browser and SSH
surfaces.

The intended production address is `https://bibleit.app`, with native SSH on
`bibleit.app:22`. Legacy Live links use
`https://live.bibleit.app/<live_id>` and redirect to
`https://bibleit.app/lives/<live_id>`; the Live ID must be a single alphanumeric
path segment. Existing path queries, such as widget options, survive the redirect.

When `BIBLEIT_PUBLIC_URL` is configured, page requests must use that origin's
authority. `BIBLEIT_HTTP_REDIRECT_HOSTS` optionally lists exact, comma-separated
DNS aliases (for example `live.bibleit.app,www.bibleit.app,bibleit-server.fly.dev`)
and requires an HTTPS canonical origin. Aliases return a fixed-origin 308 for
GET/HEAD; mutations and WebSocket upgrades return 403 before authentication.
Unknown authorities return 421 for page reads and 403 for mutations/upgrades.
Liveness/readiness GET/HEAD remain available to platform health checks.
Redirects have `no-store` and `no-referrer`, set no cookies, and never trust
forwarded-host headers. Browser cookies remain host-only: moving between hosts
requires signing in at the canonical address. Native SSH uses the server's
persistent host key; a hostname change must preserve strict host-key verification.

Owners and admins can customize organization Lives from the **Live Design**
page. See [Organization Live Design](../live-design.md) for preview controls,
permissions, persistence, and live updates.

Design writes serialize by organization and require the current revision. Two
writes using the same revision produce one success and one conflict; retrying a
committed revision does not increment it again. A transient Live registry or
notification failure after commit does not turn the save into a write error.
Connected viewers may miss that notification; a page reload/reconnect reads the
durable design. Failed database writes leave the revision unchanged.

Organization contact changes lock the organization before reading current
membership permissions. Pending contact authorization and address retrieval share the organization
lock. Contact-verification links are bearer credentials: possession
can verify the pending address without a browser login, but does not create a
session or grant organization membership. Token consumption and contact activation
commit together, with expiry checked against wall-clock time after locks. Failed
email delivery leaves the pending request recorded; retry sends a new link and
replaces the old token.

Personal Live invitation GETs reveal their target without granting collaboration.
Acceptance requires an active principal and personal account; the invitation is
one-use, checked for expiry after Live/identity/token locks, and committed together
with collaborator creation. An expired invitation does not reserve a quota slot.

### Application API and transports

Bibleit is modeled as an OTP application first. [`bibleit_api`](https://github.com/bibleit-labs/bibleit-server/blob/main/src/bibleit_api.erl)
is the transport-neutral application façade: it speaks in actors, translations,
Lives, and account access. SSH is a command adapter which parses commands,
calls the application API, and encodes responses. Cowboy is another adapter;
its handlers and WebSocket process call the same API directly inside the BEAM.

```text
SSH shell / exec ──┐
Cowboy HTTP / WS ──┼──► bibleit_api ───► OTP domain actors
CLI HTTP API ──────┘        │
                             ├── authorization
                             ├── translations
                             └── LiveSession actors
```

No HTTP handler opens a loopback SSH connection. Authorization and domain state
remain reusable by both transports.

| Endpoint | Purpose |
| --- | --- |
| `GET /healthz` | Process liveness; returns `{"ok":true}` independently of database availability. |
| `GET /readyz` | Database readiness: 200 after a successful SQL canary; 503 with `Retry-After: 1` when unavailable, busy or timed out. |
| `GET` / `POST /auth/login` | Browser sign-in by email/password. |
| `GET` / `POST /auth/signup` | Email/password account creation and verification request. |
| `GET /auth/email/verify/<token>` | Consumes a one-time email-verification link and signs the browser in. |
| `GET` / `POST /auth/password/reset` | Requests a password-reset link. |
| `GET` / `POST /auth/password/reset/<token>` | Consumes a one-time password-reset link. |
| `GET /auth/google` | Starts Google OAuth sign-in or sign-up. |
| `GET /auth/google/callback` | Google OAuth callback. |
| `GET /auth/github` | Starts GitHub OAuth sign-in or sign-up. |
| `GET /auth/github/callback` | GitHub OAuth callback. |
| `POST /auth/logout` | Revokes the current browser session, clears its cookie, and redirects to sign-in. |
| `GET` / `POST /cli/auth` | Browser approval page for a PKCE-protected CLI authorization request. |
| `POST /api/cli/token` | Exchanges a one-use CLI authorization code and verifier for an account token. |
| `POST /api/cli/command` | Runs one typed CLI command using a bearer account token. |
| `POST /api/cli/logout` | Revokes the presented CLI account token. |
| `GET /dashboard` | Authenticated account dashboard. |
| `GET /dashboard/reader` | Authenticated local Scripture Reader, including side-by-side comparison of enabled translations. |
| `GET` / `POST /dashboard/translations` | Authenticated translation library and local public-domain catalogue. |
| `GET /plans` | Hosted plan comparison and current upgrade availability. |
| `GET /docs` | User documentation for authentication, translations, Lives, plans, and local operation. |
| `GET /lives/<live-id>` | Canonical Live presentation URL. |
| `GET /<live-id>` | Short Live URL; redirects to `/lives/<live-id>`. |
| `POST /auth/<live-id>` | Validate a Live secret and set its HttpOnly Live cookie. |
| `GET /ws?live=<live-id>` | Browser WebSocket stream. |
| `GET /dashboard/api/lives/<live-id>/events` | Authenticated management WebSocket; pushes stats and studio snapshots without consuming a viewer slot. |
| `GET /assets/...` | Packaged CSS and JavaScript. |

Create a Live through an authenticated SSH session, then open its canonical URL:

```sh
# In another terminal, after `make run`:
ssh -p 2222 -i ~/.ssh/bibleit_local_dev -o IdentitiesOnly=yes bibleit-cli@127.0.0.1
# Then run: live create "Sunday Service"
# Open http://127.0.0.1:8080/lives/<id>
```

The browser page subscribes directly to its `LiveSession`. A visitor joins a
secret-protected Live by posting its secret to `POST /auth/<live-id>`; the
secret is held in a same-site, HttpOnly, per-Live cookie. Form, URL-link and
WebSocket-cookie validation share a socket-peer IP guessing budget; throttled
attempts return 429 with `Retry-After` and `Cache-Control: no-store`. Set
`http_secure_cookies` to `true` when the public site is HTTPS. Widgets remain available with `?widget=1`;
`translation=slug` or `translations=slug1,slug2` limits the rendered
translations.

### Account mutation requests

Cookie-authenticated POST requests to `/settings/<section>` and the legacy
`/dashboard/profile`, `/dashboard/settings`, `/dashboard/tokens` and
`/dashboard/keys` and `/dashboard/translations` routes require an Origin matching
the request scheme and Host.
Missing or foreign origins return 403 before form parsing or mutation.
`POST /settings/avatar` additionally requires `Content-Type: application/json`;
other or missing media types return 415. Same-origin browser forms and fetches
supply Origin; cookie-authenticated non-browser clients must supply it explicitly.
Anonymous or revoked sessions retain the login redirect for forms and 401 for
the avatar endpoint. HTTPS deployments should configure `http_secure_cookies`
consistently with the public origin.

Account identity comes from the session. Submitted actor hints do not select a
different account, and session, token and SSH-key IDs remain scoped to their
owner. Organization forms separately require their CSRF token and current
organization membership and role; global organization grants do not grant
access to another organization's settings.

Organization transaction authorization reads current membership and role after
acquiring the organization account lock. A queued operation therefore observes
demotion, removal or ownership transfer committed ahead of it; the same boundary
controls private contact details returned by organization settings.

Translation catalog and reader APIs require a current browser session and the
translation enabled in that account's personal library. Organization library
membership and submitted account/workspace hints do not enable the personal
reader. Search additionally requires `translation.search`; removing an entry
from a personal library leaves the installed translation available to other
authorized accounts. Organization library changes require the form's CSRF token
and current owner/admin authority.

Notification lists and actions are scoped to the signed-in recipient and require
CSRF for mutations. Owning a notification that points at an invitation does not
replace that invitation's recipient check. Invitation acceptance/rejection and
marking the notification read commit in one transaction; failure of the read
acknowledgement rolls back the invitation and membership changes, allowing retry.
Invitation decisions check real-time expiry after acquiring the invitation row
lock, including anonymous decline. Concurrent decisions consume a pending
invitation once; a stale accepted notice cannot restore removed membership.
Notification links must be local
absolute paths without a protocol-relative authority, backslashes, whitespace
or control characters. Unsafe stored hrefs render as plain text without an open
action; attempting to open one leaves its read status unchanged.

### Live management JSON requests

Cookie-authenticated `POST /dashboard/api/lives/<id>/control` and
`POST /dashboard/api/lives/<id>/invitations` require an `Origin` matching the
request's scheme and Host, plus `Content-Type: application/json` (parameters
such as `charset=utf-8` are allowed). Missing, opaque or foreign origins return
403; other or missing media types return 415. Checks run before body decoding or
mutation. Normal same-origin browser POST fetches supply the Origin header.
Cookie-authenticated non-browser clients must explicitly supply it too. Invalid
or revoked sessions continue returning 401. These endpoints use the same origin
policy as credentialed WebSocket admission; HTTPS deployments should configure
`http_secure_cookies` consistently with the public origin.

Identity and authority come from the authenticated session and current resource
access checks. Client-supplied actor, role, permission or workspace hints do not
grant access. Personal Live collaborators can operate their Live; secret
rotation/deletion, deletion, workspace movement and personal invitation
management retain owner checks. Workspace Live operations use current workspace
membership: viewers can read state, operators/admins can update it, and secret
rotation requires owner/admin authority. Personal collaboration links do not
apply to workspace Lives. Removing collaboration or workspace membership takes
effect on subsequent requests, including a mutation waiting in a Live's queue.

### WebSocket commands

WebSockets stream Live events and also accept an authenticated `push` command.
The browser session is revalidated for every command; the actor must hold
`live.update` and be allowed to manage that specific Live. The client never
supplies an actor or permission value. Inbound complete messages and control
frames share a peer-IP budget across viewer and management sockets; exhausting
it closes with 1008. Cowboy rejects payloads above 4096 bytes, including total
fragmented payloads, with 1009 before handler dispatch. See the
[protocol reference](protocol.md) for defaults and limitations.

```json
{"type":"push","reference":{"translation":"web","book":19,"chapter":23,"verse":1}}
```

`translation` is optional when the Live has configured translations. A
successful command returns `push_result` with the verse payload; failures
return `error` with a stable `code` such as `forbidden`, `unauthenticated`, or
`invalid_reference`. Text command frames are limited to 4 KiB.

Browser account onboarding establishes or recovers a browser session. That
session can approve a CLI login without exposing its cookie to the CLI. The
CLI receives a one-use authorization code on a loopback callback, proves its
PKCE verifier, and stores only the resulting revocable account token in
`~/.bibleit/config.json`. The public Live page remains an audience presentation
endpoint and continues to use its per-Live secret when configured.

### Browser authentication

The HTTP surface has its own short-lived browser-session layer. `POST
/auth/login` accepts an email/password account, resolves it to an actor
through in-process Erlang actors, and returns a
`Secure` (when configured), `HttpOnly`, `SameSite=Lax` cookie. Passwords are
never placed in JavaScript, local storage, or a WebSocket message.

The Cowboy handlers and WebSocket endpoint resolve that cookie directly through
Erlang actors; they never make a loopback SSH connection or parse the SSH
protocol. Only the cookie value reaches the browser; its SHA-256 hash, account,
user, expiry, and last-seen time are persisted in PostgreSQL. Sessions expire
after eight hours by default (`http_session_ttl_seconds`) and survive an OTP
restart until they expire or are revoked.

Password reset changes the password, deletes all browser sessions for the user,
and consumes the user's email identity's reset links in one PostgreSQL
transaction. A failure rolls back all three changes, and concurrent consumption
of a reset link has only one successful result. This does not revoke separately
issued CLI bearer tokens or SSH keys. Password sign-in carries the authenticated
credential version into session issuance. Issuance locks the same user row as
reset and rechecks that credential before insertion: reset rejects a delayed
old-password login, or deletes its session if issuance committed first.
The credential proof stays inside the server and is never returned to the client.
Credentialed viewer and management streams
recheck authority while idle; see the [stream revocation policy](protocol.md#pipelining-and-live-events).

### CLI browser authentication

The server supports the authorization flow intended for the forthcoming CLI:
a client starts a loopback callback and opens `/cli/auth` in the default
browser. The native CLI is still in development; its command syntax is not
published as a stable interface. Existing email, Google, and GitHub login paths all return to
the pending approval after sign-in. Authorization requests and codes expire
after 30 minutes; codes are one-use and bound to the CLI's PKCE challenge.
Only loopback `http://127.0.0.1`, `localhost`, or `::1` callback URLs are
accepted.

The exchanged access token is an ordinary hashed-at-rest account token, so it
appears in the dashboard/account token list, uses the separate CLI credential safety cap (10 active credentials), and can be revoked independently. HTTP CLI commands authenticate that
token and use the same protocol decoder, permission checks, account translation
library, and Live actors as SSH. Streaming `LIVE SUBSCRIBE` remains SSH-only;
the browser uses WebSockets for Live events and authorized push commands.

### Email/password sign-up and Resend

Email accounts are optional. A sign-up records an unverified account, hashes
the password with Argon2id, then sends a single-use verification link. Opening
the link creates the corresponding Bibleit actor, marks the email verified, and
starts a browser session. Verification and password-reset links expire after
30 minutes; stored records contain only SHA-256 hashes of those link secrets.

Verification consumes the token, activates the existing user/identity, creates
the personal account and assigns the member role in one database transaction.
Expiry is checked against wall-clock time after acquiring locks. A failed write
rolls the activation and token consumption back together. Registration/replacement
and verification serialize by email across workers: verification first preserves
the active account and password; replacement first invalidates the old link.
The subsequent browser session is created only after committed verification.

Google and GitHub browser sign-in set separate ten-minute OAuth state cookies
under `/auth`, with `HttpOnly`, `SameSite=Lax` and the configured secure-cookie
policy. Callbacks require a matching cookie, state and nonempty code before
consuming state or contacting the provider. A callback from another browser is
denied without consuming the legitimate browser's state. Valid attempts clear
the state cookie, and durable state is consumed only once with expiry checked
after row-lock waits. Provider failures require starting a fresh sign-in flow.

Email addresses and password hashes are private account data. They are not
included in `AUTH ACTOR LIST` or actor metadata. The actor gets a random
`email-...` identifier and the display name selected at sign-up.

Configure Resend with all three values:

```text
BIBLEIT_RESEND_API_KEY=re_...
BIBLEIT_EMAIL_FROM="Bibleit <hello@mail.bibleit.app>"
BIBLEIT_PUBLIC_URL=https://bibleit.app
```

`BIBLEIT_EMAIL_FROM` must be a sender authorized in Resend. For development,
use a Resend test sender/recipient allowed by your account. The server sends
email through Resend's `POST https://api.resend.com/emails` API. The Docker
builder already installs the C compiler required by the Argon2id Erlang NIF.

Optionally set `BIBLEIT_EMAIL_REPLY_TO=support@bibleit.app` to route replies to
a working, monitored inbox. This sets the Reply-To header. Without it, replies
go to the From address, which should
itself receive mail. Verifying a sending domain does not configure an inbox.
Transactional messages include HTML and explicit plain-text content.

When migrating to `mail.bibleit.app`, verify the new sending domain, update the
deployed sender and reply address, then test authentication, delivery, and
replies. Disable sending for `bibleit.app` in Resend only after confirming no
remaining sender uses the root domain.

### Google sign-in and sign-up

Google OAuth is optional. When configured, `/auth/login` and `/auth/signup`
offer the same Google path. On the first successful Google sign-in, Bibleit
creates an actor from Google’s stable subject identifier; later sign-ins reuse
that actor. The Google access token is used only during the callback exchange
and is never stored.

Configure the three values together in deployment:

```text
BIBLEIT_GOOGLE_CLIENT_ID=...
BIBLEIT_GOOGLE_CLIENT_SECRET=...
BIBLEIT_PUBLIC_URL=https://bibleit.app
```

Register `https://bibleit.app/auth/google/callback` as the authorized redirect
URI in Google Cloud. The public URL must be the externally visible HTTPS origin.

### GitHub sign-in and sign-up

GitHub OAuth is optional and follows the same browser-session flow. The first
successful sign-in creates an actor from GitHub's stable numeric user ID; later
sign-ins reuse it. Bibleit requests only GitHub's `read:user` scope and does
not persist the GitHub access token.

Bibleit uses the GitHub handle as the initial username when it is available.
If it is already taken, sign-in continues to username selection with a note
explaining the conflict. Later sign-ins preserve the username chosen on Bibleit.

Configure these values together with the same public URL:

```text
BIBLEIT_GITHUB_CLIENT_ID=...
BIBLEIT_GITHUB_CLIENT_SECRET=...
BIBLEIT_PUBLIC_URL=https://bibleit.app
```

Create a GitHub **OAuth App** (not a GitHub App) and register
`https://bibleit.app/auth/github/callback` as its authorization callback URL.
For local testing, use `http://localhost:8080/auth/github/callback` and set
`BIBLEIT_PUBLIC_URL=http://localhost:8080`.

### Existing Cloudflare Turnstile widget

Email/password signup uses the existing widget with public site key
`0x4AAAAAAFRbmQt7BOETz6aN`; its paired secret belongs in Fly's existing secret
store as `BIBLEIT_TURNSTILE_SECRET_KEY`. Keep the widget and its clearance mode;
do not create a replacement or copy secrets into source, logs or documentation.
The deployed site key already matches this widget and a secret is present;
that metadata alone does not prove a successful challenge.

```text
BIBLEIT_TURNSTILE_SITE_KEY=0x4AAAAAAFRbmQt7BOETz6aN
BIBLEIT_PUBLIC_URL=https://bibleit.app
```

Allow `bibleit.app` on this existing widget in Cloudflare. The backend derives
its single expected hostname from the canonical public URL. An optional
`BIBLEIT_TURNSTILE_HOSTNAME` must match that hostname; a local/stale override
fails startup instead of widening production token acceptance. Site and secret
keys must be configured together. Local development may leave both empty or
use a separate Cloudflare test-key pair with its matching local public origin;
production must retain the real widget's secret and exact production hostname.

The signup form loads Cloudflare's API script with the response's fresh CSP
nonce, embeds action `signup`, and posts `cf-turnstile-response` to its existing
server handler. After CSRF and rate-limit checks, the server calls only
`https://challenges.cloudflare.com/turnstile/v0/siteverify`, with verified TLS,
a five-second timeout and bounded response size. Registration proceeds only on
HTTP 200, boolean `success: true`, exact action `signup` and exact hostname.
Missing/oversized tokens, malformed responses, outages, wrong claims and expired
or reused tokens are denied before account creation/email delivery. Each native
form response navigates/reloads and renders a fresh widget for retry; no token
is cached or reused in browser code.

Cloudflare tokens expire after five minutes and are single-use. Complete
destination acceptance with one fresh human challenge through signup, then
confirm a second submission of the same token is rejected. Do not expose the
token/secret in chat or logs. Local provider fixtures prove the application's
success/failure/replay handling; they do not substitute for this real-widget
acceptance. See [Cloudflare's existing-widget flow](https://developers.cloudflare.com/turnstile/spin/prompt.md)
and [server validation reference](https://developers.cloudflare.com/turnstile/get-started/server-side-validation/).

## Plan and support mutation boundaries

Contributor submission and cancellation at `/settings/plan` accept only the
`submit` and `cancel_contributor` actions. Maintainer approval and proactive grants
remain trusted console operations. Cancellation carries the current opaque
versioned request handle; a saved action from before resubmission is rejected.

Plan/support forms require matching CSRF cookie and form tokens. An explicitly
supplied Origin must match the serving origin. Forms without Origin retain token
validation; authenticated settings mutations also enforce their existing explicit
origin check. Support uses the authenticated personal account and current
Contributor eligibility, never caller-supplied actor/account fields or organization
capacity. Eligibility is rechecked after the account lock in the same transaction
as submission. An unavailable Live registry does not prevent a direct support
submission. Duplicate support issues are unique per personal account, including
after review, and duplicate reviews do not send another notification.

### Browser response security and caching

A shared Cowboy stream handler applies response security headers to dynamic pages,
static assets, redirects, errors, streamed responses and WebSocket upgrades.
CSP restricts scripts and connections to the serving origin plus the Cloudflare
Turnstile origin, blocks inline scripts/event attributes and eval, denies objects
and base URI changes, and limits form submission to the serving origin. Images
may use HTTPS/data URLs for existing branding; inline styles remain allowed for
layout/design controls. This policy complements context-aware escaping and the
Live rich-text allowlist.

Pages use `frame-ancestors 'self'` and `X-Frame-Options: SAMEORIGIN`. Only valid
`/lives/:id` viewer paths with an explicit `widget` query key allow cross-origin
framing; adding that key to account or settings pages grants no exception.
The same-origin design preview iframe remains available. Every response also
sets `nosniff`, `Referrer-Policy: no-referrer`, and a Permissions Policy disabling
camera, microphone and geolocation. When `http_secure_cookies` is configured for
an HTTPS deployment, responses also carry HSTS (`max-age=31536000`). Local HTTP
does not set HSTS. Production TLS/proxy behavior requires deployment validation.

Dynamic and cookie-bearing responses, including errors/redirects, use `no-store`.
Successful public assets, avatars and selected static documentation files retain
public caching (one hour by default) and normal validators. Personalized HTML
ignores static conditional-cache requests. Tests include real Chrome back
navigation after logout, rather than relying solely on response-header presence.

The hook follows Cowboy's [stream callback contract](https://ninenines.eu/docs/en/cowboy/2.13/manual/cowboy_stream.init/).

## Request admission and expensive routes

Dynamic page/API, plan/support and documentation shell handlers admit at most
600 requests/minute per accepted socket peer IP before locale/session resolution,
body parsing or downstream work. OAuth initiation/callback routes additionally
share 30/minute; email verification, password-reset links, organization contact
verification and organization/Live invitation links share 60/minute. Existing
signup/login/reset issuance and CLI scopes still apply. Invalid requests and
anonymous requests consume the applicable peer allowance too.

Authenticated reader and translation catalog GETs share 120/minute per actor
across sessions. Authorized reader searches additionally share the 20/minute
`ssh_search` actor allowance with SSH/CLI/docs searches. Exhausting search alone
does not prevent chapter reads. Existing member search, organization request,
WebSocket admission/frame and SSH connection/command gates remain separate.

Admission failures return 429 with a positive `Retry-After` and `no-store`, and
fail closed when the limiter is unavailable. Static files bypass the dynamic
gate; `/healthz` and `/readyz` remain exempt (readiness still checks its services).
All scopes sharing `http_global` charge its 1000/minute allowance on each check,
so one request can consume several checks. See the
[operations policy](../contribution-operations.md) for configuration and key caps.

Forwarding headers cannot select the limiting identity. Clients behind a proxy
or NAT share its peer budget. Counters are node-local fixed windows, reset on
restart, and multiply with independent nodes. Trusted edge admission, proxy
budget sizing, distributed quotas and deployment capacity require separate
validation; the local tests do not establish those properties.
