Skip to content

relay: accounts, with guests marked as guests - #326

Open
mishan wants to merge 21 commits into
masterfrom
accounts
Open

mishan wants to merge 21 commits into
masterfrom
accounts

Conversation

@mishan

@mishan mishan commented Oct 3, 2026 •

Copy link
Copy Markdown
Owner

Accounts for rooms, so a name is someone's: guests stay, marked as guests, and a registered handle is one person's. Passkeys come next as a second way to log in.

Accounts (relay)

  • The credential is an 8-word key from the EFF long wordlist (~103 bits), issued by the relay and stored only as its SHA-256. Logging in trades it for a session (s_ + 128 bits, stored hashed, 365 days from last use).
  • Handles are cleaned (control and format characters, variation selectors, stray joiners, NFC, 32 characters) and folded for uniqueness by their Unicode skeleton: wasm/web/confusables.js, generated by scripts/make-confusables.mjs from confusables.txt (18.0.0), the characters that pass for Latin letters and digits. A handle may be renamed once in 30 days; the old one, and a deleted account's, stays reserved 30 days. Nothing may fold to guest-….
  • /api/account/{register,login,me,handle,key,logout,delete} on the relay's port: JSON only (Content-Type: application/json), Bearer sessions, no cookies; CORS for one CORS_ORIGIN, and a request from another Origin is refused. Without CORS_ORIGIN there are no accounts: the routes answer 404 and a hello's session is ignored. Renaming, replacing the key and deleting take the current key; deleting also takes the handle typed out. A new key ends every session, the caller's included, and hands the caller a new one. Bodies are capped at 1 KiB while streaming.
  • Token buckets: registrations per client (IPv4, IPv6 /64), per /48 and across everyone; requests carrying a key per client and per /48 only (a shared bucket would let a few hundred addresses lock everyone out of logging in); session requests per client. TRUST_PROXY reads X-Forwarded-For from the right.
  • node:sqlite (WAL, synchronous=NORMAL, migrations by user_version), at DB; no new npm packages.
  • node relay.mjs admin account|rename|ban|unban|revoke|delete [--free]. A ban, revoke, logout, key replacement or delete closes that account's sockets.

Rooms

  • The hello carries the session; an unknown, lapsed or banned one is refused ("log in again"). A guest whose name folds to a handle is refused. A page from before tickets is told to reload.
  • The welcome gives the name the room shows and a ticket for the document socket: one room, five minutes, refreshed over the room socket. /doc/<room> without a live ticket is refused, so the document is no longer open to anyone.
  • Peers, chat and cursors carry an account flag; guests show as "name (guest)" (on a relay without accounts nobody is marked: account: null). The relay sets each cursor's name, and passes its colors only in the page's own shape.
  • A socket that starts to close is not heard from again: a refused hello is final, and a peer whose session ends leaves the room at once.
  • Every socket is pinged every 30 s and cut when it stops answering. A reconnecting document socket takes its cursor over from the one it replaces.
  • Frames are capped at 1 MiB (room socket) and 4 MiB (document socket); a peer may have four document sockets open, a document socket may speak for two awareness clients and a room hold 256, and a run's log for late joiners keeps at most 32 MiB (512 KiB a command).
  • The relay writes the sender's id into every start, stop and logged command, and the relay's name and account flag into every cursor state.
  • The account id is the persistent id JAM_BACKLOG §0 asks for.

Page

  • An Account dialog on the join card: create (the key shown once, in a form password managers save, with show/copy/download), log in, rename, replace the key, log out, delete.
  • Sessions are kept per relay, and only the site's own relay (config.json's) gets one; a ?relay= elsewhere joins as a guest with the account UI hidden. Where the relay is gets worked out once per load, and the join, the room list and the dialog all use that one value.
  • A lost room socket (relay restart, dropped network, a backgrounded phone) stops the document socket and makes the editor read only; the page joins again by itself with backoff (new hello, new ticket, document socket back on it, seat taken again), then leaves it to a Rejoin button. The name is locked to the handle only once the relay says it has accounts.

