Conversation
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).
There was a problem hiding this comment.
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
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.
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.
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.
- 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.
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


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)
s_+ 128 bits, stored hashed, 365 days from last use).wasm/web/confusables.js, generated byscripts/make-confusables.mjsfrom 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 toguest-…./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 oneCORS_ORIGIN, and a request from anotherOriginis refused. WithoutCORS_ORIGINthere 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.TRUST_PROXYreadsX-Forwarded-Forfrom the right.node:sqlite(WAL,synchronous=NORMAL, migrations byuser_version), atDB; 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
/doc/<room>without a live ticket is refused, so the document is no longer open to anyone.accountflag; 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.Page
?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.Tests
CORS_ORIGIN, old pages, the health line.Not covered
Deploying (names are placeholders; docs/RELAY.md has the runbook)
docker run(it holds the name and port).docker/nginx.confinto the site's nginx file by hand (keep certbot's lines):log_format thinksynthat the top level,access_log ... thinksynth;in each server that proxies the relay, and theX-Forwarded-Forheader;nginx -tand reload before the new relay takes traffic.docker/compose.yamlwith a.envbeside it:CORS_ORIGIN=https://<site-origin>(a bare origin; the relay refuses anything else). Without it the relay offers no accounts.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.sqlite3 ... ".backup ..."from cron (escape%as\%), prune old copies, keep some off the host: losing it loses every account.