Tests

  • accountstest (new, in CI): keys, folding and lookalikes, the API, sessions, bans, limits, Content-Type and Origin, streaming body cap.
  • relaytest: sessions in the hello, guest names, tickets (none, made up, another room's, lapsed, a room that doesn't exist), sessions ending (at once, and not heard after), a second hello after a refused one, cursor names and colors, cursor takeover on reconnect, the heartbeat, frame and socket caps, no accounts without CORS_ORIGIN, old pages, the health line.
  • jamtest: register in the dialog and log back in with the key; a guest beside an account; a second relay sees a guest and keeps the session; a join with config.json unreadable after the first read; a room socket cut by the relay and the page rejoining with its edits syncing; a home relay without accounts leaving the name free; a join after a failed config.json read.
  • relaytest, accountstest, jamtest (twice), chatcheck, panecheck, pagetest, browsertest pass; the Docker image builds and CI's room join passes on it, with accounts off and on.

Not covered

  • A rename shows in a room from the next join.
  • Guests keep seat, play and edit until room roles exist.

Deploying (names are placeholders; docs/RELAY.md has the runbook)

  1. Remove any relay container started by hand with docker run (it holds the name and port).
  2. Merge docker/nginx.conf into the site's nginx file by hand (keep certbot's lines): log_format thinksynth at the top level, access_log ... thinksynth; in each server that proxies the relay, and the X-Forwarded-For header; nginx -t and reload before the new relay takes traffic.
  3. Install the repo's docker/compose.yaml with a .env beside it: CORS_ORIGIN=https://<site-origin> (a bare origin; the relay refuses anything else). Without it the relay offers no accounts.
  4. Pages first, then docker compose pull && docker compose up -d (this creates the accounts volume). Roll back the other way round: pin the previous image tag, then revert Pages.
  5. Back the database up daily with sqlite3 ... ".backup ..." from cron (escape % as \%), prune old copies, keep some off the host: losing it loses every account.

mishan added 7 commits October 3, 2026 00:31
An account is a handle and a key: eight words from the EFF long
wordlist (about 103 bits), chosen by the relay and stored as a SHA-256.
Logging in trades the key for a 128-bit session, s_ and hex, stored
hashed and good for 365 days from its last use. A key reads back
however it is typed: case, spaces or hyphens.

Handles are cleaned up (control, format, private-use and unassigned
characters stripped, NFC, at most 32 characters) and unique once folded
(NFKC, upper then lower case, the dotted i). An owner may rename once
every 30 days, and the handle renamed from stays theirs for 30 days, so
nobody can take it up to pass as them.

accounts.mjs serves POST register, login, handle, key, logout, delete
and GET me under /api/account/, with JSON errors, Bearer sessions and
no cookies, CORS for one configured origin, and a 1 KiB body cap. A new
key and deleting take the current key, not a session alone; a new key
ends every other session. Token buckets limit registrations per client
(an IPv4 address or an IPv6 /64), per /48 and across everyone; key
requests per client and per /48 only; session requests per client.
X-Forwarded-For is read from the right, as many hops as are trusted.

The store is node:sqlite, WAL with a busy timeout, schema upgrades by
user_version under BEGIN IMMEDIATE. The admin commands (account,
rename, ban, unban, revoke, delete) run against the same file.

accountstest.mjs drives it all over HTTP on a clock it turns; CI runs
it beside relaytest.
The room socket's hello carries an account's session, or nothing for a
guest. An account plays under its handle. A session the relay does not
know -- unknown, lapsed, logged out, banned -- is refused with a reason
("log in again") rather than made a guest, so nobody plays a room
believing they are logged in. A guest's name is cleaned up as a handle
is, and one that folds to an account's handle, or to one an owner
renamed from in the last 30 days, is refused.

The welcome says who the peer is ({ name, account }); peers, joined,
chat and switched lines carry an account flag, and room.js shows a
guest's name with "(guest)" after it. PROTOCOL stays 1: a page from
before sends no session and joins as a guest, and a page against a
relay from before finds no identity or ticket in the welcome and goes
on as it did.

The document socket was open to anyone who could reach the relay.
y-websocket speaks first and cannot authenticate, so the welcome hands
out a ticket: random, for one room, good for five minutes and for any
reconnect within them. A doc upgrade without a live ticket for its room
is refused with a 403. The room socket is handed a fresh ticket every
two minutes, which the page puts in the provider's params for its next
reconnect, and its document sockets close when it does.

Ending sessions over HTTP (log out, a new key, deleting) closes the
room sockets made with them, with the reason. The admin commands run in
another process, so the relay also looks at the sessions behind its open
rooms once a minute.

The health line says the relay has accounts. relay.mjs takes DB (the
file, beside the relay by default), CORS_ORIGIN and TRUST_PROXY, and
`relay.mjs admin <command>'. The image copies the new modules and keeps
its file at /data/relay.db, a named volume in compose.yaml; nginx sets
X-Forwarded-For.
The join card has a Log in / Account button, shown once the relay's
health line says it has accounts. The dialog creates an account (a
handle, then the key shown once in a login-shaped form a password
manager saves, with Show, Copy and Download), logs in with a key
(autocomplete=current-password, typed however), and once logged in
changes the handle, replaces the key, logs out or deletes the account.
A browser's suggested password is not let replace the key in the save
form, and closing with a key unsaved asks twice.

The page keeps the session and the handle in localStorage, never the
key, and joins with the session; the name box then holds the handle and
is not editable. A refused session drops it, and the page is a guest
again. The API's origin is the relay's URL with wss turned to https.

The cursor's name is the one the relay hands back, so a guest's cursor
says "(guest)" like its name in the peers, the chat and the seat lines.

jamtest's last room: one page registers in the dialog, logs out,
reloads and logs in with its key typed with spaces and capitals; the
other is turned away under the handle and joins as a guest. Both see
"Ann" and "Bo (guest)" in the peers, on the seat, in chat and on the
editor's cursors.
…ration

RELAY.md: DB, CORS_ORIGIN and TRUST_PROXY; the accounts volume; a daily
sqlite3 .backup from the host, since losing the file loses every
account; and the admin commands through docker exec. JAM_BACKLOG.md
notes what of section 0 is done: a persistent id for accounts, handles
nobody else can take, and a document socket that needs a ticket.
The page kept one session for every relay and sent it to whichever one
`?relay=' named, in the hello and in a /me on load; that relay could
also refuse it to log the page out, then stand behind the Log in form a
password manager fills. A session is now kept under its relay's API
origin, and the account button shows, and a session goes out, only for
the relay config.json names (or the page's own host without one). Sent
to another relay, the page joins as a guest and leaves the session
alone. A join waits for that to be settled.

A new handle takes the current key as well as the session, and deleting
takes the handle typed out beside the key. Every POST is JSON, logging
out included. The hello says the page knows about tickets
(`tickets: true'), which the relay will ask for.

jamtest: logged in, the page is sent to a second relay with ?relay=,
joins it as a guest with no Account button, and still has its session
for the first.
- A ticketless document upgrade to a room nobody had opened read
  `until' of an undefined ticket and threw out of the upgrade listener,
  which took the process down. The ticket is checked first, and nothing
  thrown in an upgrade escapes it.
- Cursor names were the page's to set. The relay rewrites `user' in
  every awareness update to the name of the room socket behind the
  document socket, a guest's marked "(guest)", with an `account' flag,
  and drops entries for a client another socket controls.
- A hello without `tickets: true' is from a page older than tickets,
  which would join and then wait for ever on a refused document socket;
  it is told to reload and closed. PROTOCOL stays 1.
- Ending sessions revokes the peer's tickets and terminates its document
  sockets at once, not when an unresponsive client lets the close
  complete. A DB error in a hello is a refusal, and in the minute's
  recheck a log line, never an exception.
- The account API takes only application/json POSTs, and refuses a
  request whose Origin is not CORS_ORIGIN: a form or text/plain POST
  from any page needs no preflight and could register handles from its
  visitors' addresses. Without CORS_ORIGIN the health line offers no
  accounts, since no page could use them.
- Handles: a rename takes the key. No handle starts with `guest-', the
  page's default guest name. A banned account cannot delete itself to
  free its handle; deleting takes the handle typed out, and a deleted
  account's handles stay nobody's for 30 days, as a renamed one does
  (`admin delete <handle> --free' frees them).
- Lookalikes: variation selectors and Mongolian ones are stripped, and a
  joiner is kept only between emoji or in the scripts it shapes. foldName
  maps the Cyrillic and Greek letters drawn like Latin ones, 0 and 1, i
  and l, rn and vv, to one form (a cut of UTS #39's confusables), so
  `pаypal' clashes with `paypal'. That covers guests and kept
  handles too, which fold the same way.
- SQLite runs synchronous=NORMAL under WAL, and a session's last use is
  written at most hourly.
- X-Forwarded-For with fewer entries than TRUST_PROXY falls back to the
  socket's address, not the client-written leftmost entry; a hex
  IPv4-mapped address is limited as its IPv4 one.
- nginx logs requests by path alone, keeping tickets out of the log.

Tests for each: relaytest (the crash, the old page, cursor names and a
hijacked client, immediate revocation, the health line), accountstest
(Content-Type, Origin, a chunked body over the cap, guest- handles,
lookalikes, rename and delete taking more, a banned delete, deleted
handles kept and --free, the short X-Forwarded-For, hex-mapped IPv4).
Base automatically changed from piece-switch to master October 3, 2026 08:49
@mishan
mishan requested a balanced review from Copilot October 3, 2026 16:49

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Admin renames bypass reserved-handle validation, and deleting renamed accounts can release prior handles before the promised 30-day period.

Review effort: Balanced
Findings: 2 Medium severity

Open (2)
What changed in this PR

Adds persistent relay accounts, authenticated room identities, guest labeling, and ticket-gated document sockets.

Changes:

  • Adds SQLite-backed accounts, sessions, rate limits, and administration.
  • Integrates account UI, guest indicators, and document tickets.
  • Adds deployment documentation and comprehensive account/relay tests.
File Description
.github/​workflows/​ci.yml Runs account tests in CI.
.gitignore Ignores local relay databases.
docker/​compose.yaml Configures account storage and proxy settings.
docker/​nginx.conf Forwards client addresses and omits query strings from logs.
docker/​relay.Dockerfile Packages account modules and persistent storage.
docker/​relay.Dockerfile.dockerignore Includes account sources in builds.
docs/​JAM.md Documents account availability.
docs/​JAM_BACKLOG.md Marks persistent identity work complete.
docs/​RELAY.md Documents deployment, backup, and moderation.
wasm/​web/​CMakeLists.txt Bundles account UI modules.
wasm/​web/​account.js Implements shared key and handle normalization.
wasm/​web/​accounts.mjs Implements account storage, API, limits, and administration.
wasm/​web/​accountstest.mjs Tests account behavior and security boundaries.
wasm/​web/​accountui.js Implements browser account management.
wasm/​web/​jam.html Adds account controls and dialog.
wasm/​web/​jam.js Integrates sessions and document tickets.
wasm/​web/​jamtest.mjs Tests account and guest browser flows.
wasm/​web/​package.json Adds the account test command.
wasm/​web/​relay.mjs Authenticates room identities and gates documents.
wasm/​web/​relaytest.mjs Tests sessions, tickets, and cursor identity.
wasm/​web/​room.js Carries sessions, tickets, and account metadata.
wasm/​web/​style.css Styles the account dialog.
wasm/​web/​wordlist.mjs Supplies account-key words.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread wasm/web/accounts.mjs
Comment thread wasm/web/accounts.mjs Outdated
mishan added 3 commits October 3, 2026 10:06
The join, the room list and the account dialog each read config.json
for themselves. A read that failed for the join alone sent it to the
default relay, ws://<host>:8787, while the dialog still took the site's
relay to be home, so the join carried the home relay's session to
another endpoint, in the clear. The page now works out { url, home }
once, and all three go by that one value.

jamtest lets config.json be read once a load and refuses it after: the
logged-in join still reaches the relay the page found.
foldName lowercased before its lookalike table and matched composed
characters, so Greek nu, upsilon, eta and gamma, Cyrillic ghe,
accented lookalikes (Cyrillic io against e with diaeresis), Latin alpha
and the Armenian letters went through: `victor' and `νictor',
`paul' and `paυl', `zoë' and `zoё', `ann' and `ɑnn', and
`gυest-1' past the guest- reservation. It now decomposes (NFKD),
maps each character to its skeleton as it is -- a capital's lookalike
is not its small letter's -- then again after case folding, and
compares that.

The skeletons are confusables.js, written by scripts/make-confusables.mjs
from Unicode's confusables.txt (18.0.0): only the single characters
whose skeleton is Latin letters and digits, marks dropped, 2051 of
them. A letter whose capital folds apart from it (`I' is `l', `i'
itself) is folded with its capital, so case still does not count. Not
caught, because confusables.txt does not call them Latin: the small
capitals (`ᴀ'), Cyrillic pe (a pi) and short i (`й').

handle_folded and kept_handles store foldName's output, which SQL cannot
compute; the migrations note that a change to it needs a step that folds
every stored handle again.

Also:
- register and login swept stale sessions after their own write, so a
  busy database there answered 500 after the account was made, its key
  lost. The sweep runs first, and its failure is logged, not returned.
- Deleting an account kept its renamed-from handles only until their
  own 30 days ran out; they now run 30 days from the delete too.
- admin rename goes through the owner's rules for a handle, guest-
  included.
- A peer whose session ended (log out, a new key, delete, ban, revoke)
  was closed but stayed in the room, and ws hands on messages while a
  socket closes, for up to 30 s with a client that stops reading: it
  could chat, sit and switch as the account. It now leaves at once, and
  a room socket's messages are ignored from the moment it starts to
  close. That makes a refused hello final too; a second hello after it
  joined the room.
- A document socket reconnecting took its client id while the dead one
  was still open, and its cursor updates were dropped as another
  socket's, then the cursor vanished when the dead one closed. A socket
  of the same peer now takes the client id over, and a closing socket
  removes only the states it still holds. Every socket is pinged every
  30 s and cut if the last ping went unanswered: a document socket says
  nothing while nobody types, and one whose page went without a close
  held its cursor in the room.
- Frames are capped: 1 MiB on a room socket, 4 MiB on a document socket.
  A peer may have four document sockets open at once.
- Without CORS_ORIGIN the relay has no accounts at all: /api/account/
  answers 404, a session in a hello is ignored, and the health line says
  so. compose.yaml and RELAY.md said Origin-less requests could not use
  the API, which they could; RELAY.md now says deleted accounts' handles
  run 30 days from the delete, and that admin rename keeps to the owner's
  rules.
Comment thread wasm/web/accounts.mjs Fixed
mishan added 2 commits October 3, 2026 10:09
The table is derived from Unicode's confusables.txt, whose license asks
that its copyright and permission notice go with every copy of the
data. make-confusables.mjs now writes that notice into the generated
file's header, beside the project's own and marked as Unicode's, with
the year from the source file. The table itself is byte for byte the
same.
mishan added 8 commits October 3, 2026 11:39
- A document socket could send awareness states for any number of
  made-up client ids, every one kept and handed to everyone: about half
  a million `{}' in a 4 MiB frame, and eight frames took half a gigabyte.
  A socket may speak for two clients (a page and, briefly, its
  reconnect), a room hold 256, and an update past either cuts the
  socket, unread from then on. Client ids are indexed to their socket
  rather than searched for per state.
- The run's log capped commands, not bytes, so 400 one-megabyte `log'
  frames were 400 MB kept for late joiners. It now keeps at most 32 MiB,
  and no command over 512 KiB; past either the run is one that cannot be
  caught up with, as past the count.
- A state without a `user' went out as an anonymous cursor with no guest
  marker; every state now carries the relay's name and account flag.
- Starts, stops and logged commands were kept as the page wrote them,
  `from' included: a guest's Play could be caught up with as an
  account's, and forged `from#seq' entries could hide another peer's
  commands from late joiners. The relay writes the sender's id into
  each.
Replacing the key kept the caller's session, so a copy of that token
taken before outlived the key that had made it. The relay now ends every
session the account has and hands the caller a new one with the key;
the page keeps it.
RELAY.md: a backup restored brings back keys replaced since, sessions
ended since and accounts as they were before a ban, and what to do about
it; nginx's error log still names a ticket when the relay is down.
compose.yaml caps the container at 512 MiB, with node's heap at 384, so
a runaway is stopped and restarted rather than taking the host.
- A relay without accounts (no CORS_ORIGIN) marked everyone a guest,
  account: false in every welcome, peer, chat line and cursor, though
  nobody there could be anything else. It now says account: null, which
  the page shows as a plain name.
- CORS_ORIGIN with a path or a trailing slash matches no request's
  Origin and turned accounts off without a word; the relay now refuses
  to start on anything but scheme://host[:port], exit status 2.
- The old page's refusal said to reload, which under the service worker
  serves the old page again: it now says to press Update, or to close
  thinksynth's other tabs and reload.
With TRUST_PROXY set and nginx not yet sending the header, every client
was the proxy's address and shared its rate limits, so a handful of
registrations held off everyone's, with nothing said. The account routes
now log it once: "TRUST_PROXY is set but the proxy sends no
X-Forwarded-For".
- When the room socket closed -- a relay restart, a dropped network, a
  phone in the background past the heartbeat -- its tickets went with
  it, y-websocket retried the document socket with a dead ticket every
  couple of seconds for ever, and the editor typed into a document
  nobody else saw. Now the document socket is stopped and the editor
  made read only, and the page joins again by itself, backing off from
  a second to thirty, eight tries: a new hello, a new ticket, the
  document socket put back on it with the page's document as it
  stands, a new mesh, the seat taken again, and the run caught up with
  if it is not the page's. A refusal, or the last try, leaves it to a
  Rejoin button.
- The name box was locked to a kept handle before the relay had said it
  has accounts, and stayed locked on a relay without them (rolled back,
  or with no CORS_ORIGIN), with no button to change anything. It is now
  locked only once the health line says accounts.
- A config.json that could not be read at the load (an installed page
  opened before the network) left the default relay in place until a
  reload; the join reads it again. A relay guessed at is not asked for
  its rooms or its accounts.

jamtest: the relay cuts a logged-in page's room socket, and it joins
again and its edit reaches the other page; a home relay without accounts
leaves the name free; a join after a failed config.json read finds the
relay. And its own fixes: the stop a run is checked to is one with a
time (a relay-issued -1 stop is not one genwav can read), and the
joiner after a switch gets fifteen seconds, not five -- Start opens an
audio context and waits on its clocks before the catch-up begins.
The image job ran the relay with nothing set, which leaves accounts off
and never opens the database. A second container with CORS_ORIGIN set
is joined as well, and the health lines are checked to say
accounts: false and accounts: true.
RELAY.md: the first deploy (the hand-run container removed first, nginx
merged by hand around certbot's lines, before the relay takes traffic),
updating (Pages first, then the relay), rolling back (the relay first,
by pinning the previous image), the backup crontab with % escaped and
old copies pruned, and a restore with the relay stopped and the file
installed as the relay's user. JAM.md lists the invite link and the
room list as done, and the backlog the persistent id.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Accounts-off name handling, wildcard CORS acceptance, and byte-limit accounting need correction.

Review effort: Balanced
Findings: 1 High severity · 2 Medium severity

Open (3)
Resolved since last review (2)

Comment thread wasm/web/relay.mjs Outdated
Comment thread wasm/web/relay.mjs Outdated
Comment thread wasm/web/relay.mjs Outdated
…without accounts

- The executable refuses CORS_ORIGIN=*: it would let any site spend its
  visitors' registrations and key requests here. The account routes still
  take '*' when a harness hands it to them directly.
- A logged command is measured by its UTF-8 bytes against the per-command
  and per-run caps, not its UTF-16 length.
- A relay without accounts doesn't consult the account file for a guest's
  name, so handles a database still holds don't keep anyone out.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants