diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aceebe9b..7599df69 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -943,6 +943,12 @@ jobs: working-directory: wasm/web run: node relaytest.mjs + # The relay's accounts over HTTP, on a clock the script turns: keys, + # handles, sessions, the limits and the admin commands. + - name: Accounts + working-directory: wasm/web + run: node accountstest.mjs + # The room's second gate: a Chromium page and a Firefox page in one # room on a relay, each with a live AudioContext, one pressing Play, a # knob moved from each side, and one tape between them -- genwav's, @@ -1008,23 +1014,34 @@ jobs: # A room joined, not just the health line: that one answers before # a room has been seeded from the image's gen/ and dsp/. + # Twice: as it starts with nothing set, and with CORS_ORIGIN, which + # is what turns its accounts on (and opens its database). - name: It seeds and welcomes a room run: | + join() { + docker exec "$1" node --input-type=module -e " + import WebSocket from 'ws'; + const ws = new WebSocket('ws://127.0.0.1:8787/room/ci'); + ws.on('open', () => ws.send(JSON.stringify( + { type: 'hello', name: 'ci', protocol: 1, tickets: true }))); + ws.on('message', (m) => { + const d = JSON.parse(m); + console.log(JSON.stringify(d)); + process.exit(d.type === 'welcome' && d.piece ? 0 : 1); + }); + ws.on('error', (e) => { console.error(e.message); process.exit(1); }); + setTimeout(() => process.exit(1), 10000);" + } docker run -d --init --name relay -p 127.0.0.1:8787:8787 thinksynth-relay - for i in $(seq 30); do curl -fs 127.0.0.1:8787/ && break; sleep 1; done - docker exec relay node --input-type=module -e " - import WebSocket from 'ws'; - const ws = new WebSocket('ws://127.0.0.1:8787/room/ci'); - ws.on('open', () => ws.send(JSON.stringify( - { type: 'hello', name: 'ci', protocol: 1 }))); - ws.on('message', (m) => { - const d = JSON.parse(m); - console.log(JSON.stringify(d)); - process.exit(d.type === 'welcome' && d.piece ? 0 : 1); - }); - ws.on('error', (e) => { console.error(e.message); process.exit(1); }); - setTimeout(() => process.exit(1), 10000);" + docker run -d --init --name accounts -p 127.0.0.1:8788:8787 \ + -e CORS_ORIGIN=https://pages.example.org thinksynth-relay + for i in $(seq 30); do curl -fs 127.0.0.1:8787/ && curl -fs 127.0.0.1:8788/ && break; sleep 1; done + curl -fs 127.0.0.1:8787/ | jq -e '.accounts == false' + curl -fs 127.0.0.1:8788/ | jq -e '.accounts == true' + join relay + join accounts docker logs relay + docker logs accounts - name: Publish if: github.event_name != 'pull_request' && github.ref == 'refs/heads/master' diff --git a/.gitignore b/.gitignore index 473b46fa..cf31724f 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,9 @@ repo/ # The browser build's test harness (wasm/web/package.json) installs here. node_modules/ +# A relay run from the tree keeps its accounts beside itself (relay.mjs). +/wasm/web/relay.db* + # wasm/compare.mjs renders every piece twice, natively and through the # module, and `-k DIR' leaves the pair on disk rather than in a temp dir -- # which is what CI passes, as `-k compare'. That is 458 MB of wav for the diff --git a/docker/compose.yaml b/docker/compose.yaml index 9c772f6e..97adf7ef 100644 --- a/docker/compose.yaml +++ b/docker/compose.yaml @@ -20,3 +20,23 @@ services: init: true ports: - "127.0.0.1:8787:8787" # only the proxy on this host reaches it + # A relay that runs away with memory is stopped here and restarted, + # rather than left to take the host with it; node's heap stays under + # the limit so that it fails as itself first. + mem_limit: 512m + environment: + NODE_OPTIONS: --max-old-space-size=384 + # The room page's origin (the Pages site, say). Unset, the relay + # has no accounts: the account API is not there, and a session in + # a hello is ignored. + CORS_ORIGIN: ${CORS_ORIGIN:-} + # nginx in front, appending the client's address to X-Forwarded-For, + # which the account API's rate limits read. + TRUST_PROXY: "1" + volumes: + # The accounts (DB=/data/relay.db, set by the image). Back it up: + # docs/RELAY.md. + - accounts:/data + +volumes: + accounts: diff --git a/docker/nginx.conf b/docker/nginx.conf index c1be5a5f..b47a8bae 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -6,10 +6,17 @@ # # which adds the listen 443 and certificate lines. +# A document socket's ticket rides in its query string, and a log is no +# place for one: requests are logged by path alone. +log_format thinksynth '$remote_addr - [$time_local] ' + '"$request_method $uri $server_protocol" $status ' + '$body_bytes_sent "$http_user_agent"'; + server { listen 80; listen [::]:80; server_name relay.example.org; + access_log /var/log/nginx/access.log thinksynth; location / { proxy_pass http://127.0.0.1:8787; @@ -17,6 +24,9 @@ server { proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; + # The client's address, for the account API's rate limits; the + # relay reads one proxy's worth of it (TRUST_PROXY=1). + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # A document socket can sit quiet for as long as nobody types, and # nginx's 60 s default would cut it. proxy_read_timeout 1h; diff --git a/docker/relay.Dockerfile b/docker/relay.Dockerfile index 087f378f..d4267313 100644 --- a/docker/relay.Dockerfile +++ b/docker/relay.Dockerfile @@ -11,10 +11,17 @@ WORKDIR /srv/thinksynth/wasm/web COPY wasm/web/package.json wasm/web/package-lock.json ./ RUN npm ci --omit=dev --ignore-scripts && npm cache clean --force -COPY wasm/web/relay.mjs wasm/web/doc.js wasm/web/commands.js ./ +COPY wasm/web/relay.mjs wasm/web/doc.js wasm/web/commands.js \ + wasm/web/account.js wasm/web/accounts.mjs wasm/web/wordlist.mjs \ + wasm/web/confusables.js ./ COPY gen /srv/thinksynth/gen COPY dsp /srv/thinksynth/dsp +# The accounts' file, somewhere the relay's user can write and a volume +# can be mounted over (compose.yaml): losing it loses every account. +RUN mkdir /data && chown node:node /data +ENV DB=/data/relay.db + USER node EXPOSE 8787 diff --git a/docker/relay.Dockerfile.dockerignore b/docker/relay.Dockerfile.dockerignore index 958b4b45..131c891c 100644 --- a/docker/relay.Dockerfile.dockerignore +++ b/docker/relay.Dockerfile.dockerignore @@ -6,5 +6,9 @@ !wasm/web/relay.mjs !wasm/web/doc.js !wasm/web/commands.js +!wasm/web/account.js +!wasm/web/accounts.mjs +!wasm/web/wordlist.mjs +!wasm/web/confusables.js !gen !dsp diff --git a/docs/JAM.md b/docs/JAM.md index 4e642e95..f5252a65 100644 --- a/docs/JAM.md +++ b/docs/JAM.md @@ -395,9 +395,13 @@ Where it stands: the mirror, as the solo page does, so an `osc::sample` instrument sounds in a room. - **Text chat**, a pane on the room page (section 4). -- In progress: an invite link, so a room is joined without typing its - name; a list of the relay's rooms before joining; and a rework of the - room's layout. +- **Accounts.** A handle nobody else can join as, logged in with an + eight-word key from the relay; guests are marked as guests. The + document socket is let in by a ticket from the room socket. Running + it is [RELAY.md](RELAY.md#accounts). +- **An invite link**, so a room is joined without typing its name, and + **a list of the relay's rooms** before joining. +- In progress: a rework of the room's layout. - Not yet: TURN -- peers whose NATs defeat STUN fall back to the relay forwarding their commands; the `.patch` presets in the picker, which offers `.dsp` graphs; and the done-when, four people in two cities for diff --git a/docs/JAM_BACKLOG.md b/docs/JAM_BACKLOG.md index d35af70b..b823b21b 100644 --- a/docs/JAM_BACKLOG.md +++ b/docs/JAM_BACKLOG.md @@ -46,6 +46,15 @@ can edit. Before any audience feature: carries the persistent id. Both are small in M3's relay and awkward once rooms are live. +*Done of this:* names and a persistent id, for accounts. The `hello` +carries an account's session, the relay plays it under its handle, and +the account's id in the relay's database is the persistent id; nobody +else can take the handle, or one its owner renamed from in the last 30 +days. Guests are still a name per session, marked as guests wherever +the room shows names. The document is no longer open to anyone who can +reach the relay: its socket needs a short-lived ticket the room socket +hands out. Roles, visibility and moderation within a room are not done. + ## 1. The headless peer, and load **What.** A peer with no page and no sound card: the Node wasm build, @@ -247,7 +256,7 @@ Grouped by what unlocks what. Sizes are relative to a milestone. | | Item | Needs | Size | |---|---|---|---| -| 1 | Roles, visibility, persistent id (section 0) | M3 | S | +| 1 | Roles and visibility (section 0; the persistent id is done, for accounts) | M3 | S | | 2 | Headless peer and load testing (section 1) | M3, before M5 | M | | 3 | The recording format, fixed (section 4.1) | M3 | S | | 4 | Spectators, with the relay fan-out and the delay (section 2) | M4, 1, 2 | M | @@ -349,7 +358,8 @@ tens of thousands of concurrent sessions. The short list of things that are free now and costly later, gathered from above: -1. The relay enforces roles; `hello` carries a persistent id. +1. The relay enforces roles; `hello` carries a persistent id. (The id is + done, for accounts: section 0.) 2. Musicians' pages send a copy of their commands to the relay when the room has spectators or recording on. 3. The recording format is the protocol plus a header naming the build diff --git a/docs/RELAY.md b/docs/RELAY.md index c6791f5c..e0359988 100644 --- a/docs/RELAY.md +++ b/docs/RELAY.md @@ -1,9 +1,10 @@ # Running the relay The room page needs a relay (`wasm/web/relay.mjs`): the shared document, -the clock, who is in a room, and signalling. One Node process, one port, -nothing persisted. A page served over https can only open `wss://`, so -the relay runs behind a TLS proxy. +the clock, who is in a room, and signalling. One Node process, one port. +Rooms live in memory; accounts are kept in one SQLite file. A page +served over https can only open `wss://`, so the relay runs behind a TLS +proxy. ## The image @@ -14,29 +15,190 @@ dependencies, and `gen/` and `dsp/`, which new rooms are seeded from. ## On the host -1. Copy `docker/compose.yaml` over and start it: +The names below are placeholders: `relay.example.org` for the relay's +DNS name, `https://pages.example.org` for the origin the room page is +served from. + +### The first time + +1. A relay started by hand with `docker run`, from before compose, holds + the name `thinksynth-relay` and port 8787: stop and remove it first. + + docker rm -f thinksynth-relay + +2. nginx: merge `docker/nginx.conf` into the site's file by hand -- do + not copy it over, or certbot's `listen 443` and certificate lines go + with it. What it adds: + + - `log_format thinksynth ...;` at the top level of the file, outside + every `server { }`; + - `access_log /var/log/nginx/access.log thinksynth;` inside each + `server { }` that proxies the relay, the TLS one included; + - `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;` in + the `location`. + + Then + + sudo nginx -t && sudo systemctl reload nginx + + before the new relay takes traffic. It trusts one proxy's + `X-Forwarded-For` (`TRUST_PROXY=1`), and without the header every + client is nginx's address and shares one rate limit, so a few + registrations hold off everybody's; the relay logs "TRUST_PROXY is set + but the proxy sends no X-Forwarded-For" once if that happens. For a + new host, point the DNS name at it, install the file with the real + name in place of `relay.example.org`, and run + `sudo certbot --nginx -d relay.example.org`. Long proxy timeouts + matter: a document socket is quiet while nobody types. + +3. Copy `docker/compose.yaml` over, with a `.env` beside it: + + CORS_ORIGIN=https://pages.example.org + + (exactly an origin: scheme, host and any port, no path or trailing + slash; the relay will not start on anything else). Then docker compose -f compose.yaml pull docker compose -f compose.yaml up -d - The relay listens on `127.0.0.1:8787`, for the proxy only. - `curl 127.0.0.1:8787/` answers with its protocol and rooms. + The relay listens on `127.0.0.1:8787`, for the proxy only, and + `curl 127.0.0.1:8787/` answers with its protocol, `accounts: true` + and its rooms. + +4. Set the repository variable `JAM_RELAY` to `wss://relay.example.org`. + The next master build writes it into the Pages site's `config.json`, + and the demo's room page joins it. Without the variable the site has + no relay and the room page cannot join anyone. + +5. Back the accounts up (below) from the first day. + +### Updating + +Pages first, then the relay. A new page works on an old relay, as a +guest with no Account button; an old page is refused by a new relay +("this page is older than the relay"), so the pages people have should +be new by the time the relay is. + +1. Let master's build deploy the Pages site. +2. Merge any change to `docker/nginx.conf` by hand, as above, and + `sudo nginx -t && sudo systemctl reload nginx`. +3. `docker compose -f compose.yaml pull && docker compose -f + compose.yaml up -d`. + +Rooms live in memory, so a restart ends every room in progress; pages +in a room join it again by themselves. Accounts are in the volume and +survive it. + +### Rolling back + +The relay first, then Pages -- the other way round from an update, for +the same reason. In `compose.yaml`, pin the image to the previous +commit's tag in place of `latest`: + + image: ghcr.io/mishan/thinksynth-relay: + +and `docker compose -f compose.yaml up -d`; then revert the site. A +relay from before accounts leaves the database in the volume alone, and +the accounts are there again when the relay that knows them is. + +## Accounts + +An account is a handle and a key: eight words the relay picks, which the +room page's Account dialog shows once for a password manager to save. +The relay keeps only hashes of keys and sessions. Anyone may still join +as a guest, marked as one, under any name that is not an account's +handle; no handle starts with `guest-`. The routes are under +`/api/account/` on the relay's own port (`wasm/web/accounts.mjs`). +Accounts belong to the relay the site's `config.json` names: a page sent +to another relay with `?relay=` joins it as a guest and keeps its +session to itself. + +The image reads three variables, which `compose.yaml` passes on: + +- `DB`: the file, `/data/relay.db` in the image. `compose.yaml` mounts + the named volume `accounts` on `/data`. Run from the tree, the relay + keeps `relay.db` beside itself; `DB=:memory:` keeps nothing. +- `CORS_ORIGIN`: the origin the room page is served from -- the Pages + site's, `https://pages.example.org` for instance. The account API + refuses requests from any other origin. Without it the relay has no + accounts at all: `/api/account/` answers 404, a session in a room's + hello is ignored (the page joins as a guest), the health line says + `accounts: false` and the page shows no Account button. Put it in a + `.env` beside `compose.yaml`: + + CORS_ORIGIN=https://pages.example.org + +- `TRUST_PROXY`: how many proxies in front append to `X-Forwarded-For`, + which the API's rate limits read. `compose.yaml` sets 1 for the nginx + of `docker/nginx.conf`, which appends it. Set it only behind proxies + that do, or a client picks its own address. + +The document socket is let in by a ticket in its query string, good for +five minutes. `docker/nginx.conf` logs requests by path alone so that +tickets stay out of the access log; keep that `log_format` if the file is +adapted. nginx's error log still names the whole request, ticket and all, +when the relay cannot be reached; a ticket lapses five minutes after it +is handed out, so keep the error log to the people who run the host. + +### Back it up + +Losing the file loses every account, with no way to recover one: the +key is the account. Copy it daily from the host, which needs `sqlite3`; +`.backup` is safe while the relay writes. As root: + + mkdir -p /var/backups/thinksynth + +and in root's crontab (`sudo crontab -e`), where `%` has to be written +`\%`: + + 17 4 * * * sqlite3 "$(docker volume inspect -f '{{.Mountpoint}}' thinksynth_accounts)/relay.db" ".backup '/var/backups/thinksynth/relay-$(date +\%F).db'" && find /var/backups/thinksynth -name 'relay-*.db' -mtime +28 -delete + +which keeps four weeks of copies. Keep some somewhere other than the +host as well. + +### Restore + +With the relay stopped, and the file the relay's user's (uid 1000 in +the image): + + docker compose -f compose.yaml stop relay + vol=$(docker volume inspect -f '{{.Mountpoint}}' thinksynth_accounts) + rm -f "$vol/relay.db-wal" "$vol/relay.db-shm" + install -o 1000 -g 1000 -m 600 /var/backups/thinksynth/relay-.db "$vol/relay.db" + docker compose -f compose.yaml start relay + +A restore goes back to the day of the backup, for better and worse: +keys replaced since work again and the new ones do not, sessions ended +since -- logged out, revoked -- are live again, and an account banned +since is not. After one, ban those accounts again (`admin ban`), and +ask anyone who replaced a key because it was lost or seen to replace it +again; `admin revoke` ends a restored account's sessions. + +### Moderation -2. Point a DNS name at the host, install `docker/nginx.conf` with that - name in place of `relay.example.org`, and run `certbot --nginx` for it. - Long proxy timeouts matter: a document socket is quiet while nobody - types. +The admin commands run against the same file, beside the running relay: -3. Set the repository variable `JAM_RELAY` to `wss://`. The - next master build writes it into the Pages site's `config.json`, and - the demo's room page joins it. Without the variable the site has no - relay and the room page cannot join anyone. + docker exec thinksynth-relay node relay.mjs admin account + docker exec thinksynth-relay node relay.mjs admin rename + docker exec thinksynth-relay node relay.mjs admin ban + docker exec thinksynth-relay node relay.mjs admin unban + docker exec thinksynth-relay node relay.mjs admin revoke + docker exec thinksynth-relay node relay.mjs admin delete -To update, pull and `up -d` again. Rooms live in memory, so a restart -ends every room in progress. +A ban ends the account's sessions and refuses its key until an unban; a +banned account cannot delete itself. `revoke` ends the sessions and +leaves the key working. A deleted account's handles -- its own and any it +was renamed from -- stay nobody's for 30 days from the delete, so a +name cannot be taken over to impersonate its owner; `delete +--free` frees them at once. `rename` takes the same handles an owner +could pick, so none starting with `guest-`. The relay looks +at the sessions behind its open rooms once a minute and closes those +that have ended, so a ban or a revoke empties the account out of every +room within the minute. ## Not yet - TURN. Peers whose NATs defeat STUN fall back to the relay forwarding their commands (`mesh.js`), which works but adds a hop. -- Limits. Anyone who can reach the relay can open rooms. +- Limits on rooms. Anyone who can reach the relay can open one; the + account API is the part that is rate-limited. diff --git a/scripts/make-confusables.mjs b/scripts/make-confusables.mjs new file mode 100644 index 00000000..2dff8935 --- /dev/null +++ b/scripts/make-confusables.mjs @@ -0,0 +1,157 @@ +#!/usr/bin/env node +/* + * Copyright (C) 2004-2026 Metaphonic Labs + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by the + * Free Software Foundation; either version 2 of the License, or (at your + * option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General + * Public License along with this program; if not, write to the + * Free Software Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ + +/* + * make-confusables.mjs -- wasm/web/confusables.js, from Unicode's + * confusables.txt (UTS #39): + * + * curl -O https://www.unicode.org/Public/security/latest/confusables.txt + * node scripts/make-confusables.mjs confusables.txt + * + * Only the single characters whose skeleton is Latin letters and digits + * are kept, marks in the skeleton dropped: a handle is faked with letters + * that pass for a-z and 0-9, and the rest of the file is the rest of + * Unicode. Then, for each letter whose capital folds apart from it once + * the case is gone (`I' is `l'; `i' is itself), the small letter goes + * where the capital does, so that case still does not count. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const text = fs.readFileSync(process.argv[2] ?? 'confusables.txt', 'utf8'); +const version = /^# Version: (.*)$/m.exec(text)?.[1] ?? '?'; +const date = /^# Date: (.*?),/m.exec(text)?.[1] ?? '?'; +const year = /^# \u00A9 (\d{4})/m.exec(text)?.[1] ?? '?'; + +/* The data is Unicode's, under the Unicode License v3 + (https://www.unicode.org/license.txt), whose notice has to go with every + copy of it: the generated file carries it. */ +const NOTICE = `UNICODE LICENSE V3 + +COPYRIGHT AND PERMISSION NOTICE + +Copyright \u00A9 1991-${year} Unicode, Inc. + +NOTICE TO USER: Carefully read the following legal agreement. BY +DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR +SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE +TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT +DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of data files and any associated documentation (the "Data Files") or +software and any associated documentation (the "Software") to deal in the +Data Files or Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, and/or sell +copies of the Data Files or Software, and to permit persons to whom the +Data Files or Software are furnished to do so, provided that either (a) +this copyright and permission notice appear with all copies of the Data +Files or Software, or (b) this copyright and permission notice appear in +associated Documentation. + +THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF +THIRD PARTY RIGHTS. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE +BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, +OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, +ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA +FILES OR SOFTWARE. + +Except as contained in this notice, the name of a copyright holder shall +not be used in advertising or otherwise to promote the sale, use or other +dealings in these Data Files or Software without prior written +authorization of the copyright holder.`; +const map = new Map(); +const chr = (hex) => String.fromCodePoint(parseInt(hex, 16)); + +for (const line of text.split('\n')) +{ + const m = /^([0-9A-F]+) ;\t([0-9A-F ]+) ;\tMA\b/.exec(line); + + if (m === null) + continue; + + const target = m[2].trim().split(' ').map(chr).join('') + .replace(/\p{M}/gu, ''); + + if (/^[A-Za-z0-9]+$/.test(target) && chr(m[1]) !== target) + map.set(chr(m[1]), target); +} + +/* As account.js's foldName does, with the table so far. */ +const skeleton = (s) => Array.from(s, (c) => map.get(c) ?? c).join(''); +const fold = (s) => + skeleton(skeleton(s.normalize('NFKD')).toUpperCase().toLowerCase()); + +for (const c of 'abcdefghijklmnopqrstuvwxyz') + if (fold(c) !== fold(c.toUpperCase())) + map.set(c, fold(c.toUpperCase())); + +const entries = [...map].sort(([a], [b]) => a.codePointAt(0) - + b.codePointAt(0)) + .map(([c, t]) => `${c.codePointAt(0).toString(16)}=${t}`); +const lines = []; + +for (const e of entries) +{ + if (lines.length === 0 || lines.at(-1).length + e.length > 70) + lines.push(e); + else + lines[lines.length - 1] += ` ${e}`; +} + +const header = fs.readFileSync(path.join(here, '..', 'wasm', 'web', 'doc.js'), + 'utf8').split('\n').slice(0, 17).join('\n'); + +fs.writeFileSync(path.join(here, '..', 'wasm', 'web', 'confusables.js'), +`${header} + +/* + * confusables.js -- written by scripts/make-confusables.mjs from Unicode's + * confusables.txt, version ${version} (${date}); do not edit. + * + * Each character that passes for Latin letters or digits, and what it + * passes for: hex code point, then its skeleton. + * + * The notice above is this project's, for the code. The table is derived + * from Unicode's data, which is Unicode's, and is distributed under its + * own license: + * +${NOTICE.split('\n').map((l) => ` * ${l}`.trimEnd()).join('\n')} + */ + +export const SKELETON = new Map(\` +${lines.join('\n')} +\`.trim().split(/\\s+/).map((e) => +{ + const [hex, to] = e.split('='); + + return [String.fromCodePoint(parseInt(hex, 16)), to]; +})); +`); + +process.stdout.write(`confusables.js: ${map.size} characters, Unicode ` + + `${version}\n`); diff --git a/wasm/web/CMakeLists.txt b/wasm/web/CMakeLists.txt index ee9e9f83..23684c95 100644 --- a/wasm/web/CMakeLists.txt +++ b/wasm/web/CMakeLists.txt @@ -432,6 +432,7 @@ endif() # what it pins: a dependency bumped without it here would rebuild nothing # and serve the old editor. set(BUNDLE_SOURCES jam.js chat.js room.js mesh.js editor.js doc.js clock.js + account.js accountui.js confusables.js commands.js host.js keyboard.js panel.js patch.js mic.js midi.js midiout.js midioutui.js ../tape.mjs canvasview.js composerview.js nodeview.js rollview.js diff --git a/wasm/web/account.js b/wasm/web/account.js new file mode 100644 index 00000000..e7af7abc --- /dev/null +++ b/wasm/web/account.js @@ -0,0 +1,197 @@ +/* + * Copyright (C) 2004-2026 Metaphonic Labs + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by the + * Free Software Foundation; either version 2 of the License, or (at your + * option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General + * Public License along with this program; if not, write to the + * Free Software Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ + +/* + * account.js -- what the relay and the page agree on about accounts: the + * API's address, how a name is cleaned up and compared, and how a key is + * read back from however it was typed. No DOM and no Node: both import it. + * + * An account is a handle and a key; no email, password or real name. The + * relay makes the key -- eight words -- and keeps only its hash, so the key + * is the account, and logging in trades it for a session the page keeps. + */ + +import { SKELETON } from './confusables.js'; + +/* Where the relay serves the account routes, beside its sockets. */ +export const ACCOUNT_API = '/api/account'; + +/* Words in a key, and the longest key a request may carry: eight long + words and their separators, with room to spare. */ +export const KEY_WORDS_PER_KEY = 8; +const KEY_INPUT_MAX = 200; + +/* A name -- a handle, or a guest's -- in characters (grapheme clusters), + and in UTF-16 units, which is what bounds a run of long emoji. */ +export const NAME_MAX = 32; +const NAME_UNITS_MAX = 64; +const NAME_INPUT_MAX = 200; + +/* Stripped outright: control characters other than whitespace, format + characters other than the zero-width joiner and non-joiner, private-use, + unassigned and lone-surrogate code points, marks that only ever render + invisibly, and the variation selectors, Mongolian ones included, which + change how a character is drawn and nothing about which it is. */ +const STRIPPED = new RegExp( + '(?!\\s)\\p{Cc}|(?!\\u200C|\\u200D)\\p{Cf}|[\\p{Co}\\p{Cn}\\p{Cs}' + + '\\u034F\\u17B4\\u17B5\\u180B-\\u180D\\u180F\\uFE00-\\uFE0F' + + '\\u{E0100}-\\u{E01EF}]', 'gu'); + +/* A run of whitespace, or of characters that render blank without being + whitespace to Unicode: the Hangul fillers, the Mongolian vowel separator + and the braille blank. */ +const BLANK_RUN = /[\s\u115F\u1160\u180E\u2800\u3164\uFFA0]+/gu; + +const JOINER_RUN = /(?:\u200C|\u200D)+/gu; + +/* What a joiner means something between: emoji, which it joins into one + picture, and the scripts whose letters it shapes. Anywhere else -- in + Latin, say -- it is an invisible character that makes two names look + alike and differ. */ +const JOINS = new RegExp( + '[\\p{Extended_Pictographic}\\p{Emoji_Modifier}' + + '\\p{Script_Extensions=Arabic}' + + '\\p{Script_Extensions=Syriac}\\p{Script_Extensions=Devanagari}' + + '\\p{Script_Extensions=Bengali}\\p{Script_Extensions=Gurmukhi}' + + '\\p{Script_Extensions=Gujarati}\\p{Script_Extensions=Oriya}' + + '\\p{Script_Extensions=Tamil}\\p{Script_Extensions=Telugu}' + + '\\p{Script_Extensions=Kannada}\\p{Script_Extensions=Malayalam}' + + '\\p{Script_Extensions=Sinhala}]', 'u'); + +/* A joiner run stays only as a single joiner between two characters it + means something between. */ +function keepJoiner (run, at, text) +{ + const before = Array.from(text.slice(0, at)).at(-1); + const after = Array.from(text.slice(at + run.length, at + run.length + 2)) + .at(0); + + return run.length === 1 && before !== undefined && after !== undefined && + JOINS.test(before) && JOINS.test(after) ? run : ''; +} + +let graphemes; + +/* The first `max' characters of `text' that fit in `units' UTF-16 units: + grapheme clusters, or code points where Intl.Segmenter is missing. */ +function truncate (text, max, units) +{ + graphemes ??= typeof Intl.Segmenter === 'function' + ? new Intl.Segmenter(undefined, { granularity: 'grapheme' }) : null; + + const chars = graphemes ? Array.from(graphemes.segment(text), + (s) => s.segment) + : Array.from(text); + let out = ''; + + for (const c of chars.slice(0, max)) + { + if (out.length + c.length > units) + break; + + out += c; + } + + return out; +} + +/* A name as people will see it, or null if nothing visible is left: no + invisible or direction-changing characters, blanks as single spaces, + composed, at most two stacked marks, trimmed and cut to NAME_MAX. What + two names are told apart by is foldName's. */ +export function normalizeName (raw) +{ + if (typeof raw !== 'string' || raw.length > NAME_INPUT_MAX) + return null; + + const cleaned = raw.replace(STRIPPED, '') + .replace(BLANK_RUN, ' ') + .replace(JOINER_RUN, keepJoiner) + .replace(/ {2,}/g, ' ') + .normalize('NFC') + .replace(/(\p{M}{2})\p{M}+/gu, '$1') + .trim(); + const name = truncate(cleaned, NAME_MAX, NAME_UNITS_MAX).trim(); + + return name === '' ? null : name; +} + +/* The form two names clash in: what they look like, by Unicode's + * confusables (confusables.js), with case and compatibility forms left out. + * + * Decomposed first (NFKD: full-width letters and ligatures to their plain + * letters, accents apart from them), so `\u0451' and `\u00EB' are each an + * `e' and the same mark. Then the skeleton, once for the characters as + * they are -- a capital Greek `\u039D' passes for `N', where the small one + * passes for `v' -- and again after case is gone: upper-casing first + * catches what lower-casing alone misses (`\u00DF' and `SS'). The dot a + * capital `\u0130' leaves on its `i' is dropped. + * + * What this returns is stored (accounts.mjs, handle_folded and + * kept_handles), so a change to it ships with a migration that folds every + * stored handle again. */ +export function foldName (name) +{ + const skeleton = (s) => Array.from(s, (c) => SKELETON.get(c) ?? c) + .join(''); + + return skeleton(skeleton(name.normalize('NFKD')).toUpperCase() + .toLowerCase()) + .replace(/l\u0307/g, 'l'); +} + +/* A key as typed or pasted, in its one form: lowercase words joined by + hyphens, whatever separated them. Null if it is not eight words of + letters. */ +export function normalizeKey (raw) +{ + if (typeof raw !== 'string' || raw.length > KEY_INPUT_MAX) + return null; + + const words = raw.toLowerCase().split(/[\s-]+/).filter(Boolean); + + return words.length === KEY_WORDS_PER_KEY && + words.every((w) => /^[a-z]+$/.test(w)) ? words.join('-') : null; +} + +/* A name as the room shows it: a guest's says so. `account' is the + relay's word, and a relay from before accounts says nothing, which + marks nobody. */ +export function shownName ({ name, account }) +{ + return account === false ? `${name} (guest)` : name; +} + +/* The relay's HTTP origin, for its WebSocket URL: wss://host/ is + https://host. Null for a URL that is not one. */ +export function apiOriginOf (relayUrl) +{ + try + { + const url = new URL(relayUrl); + + url.protocol = { 'wss:': 'https:', 'ws:': 'http:' }[url.protocol] ?? + url.protocol; + + return /^https?:$/.test(url.protocol) ? url.origin : null; + } + catch + { + return null; + } +} diff --git a/wasm/web/accounts.mjs b/wasm/web/accounts.mjs new file mode 100644 index 00000000..5347be9e --- /dev/null +++ b/wasm/web/accounts.mjs @@ -0,0 +1,1199 @@ +/* + * Copyright (C) 2004-2026 Metaphonic Labs + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by the + * Free Software Foundation; either version 2 of the License, or (at your + * option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General + * Public License along with this program; if not, write to the + * Free Software Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ + +/* + * accounts.mjs -- the relay's accounts: one SQLite file of handles, key + * hashes and sessions; the routes under /api/account/; and the admin + * commands, run against the same file. The routes: + * + * POST register { handle } { key, session, account } + * POST login { key } { session, account } + * GET me session { account } + * POST handle { handle, key } session { account } + * POST key { key } session { key, session } + * POST logout session {} + * POST delete { key, handle } session? {} + * + * A session goes as `Authorization: Bearer s_...', never as a cookie, so + * no other site's page can send one for its visitor; a failure is `{ + * error, message }'. Whatever could lose the owner the account takes the + * key itself and not a session alone: a logged-in browser that is + * borrowed or stolen can play as the account, and no more. + * + * The key is eight random words of the relay's choosing, about 103 bits, + * so it is stored as a plain SHA-256: a slow hash only helps a secret + * somebody could guess. Sessions are stored hashed the same way, so a + * copy of the file logs nobody in. + */ + +import crypto from 'node:crypto'; +import net from 'node:net'; +import { DatabaseSync } from 'node:sqlite'; + +import { ACCOUNT_API, KEY_WORDS_PER_KEY, foldName, normalizeKey, + normalizeName } from './account.js'; +import { KEY_WORDS } from './wordlist.mjs'; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/* How long a session lasts after it was last used. */ +export const SESSION_TTL_MS = 365 * DAY_MS; + +/* How often an owner may change the handle, and how long the one changed + from stays theirs: nobody can take it up straight away and pass as them + to the people who knew them by it. */ +export const RENAME_EVERY_MS = 30 * DAY_MS; +export const HANDLE_KEPT_MS = 30 * DAY_MS; + +const BODY_MAX_BYTES = 1024; + +/* A session's last use is written at most this often: it only has to be + good to the day for a year's lapse, and a write per hello and per + minute's check of every open room is a write the disk waits on. */ +const TOUCH_EVERY_MS = 60 * 60 * 1000; + +/* What the page names guests who give no name (jam.js): no handle may + start with it, so no account can pass as one, nor take one's name. */ +const GUEST_PREFIX = 'guest-'; + +/* Stale sessions and lapsed handles go at most this often, on the next + registration or log in. */ +const PRUNE_EVERY_MS = 60 * 60 * 1000; + +/* A limit shared by all clients turning requests away is logged at most + this often. */ +const SHARED_LOG_EVERY_MS = 10 * 60 * 1000; + +/* Clients a limit tracks at most; past it the least recently seen is + forgotten, so memory stays bounded however many addresses one client + cycles through. */ +const TRACKED_MAX = 10000; + +/* Token buckets -- a burst, then one request back every `refillMs' -- per + client, per IPv6 /48 and across everyone. Key requests have no shared + bucket: guessing a key is hopeless at any rate, and a shared one would + let a few hundred addresses lock everybody out of logging in. */ +export const LIMITS = { + register: [{ burst: 5, refillMs: 60 * 60 * 1000 }, + { burst: 20, refillMs: 15 * 60 * 1000 }, + { burst: 100, refillMs: 10 * 1000 }], + key: [{ burst: 10, refillMs: 30 * 1000 }, + { burst: 40, refillMs: 7500 }, + null], + session: [{ burst: 60, refillMs: 1000 }, null, null], +}; + +/* Schema upgrades in order: entry i takes a file from user_version i to + i + 1. Append only; never edit one that has shipped. handle_folded and + kept_handles hold foldName's output (account.js), which SQL cannot + compute: a change to foldName comes with a step here that folds every + stored handle again in JS. */ +const MIGRATIONS = [ + `CREATE TABLE accounts ( + -- AUTOINCREMENT: a deleted account's id is never handed out again, + -- so nothing kept against it passes to a newer account. + id INTEGER PRIMARY KEY AUTOINCREMENT, + handle TEXT NOT NULL, + handle_folded TEXT NOT NULL UNIQUE, + key_hash TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL, + renamed_at INTEGER, + banned INTEGER NOT NULL DEFAULT 0 + ) STRICT; + CREATE TABLE sessions ( + token_hash TEXT PRIMARY KEY, + account_id INTEGER NOT NULL, + last_used_at INTEGER NOT NULL + ) STRICT; + CREATE INDEX sessions_account ON sessions (account_id); + CREATE INDEX sessions_last_used ON sessions (last_used_at); + -- Handles their owners renamed from, theirs until \`until'. + CREATE TABLE kept_handles ( + handle_folded TEXT PRIMARY KEY, + account_id INTEGER NOT NULL, + until INTEGER NOT NULL + ) STRICT;`, +]; + +const COLUMNS = 'id, handle, handle_folded, created_at, renamed_at, banned'; + +/* What is stored of a key or a session: its SHA-256, in hex. */ +export function secretHash (secret) +{ + return crypto.createHash('sha256').update(secret, 'utf8').digest('hex'); +} + +export function newKey () +{ + return Array.from({ length: KEY_WORDS_PER_KEY }, + () => KEY_WORDS[crypto.randomInt(KEY_WORDS.length)]) + .join('-'); +} + +/* 128 bits, marked as a session so it cannot be mistaken for a key or a + document ticket wherever one turns up. */ +export function newSession () +{ + return `s_${crypto.randomBytes(16).toString('hex')}`; +} + +const isSession = (s) => typeof s === 'string' && /^s_[0-9a-f]{32}$/.test(s); + +/* ---- the file ---- */ + +export class AccountStore +{ + /* `file' is a path, or ':memory:'. */ + constructor (file) + { + this.db = new DatabaseSync(file); + + /* WAL lets a backup, or the admin commands, read beside the + relay's writes; a lock is waited out rather than failed on, and + the timeout comes first so that switching to WAL waits too. */ + this.db.exec('PRAGMA busy_timeout = 5000; PRAGMA journal_mode = WAL;'); + + /* Under WAL, NORMAL loses at most the last commits to a power cut + and never corrupts the file; FULL waits on the disk each time. */ + this.db.exec('PRAGMA synchronous = NORMAL;'); + this.migrate(); + + const q = (sql) => this.db.prepare(sql); + + this.q = { + insert: q('INSERT INTO accounts (handle, handle_folded, ' + + 'key_hash, created_at) VALUES (?, ?, ?, ?)'), + byId: q(`SELECT ${COLUMNS} FROM accounts WHERE id = ?`), + byKey: q(`SELECT ${COLUMNS} FROM accounts WHERE key_hash = ?`), + byFolded: q(`SELECT ${COLUMNS} FROM accounts ` + + 'WHERE handle_folded = ?'), + keptBy: q('SELECT account_id FROM kept_handles ' + + 'WHERE handle_folded = ? AND until > ?'), + keep: q('INSERT OR REPLACE INTO kept_handles ' + + '(handle_folded, account_id, until) VALUES (?, ?, ?)'), + free: q('DELETE FROM kept_handles WHERE account_id = ?'), + keepAll: q('UPDATE kept_handles SET until = max(until, ?) ' + + 'WHERE account_id = ?'), + unkeep: q('DELETE FROM kept_handles WHERE handle_folded = ?'), + lapsed: q('DELETE FROM kept_handles WHERE until <= ?'), + addSession: q('INSERT INTO sessions (token_hash, account_id, ' + + 'last_used_at) VALUES (?, ?, ?)'), + session: q('SELECT account_id, last_used_at FROM sessions ' + + 'WHERE token_hash = ?'), + touch: q('UPDATE sessions SET last_used_at = ? ' + + 'WHERE token_hash = ?'), + endSession: q('DELETE FROM sessions WHERE token_hash = ?'), + endStale: q('DELETE FROM sessions WHERE last_used_at < ?'), + endOthers: q('DELETE FROM sessions ' + + 'WHERE account_id = ? AND token_hash != ?'), + endAll: q('DELETE FROM sessions WHERE account_id = ?'), + setKey: q('UPDATE accounts SET key_hash = ? WHERE id = ?'), + setHandle: q('UPDATE accounts SET handle = ?, ' + + 'handle_folded = ?, ' + + 'renamed_at = coalesce(?, renamed_at) WHERE id = ?'), + setBanned: q('UPDATE accounts SET banned = ? WHERE id = ?'), + remove: q('DELETE FROM accounts WHERE id = ?'), + }; + } + + /* One step a transaction, the version read again under the write + lock: the admin commands may be migrating the same file at once. */ + migrate () + { + const version = () => + this.db.prepare('PRAGMA user_version').get().user_version; + + while (version() < MIGRATIONS.length) + this.transaction(() => + { + const v = version(); + + if (v < MIGRATIONS.length) + { + this.db.exec(MIGRATIONS[v]); + this.db.exec(`PRAGMA user_version = ${v + 1}`); + } + }); + } + + /* IMMEDIATE takes the write lock up front, so what is read inside is + what is written against. */ + transaction (body) + { + this.db.exec('BEGIN IMMEDIATE'); + + try + { + const out = body(); + + this.db.exec('COMMIT'); + return out; + } + catch (e) + { + this.db.exec('ROLLBACK'); + throw e; + } + } + + byId (id) + { + return accountOf(this.q.byId.get(id)); + } + + byKey (keyHash) + { + return accountOf(this.q.byKey.get(keyHash)); + } + + byHandle (folded) + { + return accountOf(this.q.byFolded.get(folded)); + } + + /* The account a folded name is taken by -- its handle, or one it was + renamed from and still keeps -- or null. */ + holder (folded, now) + { + return this.q.byFolded.get(folded)?.id ?? + this.q.keptBy.get(folded, now)?.account_id ?? null; + } + + /* A new account and its first session, or null if the handle is + somebody's; under the write lock, so nobody takes it in between. */ + create ({ handle, keyHash, sessionHash, now }) + { + const folded = foldName(handle); + + return this.transaction(() => + { + if (this.holder(folded, now) !== null) + return null; + + const id = Number(this.q.insert.run(handle, folded, keyHash, + now).lastInsertRowid); + + this.q.addSession.run(sessionHash, id, now); + return this.byId(id); + }); + } + + addSession (sessionHash, id, now) + { + this.q.addSession.run(sessionHash, id, now); + } + + /* The account a session is of, the session marked used: null for one + unknown, or unused for longer than SESSION_TTL_MS (which goes). */ + useSession (sessionHash, now) + { + const s = this.q.session.get(sessionHash); + + if (s === undefined) + return null; + + if (s.last_used_at < now - SESSION_TTL_MS) + { + this.q.endSession.run(sessionHash); + return null; + } + + if (now - s.last_used_at >= TOUCH_EVERY_MS) + this.q.touch.run(now, sessionHash); + + return this.byId(s.account_id); + } + + endSession (sessionHash) + { + this.q.endSession.run(sessionHash); + } + + /* Every session of an account's, or every one but `keep'. */ + endSessions (id, keep = null) + { + if (keep === null) + this.q.endAll.run(id); + else + this.q.endOthers.run(id, keep); + } + + prune (now) + { + this.q.endStale.run(now - SESSION_TTL_MS); + this.q.lapsed.run(now); + } + + /* A new key, every session ended, and `sessionHash' the one left. */ + replaceKey (id, keyHash, sessionHash, now) + { + this.transaction(() => + { + this.q.setKey.run(keyHash, id); + this.endSessions(id); + this.q.addSession.run(sessionHash, id, now); + }); + } + + /* A new handle, or null if it is somebody else's. An owner's own + rename (`now' given) is counted, and the handle it leaves stays + theirs for HANDLE_KEPT_MS; a moderator's is neither. */ + rename (id, handle, at, now = at) + { + const folded = foldName(handle); + + return this.transaction(() => + { + const was = this.byId(id); + const holder = this.holder(folded, now); + + if (was === null || (holder !== null && holder !== id)) + return null; + + this.q.setHandle.run(handle, folded, at, id); + this.q.unkeep.run(folded); + + if (at !== null && was.folded !== folded) + this.q.keep.run(was.folded, id, now + HANDLE_KEPT_MS); + + return this.byId(id); + }); + } + + setBanned (id, banned) + { + this.transaction(() => + { + this.q.setBanned.run(banned ? 1 : 0, id); + + if (banned) + this.endSessions(id); + }); + } + + /* An account gone, and with it its sessions. Its handles -- the one + it had and those it was renamed from -- stay nobody else's for + HANDLE_KEPT_MS from now, so that a deleted name cannot be taken up + to pass as its owner -- unless `free', a moderator's word. */ + remove (id, now, { free = false } = {}) + { + this.transaction(() => + { + const was = this.byId(id); + + this.endSessions(id); + this.q.remove.run(id); + + if (free) + this.q.free.run(id); + else if (was !== null) + { + this.q.keepAll.run(now + HANDLE_KEPT_MS, id); + this.q.keep.run(was.folded, id, now + HANDLE_KEPT_MS); + } + }); + } + + close () + { + this.db.close(); + } +} + +function accountOf (row) +{ + return row === undefined ? null : { + id: row.id, handle: row.handle, folded: row.handle_folded, + createdAt: row.created_at, renamedAt: row.renamed_at, + banned: row.banned !== 0, + }; +} + +/* An account as its owner is shown it. */ +function infoOf (account) +{ + return { + handle: account.handle, + createdAt: account.createdAt, + renameAt: account.renamedAt === null + ? account.createdAt : account.renamedAt + RENAME_EVERY_MS, + }; +} + +/* ---- refusals and limits ---- */ + +export class ApiError extends Error +{ + constructor (status, code, message, headers = {}) + { + super(message); + this.status = status; + this.code = code; + this.headers = headers; + } +} + +const unauthorized = () => + new ApiError(401, 'unauthorized', 'log in again', + { 'WWW-Authenticate': 'Bearer' }); +const badKey = (message = 'no account has that key') => + new ApiError(401, 'bad_key', message); +const banned = () => + new ApiError(403, 'banned', 'this account is banned'); +const taken = (handle) => + new ApiError(409, 'handle_taken', `${JSON.stringify(handle)} is taken`); + +/* One token bucket per key, least recently seen first. */ +class RateLimiter +{ + constructor ({ burst, refillMs }, now) + { + this.burst = burst; + this.refillMs = refillMs; + this.now = now; + this.buckets = new Map(); + } + + refilled (b, now) + { + /* A wall clock stepped back must not drain the bucket. */ + return Math.min(this.burst, b.tokens + + Math.max(0, now - b.at) / this.refillMs); + } + + take (key) + { + const now = this.now(); + let b = this.buckets.get(key); + + if (b === undefined) + { + b = { tokens: this.burst, at: now }; + + if (this.buckets.size >= TRACKED_MAX) + this.buckets.delete(this.buckets.keys().next().value); + } + else + { + b.tokens = this.refilled(b, now); + b.at = now; + this.buckets.delete(key); + } + + this.buckets.set(key, b); + + if (b.tokens < 1) + return false; + + b.tokens--; + return true; + } + + waitMs (key) + { + const b = this.buckets.get(key); + const tokens = b === undefined ? this.burst + : this.refilled(b, this.now()); + + return tokens >= 1 ? 0 : Math.ceil((1 - tokens) * this.refillMs); + } +} + +/* A request's limits, narrowest first, so that a client already over its + own spends nothing of the shared ones. */ +class TieredLimit +{ + constructor (tiers, now, onShared) + { + [this.own, this.site, this.everyone] = + tiers.map((t) => (t === null ? null : new RateLimiter(t, now))); + this.onShared = onShared; + } + + take (client) + { + const site = siteKey(client); + + for (const [limiter, key] of [[this.own, client], + [site === null ? null : this.site, site], + [this.everyone, '*']]) + { + if (limiter === null || limiter.take(key)) + continue; + + if (key === '*') + this.onShared(); + + throw new ApiError(429, 'rate_limited', + 'too many requests; slow down', + { 'Retry-After': String(Math.max(1, Math.ceil( + limiter.waitMs(key) / 1000))) }); + } + } +} + +/* The key a client is limited by: an IPv4 address (an IPv4-mapped one + too), or an IPv6 /64, since one host usually holds a whole /64 and can + hop between its addresses at will. Anything else shares one bucket. */ +export function clientKey (address) +{ + let a = String(address).trim().toLowerCase(); + + /* An IPv6 zone goes. Cut by index: the address can come from a + header a client writes, and a pattern for it is quadratic in '%'s. */ + if (a.includes('%')) + a = a.slice(0, a.indexOf('%')); + const version = net.isIP(a); + + if (version === 4) + return a; + + if (version !== 6) + return 'unknown'; + + /* An IPv4-mapped address is its IPv4 one, written either way. */ + const mapped = /^\[::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})\]$/.exec( + new URL(`http://[${a}]/`).hostname); + + if (mapped !== null) + return mapped.slice(1).map((g) => parseInt(g, 16)) + .flatMap((g) => [g >> 8, g & 255]).join('.'); + + /* An embedded IPv4 tail stands for two groups; it is past the /64. */ + const groups = (part) => (part === '' ? [] : part.split(':').flatMap( + (g) => (g.includes('.') ? ['0', '0'] : [g]))); + const gap = a.indexOf('::'); + let all = groups(a); + + if (gap >= 0) + { + const head = groups(a.slice(0, gap)); + const tail = groups(a.slice(gap + 2)); + + all = [...head, ...Array(Math.max(0, 8 - head.length - tail.length)) + .fill('0'), ...tail]; + } + + return `${all.slice(0, 4).map((g) => (parseInt(g, 16) || 0).toString(16)) + .join(':')}::/64`; +} + +/* The IPv6 /48 a client's /64 is in, which a site usually holds whole; + null for an IPv4 client. */ +export function siteKey (client) +{ + return client.endsWith('::/64') + ? `${client.split(':').slice(0, 3).join(':')}::/48` : null; +} + +/* The address `hops' entries from the right of X-Forwarded-For -- the one + the outermost trusted proxy saw, since each appends whom it was reached + from -- or null with no proxies trusted, or nothing usable there. A + header shorter than that was not all written by trusted proxies, and + its leftmost entry is whatever the client said. */ +export function forwardedAddress (header, hops) +{ + if (hops < 1 || header === undefined) + return null; + + const entries = [header].flat().join(',').split(','); + + if (entries.length < hops) + return null; + + let a = entries[entries.length - hops].trim(); + const bracketed = /^\[([^\]]*)\](?::\d{1,5})?$/.exec(a); + + if (bracketed !== null) + a = bracketed[1]; + else if (/^[\d.]+:\d{1,5}$/.test(a)) + a = a.slice(0, a.lastIndexOf(':')); + + return net.isIP(a) === 0 ? null : a; +} + +/* ---- the service ---- */ + +/* + * `onSessionsEnded({ session } | { account, except }, why)' is told + * whenever sessions end here -- one, or all of an account's but `except' + * -- so that the sockets made with them can go too. + */ +export class Accounts +{ + constructor ({ store, now = Date.now, onSessionsEnded = () => {}, + limits = LIMITS, + log = (line) => process.stderr.write(`${line}\n`) }) + { + this.store = store; + this.now = now; + this.onSessionsEnded = onSessionsEnded; + this.log = log; + this.lastPrune = -Infinity; + this.shared = new Map(); /* what -> { count, at } */ + this.limits = Object.fromEntries(Object.entries(limits).map( + ([what, tiers]) => [what, new TieredLimit( + tiers, now, () => this.sharedRefusal(what))])); + } + + register (client, body) + { + this.limits.register.take(client); + + const handle = handleOf(body.handle); + const key = newKey(); + const session = newSession(); + const now = this.now(); + + this.prune(now); + + const account = this.store.create({ handle, keyHash: secretHash(key), + sessionHash: secretHash(session), + now }); + + if (account === null) + throw taken(handle); + + return { key, session, account: infoOf(account) }; + } + + login (client, body) + { + this.limits.key.take(client); + + const account = this.byKey(body.key); + + if (account.banned) + throw banned(); + + const session = newSession(); + const now = this.now(); + + this.prune(now); + this.store.addSession(secretHash(session), account.id, now); + return { session, account: infoOf(account) }; + } + + me (client, authorization) + { + const { account } = this.session(client, authorization); + + return { account: infoOf(account) }; + } + + /* With the key, not a session alone: a borrowed browser renaming the + account would leave its handle to whoever takes it next. */ + rename (client, authorization, body) + { + const { account } = this.session(client, authorization); + + this.limits.key.take(client); + this.ownKey(account, body.key); + + const handle = handleOf(body.handle); + const now = this.now(); + const { renameAt } = infoOf(account); + + if (handle === account.handle) + return { account: infoOf(account) }; + + if (now < renameAt) + throw new ApiError(409, 'rename_too_soon', + 'the handle can next be changed at ' + + new Date(renameAt).toISOString()); + + const renamed = this.store.rename(account.id, handle, now); + + if (renamed === null) + throw taken(handle); + + return { account: infoOf(renamed) }; + } + + /* A new key for the current one, ending every other session: a + session alone that could make one would be a stolen browser's way + to take the account and then delete it. */ + /* The caller's session goes too, for a new one: a copy of it taken + before would otherwise outlive the key that made it. */ + replaceKey (client, authorization, body) + { + const { account } = this.session(client, authorization); + + this.limits.key.take(client); + + this.ownKey(account, body.key); + + const key = newKey(); + const session = newSession(); + const sessionHash = secretHash(session); + + this.store.replaceKey(account.id, secretHash(key), sessionHash, + this.now()); + this.onSessionsEnded({ account: account.id, except: sessionHash }, + 'the account\'s key was replaced'); + return { key, session }; + } + + logout (client, authorization) + { + this.limits.session.take(client); + + const sessionHash = secretHash(bearer(authorization)); + + this.store.endSession(sessionHash); + this.onSessionsEnded({ session: sessionHash }, 'logged out'); + return {}; + } + + /* The key, not a session alone, so a borrowed browser cannot; and + with a session, the key must be its account's, so a password + manager offering the wrong entry cannot delete another account. + The handle, typed, is the owner saying they mean it. A banned + account cannot: deleting would free its handle for it to take + again. */ + remove (client, authorization, body) + { + const own = authorization === undefined + ? null : this.session(client, authorization).account; + + this.limits.key.take(client); + + const account = this.byKey(body.key); + + if (own !== null && own.id !== account.id) + throw badKey('that key is another account\'s'); + + if (account.banned) + throw banned(); + + const typed = normalizeName(body.handle); + + if (typed === null || foldName(typed) !== account.folded) + throw new ApiError(400, 'confirm', + 'type the account\'s handle to delete it'); + + this.store.remove(account.id, this.now()); + this.onSessionsEnded({ account: account.id, except: null }, + 'the account was deleted'); + return {}; + } + + /* For the relay: the account a room socket's session is of, as `{ id, + handle, sessionHash }', or null for a session that has ended or an + account that is banned. A hash, for a socket checked again later. */ + sessionAccount ({ session, sessionHash = isSession(session) + ? secretHash(session) : null }) + { + const account = sessionHash === null + ? null : this.store.useSession(sessionHash, this.now()); + + return account === null || account.banned + ? null : { id: account.id, handle: account.handle, sessionHash }; + } + + /* Whether a guest may go by `name': not if it is an account's handle, + or one an account still keeps. */ + nameFree (name) + { + return this.store.holder(foldName(name), this.now()) === null; + } + + ownKey (account, raw) + { + if (this.byKey(raw).id !== account.id) + throw badKey('that key is another account\'s'); + } + + byKey (raw) + { + const key = normalizeKey(raw); + const account = key === null ? null + : this.store.byKey(secretHash(key)); + + if (account === null) + throw badKey(); + + return account; + } + + session (client, authorization) + { + this.limits.session.take(client); + + const sessionHash = secretHash(bearer(authorization)); + const account = this.store.useSession(sessionHash, this.now()); + + if (account === null) + throw unauthorized(); + + if (account.banned) + throw banned(); + + return { account, sessionHash }; + } + + prune (now) + { + if (now - this.lastPrune < PRUNE_EVERY_MS) + return; + + this.lastPrune = now; + + /* Before the request's own write, and never its failure: one + that failed after an account was made would lose its key. */ + try + { + this.store.prune(now); + } + catch (e) + { + this.log(`accounts: pruning: ${e.message}`); + } + } + + sharedRefusal (what) + { + const now = this.now(); + const seen = this.shared.get(what) ?? { count: 0, at: -Infinity }; + + seen.count++; + this.shared.set(what, seen); + + if (now - seen.at < SHARED_LOG_EVERY_MS) + return; + + this.log(`accounts: the ${what} limit shared by everyone refused ` + + `${seen.count} request${seen.count === 1 ? '' : 's'}`); + seen.count = 0; + seen.at = now; + } +} + +function handleOf (raw) +{ + const handle = normalizeName(raw); + + if (handle === null) + throw new ApiError(400, 'bad_handle', + 'a handle needs a visible character'); + + if (foldName(handle).startsWith(foldName(GUEST_PREFIX))) + throw new ApiError(400, 'bad_handle', + `a handle may not start with ${GUEST_PREFIX}`); + + return handle; +} + +function bearer (authorization) +{ + const m = /^Bearer +(\S+)\s*$/i.exec(authorization ?? ''); + + if (m === null || !isSession(m[1])) + throw unauthorized(); + + return m[1]; +} + +/* ---- the routes ---- */ + +/* A request listener for everything under ACCOUNT_API. `corsOrigin' is + the page's origin, or `*' for any (a harness); a request from any other + origin is refused, and one with none (curl) is let through. The relay + serves none of this without one (relay.mjs); + `trustProxy' how many proxies in front append to X-Forwarded-For. Only + count proxies that set it, or a client picks its own rate limit. */ +export function accountRoutes (accounts, { corsOrigin = null, + trustProxy = 0, + log = accounts.log } = {}) +{ + /* Behind a proxy that sends no X-Forwarded-For, every client is the + proxy's address and shares its buckets: a few registrations would + hold off everyone's. Said once, loudly. */ + let unforwarded = false; + + const routes = { + '/register': ['POST', (c, a, body) => accounts.register(c, body)], + '/login': ['POST', (c, a, body) => accounts.login(c, body)], + '/me': ['GET', (c, a) => accounts.me(c, a)], + '/handle': ['POST', (c, a, body) => accounts.rename(c, a, body)], + '/key': ['POST', (c, a, body) => accounts.replaceKey(c, a, body)], + '/logout': ['POST', (c, a) => accounts.logout(c, a)], + '/delete': ['POST', (c, a, body) => accounts.remove(c, a, body)], + }; + + return async (req, res) => + { + const send = (status, body, headers = {}) => + { + const text = JSON.stringify(body); + + res.writeHead(status, { + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Length': Buffer.byteLength(text), + 'Cache-Control': 'no-store', + ...headers, + }); + res.end(text); + }; + + if (corsOrigin) + { + res.setHeader('Access-Control-Allow-Origin', corsOrigin); + res.setHeader('Vary', 'Origin'); + } + + try + { + const route = routes[new URL(req.url, 'http://relay').pathname + .slice(ACCOUNT_API.length)]; + + if (route === undefined) + throw new ApiError(404, 'not_found', 'not found'); + + /* A preflight: a JSON POST from the page's origin needs one, + and so does anything carrying a session. */ + if (req.method === 'OPTIONS') + { + res.writeHead(204, { + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + 'Access-Control-Allow-Headers': + 'Content-Type, Authorization', + 'Access-Control-Max-Age': '86400', + }); + res.end(); + return; + } + + const [method, run] = route; + + if (req.method !== method) + throw new ApiError(405, 'method_not_allowed', `use ${method}`, + { Allow: `${method}, OPTIONS` }); + + /* A page elsewhere may send a form or text/plain to any site + without asking first: refused here, or a page anywhere could + register handles from its visitors' addresses. JSON from + another origin is preflighted, and so is refused by the + browser unless the origin is the page's. */ + const origin = req.headers.origin; + + if (origin !== undefined && corsOrigin !== '*' && + origin !== corsOrigin) + throw new ApiError(403, 'bad_origin', + `${origin} may not use this relay's ` + + 'accounts'); + + if (method === 'POST' && + !/^application\/json\s*(;|$)/i.test( + req.headers['content-type'] ?? '')) + throw new ApiError(415, 'bad_request', + 'the body must be application/json', + { Connection: 'close' }); + + const forwarded = forwardedAddress(req.headers['x-forwarded-for'], + trustProxy); + + if (trustProxy > 0 && req.headers['x-forwarded-for'] === + undefined && !unforwarded) + { + unforwarded = true; + log('accounts: TRUST_PROXY is set but the proxy sends no ' + + 'X-Forwarded-For; every client shares one rate limit ' + + 'until it does (docs/RELAY.md)'); + } + + const client = clientKey(forwarded ?? req.socket.remoteAddress ?? + 'unknown'); + const body = method === 'POST' ? await readJson(req) : {}; + + send(200, run(client, req.headers.authorization, body)); + } + catch (e) + { + if (e instanceof ApiError) + send(e.status, { error: e.code, message: e.message }, + e.headers); + else if (!res.headersSent) + { + process.stderr.write(`accounts: ${e.stack}\n`); + send(500, { error: 'internal', message: 'internal error' }); + } + } + }; +} + +/* A JSON object of at most BODY_MAX_BYTES, or none at all; past the cap + the rest is left unread and the connection goes with it. */ +function readJson (req) +{ + const tooLarge = () => + new ApiError(413, 'too_large', + `the body is over ${BODY_MAX_BYTES} bytes`, + { Connection: 'close' }); + + return new Promise((resolve, reject) => + { + if (Number(req.headers['content-length']) > BODY_MAX_BYTES) + { + reject(tooLarge()); + return; + } + + const chunks = []; + let size = 0; + + req.on('data', (chunk) => + { + size += chunk.length; + + if (size > BODY_MAX_BYTES) + { + reject(tooLarge()); + req.pause(); + } + else + chunks.push(chunk); + }); + req.on('error', reject); + req.on('end', () => + { + const text = Buffer.concat(chunks).toString('utf8'); + let body; + + try + { + body = text.trim() === '' ? {} : JSON.parse(text); + } + catch + { + reject(new ApiError(400, 'bad_request', + 'the body is not JSON')); + return; + } + + if (typeof body !== 'object' || body === null || + Array.isArray(body)) + reject(new ApiError(400, 'bad_request', + 'the body is not a JSON object')); + else + resolve(body); + }); + }); +} + +/* ---- the admin commands ---- */ + +export const ADMIN_USAGE = `usage: relay.mjs admin + account show an account + rename change its handle (the owner's own renames are + unaffected, and the old handle is free at once) + ban end its sessions and refuse it until unbanned + unban let it log in again + revoke end its sessions; its key still logs in + delete [--free] delete it; its handles stay nobody's for 30 + days, as when its owner deletes it, unless + --free frees them now`; + +/* One command, its lines to `out'; returns the exit status. The relay + notices sessions ended here within a minute (relay.mjs). */ +export function runAdmin (args, store, out, now = Date.now()) +{ + const [command, raw, ...rest] = args; + const usage = () => + { + out(ADMIN_USAGE); + return 2; + }; + + const free = command === 'delete' && rest[0] === '--free'; + + if (raw === undefined || + rest.length !== (command === 'rename' || free ? 1 : 0)) + return usage(); + + const name = normalizeName(raw); + const account = name === null ? null : store.byHandle(foldName(name)); + const quoted = JSON.stringify(account?.handle ?? raw); + + if (!['account', 'rename', 'ban', 'unban', 'revoke', 'delete'] + .includes(command)) + return usage(); + + if (account === null) + { + out(`no account ${quoted}`); + return 1; + } + + switch (command) + { + case 'account': + { + const day = (ms) => new Date(ms).toISOString().slice(0, 10); + + out(`#${account.id} ${quoted} ` + + `created ${day(account.createdAt)}` + + (account.renamedAt === null + ? '' : ` renamed ${day(account.renamedAt)}`) + + (account.banned ? ' [banned]' : '')); + return 0; + } + + case 'rename': + { + let handle; + + try + { + handle = handleOf(rest[0]); + } + catch (e) + { + out(`${JSON.stringify(rest[0])}: ${e.message}`); + return 1; + } + + const renamed = store.rename(account.id, handle, null, now); + + if (renamed === null) + { + out(`${JSON.stringify(handle)} is taken`); + return 1; + } + + out(`${quoted} is now ${JSON.stringify(renamed.handle)}`); + return 0; + } + + case 'ban': + case 'unban': + store.setBanned(account.id, command === 'ban'); + out(`${quoted} ${command === 'ban' ? 'banned' : 'unbanned'}`); + return 0; + + case 'revoke': + store.endSessions(account.id); + out(`${quoted}'s sessions ended`); + return 0; + + default: + store.remove(account.id, now, { free }); + out(`${quoted} deleted` + (free ? '; its handles are free' : '')); + return 0; + } +} diff --git a/wasm/web/accountstest.mjs b/wasm/web/accountstest.mjs new file mode 100644 index 00000000..fa7c1654 --- /dev/null +++ b/wasm/web/accountstest.mjs @@ -0,0 +1,618 @@ +#!/usr/bin/env node +/* + * Copyright (C) 2004-2026 Metaphonic Labs + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by the + * Free Software Foundation; either version 2 of the License, or (at your + * option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General + * Public License along with this program; if not, write to the + * Free Software Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ + +/* + * accountstest.mjs -- the relay's accounts, over HTTP as the page uses + * them, on a clock this script turns. + * + * node wasm/web/accountstest.mjs + * + * Keys are eight words and read back however they were typed; handles are + * cleaned up, and clash when they fold alike; registering, logging in, + * renaming, a new key, logging out and deleting each do what they say, and + * end the sessions they should; a stale, revoked or banned session is + * refused; a renamed handle stays its owner's for a while; the limits + * refuse past their buckets, per client, per /48 and across everyone; and + * the routes answer a CORS preflight and refuse a body that is too big. + * + * Exit status is the number of failures. + */ + +import fs from 'node:fs'; +import http from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; + +import { foldName, normalizeKey, normalizeName } from './account.js'; +import { AccountStore, Accounts, HANDLE_KEPT_MS, RENAME_EVERY_MS, + SESSION_TTL_MS, accountRoutes, clientKey, forwardedAddress, + newKey, runAdmin } from './accounts.mjs'; +import { KEY_WORDS } from './wordlist.mjs'; + +let failures = 0; + +function check (cond, what) +{ + if (cond) + process.stdout.write(`ok ${what}\n`); + else + { + failures++; + process.stdout.write(`FAIL ${what}\n`); + } +} + +const DAY_MS = 24 * 60 * 60 * 1000; +const PAGE = 'https://page.example.org'; + +/* Limits nothing below reaches, but for the test of them. */ +const ROOMY = { burst: 1000, refillMs: 1000 }; +const roomy = { register: [ROOMY, ROOMY, ROOMY], key: [ROOMY, ROOMY, null], + session: [ROOMY, null, null] }; + +/* An account service on `store', behind its routes on a port of its own, + with the clock at `clock.now' and every onSessionsEnded kept. */ +async function serve (store, { limits = roomy, trustProxy = 0 } = {}) +{ + const clock = { now: Date.now() }; + const ended = []; + const logged = []; + const accounts = new Accounts({ store, now: () => clock.now, limits, + onSessionsEnded: (e) => ended.push(e), + log: (line) => logged.push(line) }); + const server = http.createServer( + accountRoutes(accounts, { corsOrigin: PAGE, trustProxy })); + + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + + const base = `http://127.0.0.1:${server.address().port}/api/account`; + + /* A route, as { status, body, headers }. */ + const call = async (route, { body, session, method = 'POST', + headers = {} } = {}) => + { + const res = await fetch(`${base}/${route}`, { + method, + headers: { ...(method === 'POST' + ? { 'Content-Type': 'application/json' } : {}), + ...headers, + ...(session ? { Authorization: `Bearer ${session}` } + : {}) }, + body: body === undefined ? undefined + : typeof body === 'string' ? body : JSON.stringify(body), + }); + const text = await res.text(); + + return { status: res.status, headers: res.headers, + body: text === '' ? null : JSON.parse(text) }; + }; + + return { clock, ended, logged, call, base, + close: () => server.close() }; +} + +/* ---- keys and names ---- */ + +{ + const key = newKey().split('-'); + + check(key.length === 8 && key.every((w) => KEY_WORDS.includes(w)), + 'a key is eight words of the list'); + + const k = 'abacus-zoom-acid-aloe-wool-yarn-zen-acre'; + + for (const [typed, want] of [ + [k, k], + ['Abacus Zoom ACID aloe wool yarn zen acre', k], + [' abacus--zoom acid-aloe\twool yarn zen acre ', k], + ['abacus zoom acid aloe wool yarn zen', null], + ['abacus zoom acid aloe wool yarn zen acre acre', null], + ['abacus zoom acid aloe wool yarn zen acr3', null], + [7, null]]) + check(normalizeKey(typed) === want, + `the key ${JSON.stringify(typed)} reads as ${want}`); + + for (const [raw, want] of [ + [' Ann ', 'Ann'], + ['A\u202Enn', 'Ann'], + ['An\u200Bn\u0000', 'Ann'], + ['two spaces\u3164here', 'two spaces here'], + ['\u200B\u2800', null], + ['x'.repeat(40), 'x'.repeat(32)], + ['e\u0301\u0301\u0301\u0301', '\u00E9\u0301\u0301'], + [null, null]]) + check(normalizeName(raw) === want, + `the name ${JSON.stringify(raw)} is ${JSON.stringify(want)}`); + + for (const [a, b, clash] of [ + ['Ann', 'aNN', true], + ['stra\u00DFe', 'STRASSE', true], + ['\u0130stanbul', 'istanbul', true], + ['\uFF41nn', 'ann', true], + ['\uFB01sh', 'fish', true], + ['victor', '\u03BDictor', true], + ['paul', 'pa\u03C5l', true], + ['zo\u00EB', 'zo\u0451', true], + ['ann', '\u0251nn', true], + ['guest-1', 'g\u03C5est-1', true], + ['NICK', '\u039DICK', true], + ['Ian', 'lan', true], + ['m', 'rn', true], + ['Ann', 'Anne', false], + ['zoe', 'zo\u00EB', false]]) + check((foldName(a) === foldName(b)) === clash, + `${JSON.stringify(a)} and ${JSON.stringify(b)} ` + + `${clash ? 'clash' : 'do not clash'}`); +} + +/* ---- the routes, start to end ---- */ + +{ + const s = await serve(new AccountStore(':memory:')); + + try + { + const ann = await s.call('register', { body: { handle: ' Ann ' } }); + + check(ann.status === 200 && ann.body.account.handle === 'Ann' && + normalizeKey(ann.body.key) === ann.body.key && + /^s_[0-9a-f]{32}$/.test(ann.body.session), + 'registering hands over a key, a session and the handle'); + check(ann.headers.get('access-control-allow-origin') === PAGE, + 'and says which origin may read it'); + + for (const [handle, status, error] of [ + ['ANN', 409, 'handle_taken'], + ['\u0410nn', 409, 'handle_taken'], + ['A\u200Dnn', 409, 'handle_taken'], + ['\u200B', 400, 'bad_handle'], + ['guest-123', 400, 'bad_handle'], + ['Guest-Ann', 400, 'bad_handle'], + [7, 400, 'bad_handle']]) + { + const r = await s.call('register', { body: { handle } }); + + check(r.status === status && r.body.error === error, + `registering ${JSON.stringify(handle)} is ${error}`); + } + + const typed = ann.body.key.toUpperCase().replaceAll('-', ' '); + const login = await s.call('login', { body: { key: typed } }); + + check(login.status === 200 && login.body.account.handle === 'Ann' && + login.body.session !== ann.body.session, + 'the key logs in however it is typed, to a new session'); + + const wrong = await s.call('login', { body: { + key: 'abacus zoom acid aloe wool yarn zen acre' } }); + + check(wrong.status === 401 && wrong.body.error === 'bad_key', + 'a key that is nobody\'s logs nobody in'); + + const me = await s.call('me', { method: 'GET', + session: ann.body.session }); + const nobody = await s.call('me', { method: 'GET' }); + + check(me.status === 200 && me.body.account.handle === 'Ann', + 'a session says whose it is'); + check(nobody.status === 401 && nobody.body.error === 'unauthorized' && + nobody.headers.get('www-authenticate') === 'Bearer', + 'and no session is told to log in'); + + /* A rename: with the key, once a month, and the handle left + behind stays its owner's for a while, so nobody can pass as them + under it. */ + const keyless = await s.call('handle', { session: ann.body.session, + body: { handle: 'Annie' } }); + const renamed = await s.call('handle', { + session: ann.body.session, + body: { handle: 'Annie', key: ann.body.key } }); + const again = await s.call('handle', { + session: ann.body.session, + body: { handle: 'Ann', key: ann.body.key } }); + const squat = await s.call('register', { body: { handle: 'ann' } }); + + check(keyless.status === 401 && keyless.body.error === 'bad_key', + 'a session alone cannot rename the account'); + check(renamed.status === 200 && + renamed.body.account.handle === 'Annie' && + again.status === 409 && again.body.error === 'rename_too_soon', + 'a handle changes once, then not again for a while'); + check(squat.status === 409 && squat.body.error === 'handle_taken', + 'and nobody else can take the one it was'); + + s.clock.now += Math.max(HANDLE_KEPT_MS, RENAME_EVERY_MS) + DAY_MS; + + const lapsed = await s.call('register', { body: { handle: 'ann' } }); + + check(lapsed.status === 200, + 'until it has been free long enough'); + + /* A new key takes the current one, and ends every session -- + the one asking for it too, which is handed a new one, so that a + copy of it taken before does not outlive the key. */ + const other = (await s.call('login', { + body: { key: ann.body.key } })).body.session; + const notMine = await s.call('key', { + session: ann.body.session, body: { key: lapsed.body.key } }); + const replaced = await s.call('key', { session: ann.body.session, + body: { key: ann.body.key } }); + const oldKey = await s.call('login', { body: { key: ann.body.key } }); + const newKey_ = await s.call('login', { + body: { key: replaced.body.key } }); + const otherMe = await s.call('me', { method: 'GET', session: other }); + const thisMe = await s.call('me', { method: 'GET', + session: ann.body.session }); + const current = replaced.body.session; + const nowMe = await s.call('me', { method: 'GET', session: current }); + + check(notMine.status === 401 && notMine.body.error === 'bad_key', + 'a new key wants this account\'s key, not another\'s'); + check(replaced.status === 200 && oldKey.status === 401 && + newKey_.status === 200, + 'a new key logs in, and the old one does not'); + check(otherMe.status === 401 && thisMe.status === 401 && + nowMe.status === 200 && + s.ended.some((e) => e.account !== undefined && + e.except !== null), + 'and every session before it is ended, the asker\'s too, ' + + 'which has a new one; and the relay is told'); + + /* Logging out ends the one session. */ + const out = await s.call('logout', { session: newKey_.body.session }); + const after = await s.call('me', { method: 'GET', + session: newKey_.body.session }); + + check(out.status === 200 && after.status === 401 && + s.ended.some((e) => e.session !== undefined), + 'logging out ends the session, and the relay is told'); + + /* A year unused and a session lapses; used, it does not. */ + const stale = (await s.call('login', { + body: { key: replaced.body.key } })).body.session; + + s.clock.now += SESSION_TTL_MS / 2; + await s.call('me', { method: 'GET', session: current }); + s.clock.now += SESSION_TTL_MS / 2 + DAY_MS; + + const staleMe = await s.call('me', { method: 'GET', session: stale }); + const usedMe = await s.call('me', { method: 'GET', + session: current }); + + check(staleMe.status === 401 && usedMe.status === 200, + 'a session unused for a year lapses; one in use does not'); + + /* Deleting takes the key -- with a session, that session's + account's key -- and the handle typed out. */ + const wrongDelete = await s.call('delete', { + session: current, + body: { key: lapsed.body.key, handle: 'Annie' } }); + const unconfirmed = await s.call('delete', { + session: current, + body: { key: replaced.body.key, handle: 'Ann' } }); + const deleted = await s.call('delete', { + session: current, + body: { key: replaced.body.key, handle: 'annie' } }); + const gone = await s.call('login', { + body: { key: replaced.body.key } }); + + check(wrongDelete.status === 401 && unconfirmed.status === 400 && + unconfirmed.body.error === 'confirm' && + deleted.status === 200 && gone.status === 401, + 'deleting takes the account\'s own key and its handle typed, ' + + 'and then it is gone'); + + const reuse = await s.call('register', { body: { handle: 'Annie' } }); + + s.clock.now += HANDLE_KEPT_MS + DAY_MS; + + const reused = await s.call('register', { body: { handle: 'Annie' } }); + + check(reuse.status === 409 && reused.status === 200, + 'and its handle is nobody\'s until it has been free long ' + + 'enough'); + + /* A page elsewhere can send a form or text/plain without asking: + a request that is not JSON, or from another origin, is not let + register anything. */ + const plain = await s.call('register', { + headers: { 'Content-Type': 'text/plain' }, + body: { handle: 'Plain' } }); + const foreign = await s.call('register', { + headers: { Origin: 'https://elsewhere.example.org' }, + body: { handle: 'Foreign' } }); + const ours = await s.call('register', { + headers: { Origin: PAGE }, body: { handle: 'Ours' } }); + + check(plain.status === 415 && foreign.status === 403 && + ours.status === 200, + 'only JSON from the page\'s origin registers'); + + /* Past the cap with no length to refuse it by up front. */ + const chunked = await new Promise((resolve) => + { + const req = http.request(`${s.base}/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', + 'Transfer-Encoding': 'chunked' }, + }, (res) => resolve(res.statusCode)); + + req.on('error', () => resolve('reset')); + + for (let i = 0; i < 8; i++) + req.write(' '.repeat(512)); + + req.end('{}'); + }); + + check(chunked === 413, 'a chunked body over a KiB is refused'); + + const big = await s.call('register', { + body: JSON.stringify({ handle: 'x'.repeat(2000) }) }); + const notJson = await s.call('register', { body: 'handle=x' }); + const preflight = await s.call('me', { + method: 'OPTIONS', + headers: { Origin: PAGE, + 'Access-Control-Request-Headers': 'authorization' } }); + + check(big.status === 413 && notJson.status === 400, + 'a body over a KiB, or not JSON, is refused'); + check(preflight.status === 204 && + preflight.headers.get('access-control-allow-origin') === PAGE && + /authorization/i.test( + preflight.headers.get('access-control-allow-headers')), + 'a preflight lets the page send a session'); + } + finally + { + s.close(); + } +} + +/* ---- the admin commands, on a file ---- */ + +{ + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'accountstest-')); + const file = path.join(dir, 'relay.db'); + const s = await serve(new AccountStore(file)); + const admin = (...args) => + { + const store = new AccountStore(file); + const lines = []; + const status = runAdmin(args, store, (l) => lines.push(l)); + + store.close(); + return { status, lines }; + }; + + try + { + const bo = (await s.call('register', { body: { handle: 'Bo' } })).body; + const shown = admin('account', 'BO'); + const banned = admin('ban', 'bo'); + const login = await s.call('login', { body: { key: bo.key } }); + const me = await s.call('me', { method: 'GET', session: bo.session }); + + check(shown.status === 0 && /"Bo"/.test(shown.lines[0]), + 'the admin commands find an account by its handle, folded'); + const selfDelete = await s.call('delete', { + body: { key: bo.key, handle: 'Bo' } }); + + check(banned.status === 0 && login.body.error === 'banned' && + me.status === 401, + 'a ban ends the sessions and refuses the key'); + check(selfDelete.body?.error === 'banned', + 'and a banned account cannot delete itself to free its ' + + 'handle'); + + admin('unban', 'bo'); + + const session = (await s.call('login', { body: { key: bo.key } })) + .body?.session; + + check(session !== undefined, 'and an unban lets it log in again'); + + admin('revoke', 'bo'); + check((await s.call('me', { method: 'GET', session })).status === 401, + 'revoking ends every session'); + + check(admin('rename', 'bo', 'guest-9').status === 1, + 'a moderator cannot rename anyone into a guest\'s name'); + + const renamed = admin('rename', 'bo', 'Bob'); + const asOwner = await s.call('login', { body: { key: bo.key } }); + + check(renamed.status === 0 && + asOwner.body.account.handle === 'Bob' && + asOwner.body.account.renameAt <= s.clock.now && + (await s.call('register', { body: { handle: 'bo' } })) + .status === 200, + 'a moderator\'s rename leaves the owner\'s free, and keeps ' + + 'nothing'); + + check(admin('delete', 'bob').status === 0 && + (await s.call('login', { body: { key: bo.key } })).status === + 401 && admin('account', 'bob').status === 1 && + (await s.call('register', { body: { handle: 'Bob' } })) + .status === 409, + 'deleting from the admin commands deletes, and keeps the ' + + 'handle'); + + const cy = (await s.call('register', { body: { handle: 'Cy' } })) + .body; + + check(cy.key !== undefined && + admin('delete', 'cy', '--free').status === 0 && + (await s.call('register', { body: { handle: 'Cy' } })) + .status === 200, + 'unless told to free it'); + check(admin('frobnicate', 'x').status === 2, + 'an unknown command says how to use them'); + } + finally + { + s.close(); + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +/* ---- a deleted account's old handles, and a failing sweep ---- */ + +{ + const s = await serve(new AccountStore(':memory:')); + + try + { + /* Renamed from twenty days before the account goes: still kept + thirty days after it went. */ + const eve = (await s.call('register', { body: { handle: 'Eve' } })) + .body; + + await s.call('handle', { session: eve.session, + body: { handle: 'Eva', key: eve.key } }); + s.clock.now += 20 * DAY_MS; + await s.call('delete', { body: { key: eve.key, handle: 'Eva' } }); + s.clock.now += 20 * DAY_MS; + + check((await s.call('register', { body: { handle: 'Eve' } })) + .status === 409, + 'a deleted account\'s old handle is kept 30 days from the ' + + 'delete'); + } + finally + { + s.close(); + } + + const store = new AccountStore(':memory:'); + + store.prune = () => { throw new Error('database is locked'); }; + + const t = await serve(store); + + try + { + const r = await t.call('register', { body: { handle: 'Fen' } }); + + check(r.status === 200 && normalizeKey(r.body.key) === r.body.key, + 'a sweep that fails does not lose a new account its key'); + } + finally + { + t.close(); + } +} + +/* ---- the limits ---- */ + +{ + const s = await serve(new AccountStore(':memory:'), { + trustProxy: 1, + limits: { register: [{ burst: 2, refillMs: 60000 }, + { burst: 3, refillMs: 60000 }, + { burst: 6, refillMs: 60000 }], + key: [{ burst: 2, refillMs: 60000 }, ROOMY, null], + session: [ROOMY, null, null] } }); + const from = (address) => ({ 'X-Forwarded-For': `9.9.9.9, ${address}` }); + let n = 0; + const register = (address) => s.call('register', { + headers: from(address), body: { handle: `h${n++}` } }); + const statuses = async (addresses) => + { + const out = []; + + for (const a of addresses) + out.push((await register(a)).status); + + return out.join(' '); + }; + + try + { + /* Two each, three a /48 and six in all. */ + check(await statuses(['10.0.0.1', '10.0.0.1', '10.0.0.1']) === + '200 200 429', 'a client is refused past its own bucket'); + check(await statuses(['2001:db8:1:1::1', '2001:db8:1:1::2', + '2001:db8:1:1::3', '2001:db8:1:2::1', + '2001:db8:1:3::1']) === + '200 200 429 200 429', + 'a /64 is one client, and a /48 shares a bucket'); + check(await register('192.0.2.7').then((r) => r.status) === 200, + 'another client still gets in'); + + const last = await register('192.0.2.8'); + + check(last.status === 429 && + Number(last.headers.get('retry-after')) >= 1, + 'and everyone shares one, which says when to try again'); + + const keys = []; + + for (let i = 0; i < 3; i++) + keys.push((await s.call('login', { headers: from('10.0.0.9'), + body: { key: 'x' } })).status); + + check(keys.join(' ') === '401 401 429', + 'key requests are limited per client too'); + + /* Behind a proxy that does not say who the client is, said once: + everyone is sharing the proxy's buckets. */ + check(s.logged.every((l) => !/X-Forwarded-For/.test(l)), + 'nothing is said while the proxy says who the client is'); + + for (let i = 0; i < 2; i++) + await s.call('me', { method: 'GET' }); + + check(s.logged.filter((l) => /sends no X-Forwarded-For/.test(l)) + .length === 1, + 'a trusted proxy that sends no X-Forwarded-For is reported, ' + + 'once'); + } + finally + { + s.close(); + } + + for (const [address, want] of [ + ['192.0.2.1', '192.0.2.1'], + ['::ffff:192.0.2.1', '192.0.2.1'], + ['::ffff:c000:201', '192.0.2.1'], + ['2001:db8:a:b:c:d:e:f', '2001:db8:a:b::/64'], + ['2001:DB8::1', '2001:db8:0:0::/64'], + ['fe80::1%eth0', 'fe80:0:0:0::/64'], + ['not an address', 'unknown']]) + check(clientKey(address) === want, + `${address} is limited as ${want}`); + + for (const [header, hops, want] of [ + ['1.1.1.1, 2.2.2.2', 1, '2.2.2.2'], + ['1.1.1.1, 2.2.2.2', 2, '1.1.1.1'], + ['1.1.1.1', 3, null], + ['1.1.1.1', 0, null], + ['[2001:db8::1]:443', 1, '2001:db8::1'], + ['1.1.1.1:80', 1, '1.1.1.1'], + ['forged', 1, null]]) + check(forwardedAddress(header, hops) === want, + `X-Forwarded-For ${JSON.stringify(header)} behind ${hops} ` + + `is ${want}`); +} + +process.stdout.write(`\n${failures === 0 ? 'accounts do what they say' + : `${failures} failed`}\n`); +process.exitCode = failures; diff --git a/wasm/web/accountui.js b/wasm/web/accountui.js new file mode 100644 index 00000000..1d77c362 --- /dev/null +++ b/wasm/web/accountui.js @@ -0,0 +1,621 @@ +/* + * Copyright (C) 2004-2026 Metaphonic Labs + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by the + * Free Software Foundation; either version 2 of the License, or (at your + * option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General + * Public License along with this program; if not, write to the + * Free Software Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ + +/* + * accountui.js -- the room page's account dialog: create an account, log + * in with a key, and once logged in change the handle, replace the key, + * log out or delete the account. Opened from the join card. + * + * The key is the account and the relay picks it, so the dialog's real job + * is getting the key into a password manager. Managers save what is + * submitted in a login-shaped form and fill it back on the same site, so + * the key is shown in one: the handle as the username and the key as a + * password (`new-password' when issued, `current-password' wherever it is + * asked for). Submitting the form, then the form going away, is what makes + * a manager offer to save it. Show, Copy and Download cover people without + * one. + * + * The page keeps the session and the handle beside it, never the key. + */ + +import { ACCOUNT_API, apiOriginOf, normalizeName } from './account.js'; + +/* A session is kept under the relay it is for: one relay's is never + another's to see, nor to end. */ +const STORE = 'thinksynth:account:'; + +/* A request slower than this is a relay that is not answering. */ +const REQUEST_MS = 10000; + +function load (origin) +{ + try + { + const kept = JSON.parse(localStorage.getItem(STORE + origin)); + + return typeof kept?.session === 'string' ? kept : null; + } + catch + { + return null; + } +} + +function keep (origin, kept) +{ + try + { + if (kept === null) + localStorage.removeItem(STORE + origin); + else + localStorage.setItem(STORE + origin, JSON.stringify(kept)); + } + catch + { + /* No storage: logged in for as long as the page is open. */ + } +} + +/* A failed request: the relay's { error, message }, or `network'. */ +class AccountError extends Error +{ + constructor (code, message) + { + super(message); + this.code = code; + } +} + +/* The account routes on the relay at `origin'. */ +function client (origin) +{ + const call = async (route, { body, session } = {}) => + { + let res; + + try + { + res = await fetch(`${origin}${ACCOUNT_API}${route}`, { + method: route === '/me' ? 'GET' : 'POST', + headers: { + ...(route === '/me' + ? {} : { 'Content-Type': 'application/json' }), + ...(session ? { Authorization: `Bearer ${session}` } : {}), + }, + body: route === '/me' ? undefined : JSON.stringify(body ?? {}), + signal: AbortSignal.timeout(REQUEST_MS), + }); + } + catch + { + throw new AccountError('network', 'the relay is not answering'); + } + + const out = await res.json().catch(() => null); + + if (typeof out !== 'object' || out === null) + throw new AccountError('network', 'the relay has no accounts'); + + if (!res.ok) + throw new AccountError(String(out.error), String(out.message)); + + return out; + }; + + return { + register: (handle) => call('/register', { body: { handle } }), + login: (key) => call('/login', { body: { key } }), + me: (session) => call('/me', { session }), + rename: (session, handle, key) => + call('/handle', { session, body: { handle, key } }), + replaceKey: (session, key) => call('/key', { session, body: { key } }), + logout: (session) => call('/logout', { session }), + remove: (session, key, handle) => + call('/delete', { session, body: { key, handle } }), + }; +} + +/* A failed request, in the words of the person who made it. */ +function failed (e) +{ + switch (e.code) + { + case 'network': + return 'Cannot reach the relay right now.'; + case 'rate_limited': + return 'Too many tries; wait a little and try again.'; + case 'bad_key': + return /another/.test(e.message) + ? 'That is another account\'s key.' + : 'That key does not match any account.'; + case 'handle_taken': + return 'That handle is taken.'; + case 'bad_handle': + return 'A handle needs at least one visible character.'; + case 'confirm': + return 'Type your handle to delete the account.'; + case 'banned': + return 'This account is banned.'; + default: + return e.message; + } +} + +function el (tag, props = {}, ...children) +{ + const e = Object.assign(document.createElement(tag), props); + + e.append(...children); + return e; +} + +function button (text, onclick) +{ + return el('button', { type: 'button', textContent: text, onclick }); +} + +function section (heading, ...children) +{ + return el('section', { className: 'accountpart' }, + el('h2', { textContent: heading, dir: 'auto' }), ...children); +} + +/* A login-shaped form: the handle as the username, the key as the + password. `id' keeps several on one screen apart. */ +function keyForm (id, handle, autocomplete, submitText) +{ + const user = el('input', { id: `account-${id}-user`, name: 'username', + autocomplete: 'username', value: handle }); + const key = el('input', { id: `account-${id}-key`, name: 'password', + type: 'password', autocomplete, + required: true, spellcheck: false, + autocapitalize: 'none', + placeholder: 'eight words' }); + const submit = el('button', { type: 'submit', textContent: submitText }); + const form = el('form', { method: 'post', autocomplete: 'on' }, + el('label', {}, 'Handle ', user), + el('label', {}, 'Key ', key), submit); + + return { form, key, submit }; +} + +/* A key as a text file, for whoever has no password manager. */ +function download (handle, key) +{ + const url = URL.createObjectURL(new Blob([ + `thinksynth account\n\nHandle: ${handle}\nKey: ${key}\n` + + `Site: ${location.origin}\n\nThe key is the account: anyone with ` + + 'it can play as you, and a lost one cannot be replaced.\n'], + { type: 'text/plain' })); + + el('a', { href: url, download: 'thinksynth-key.txt' }).click(); + + /* Revoked at once, some browsers cancel the download. */ + setTimeout(() => URL.revokeObjectURL(url), 30000); +} + +/* + * `open' is the join card's button, `dialog' the the screens go + * in, and `relays()' resolves to `{ url, home }': the relay this page + * joins and the one the site names, the very values the join uses. + * `onChange(handle)' is called whenever who this page is changes: a + * handle, or null for a guest. + * + * Accounts are the home relay's only. A page sent to another one (the + * URL's `relay') joins it as a guest, with no button: a relay that is not + * the site's has no business seeing its session, nor a key typed into a + * dialog it could stand behind. The button stays hidden, too, on a relay + * without accounts. + */ +export function createAccounts ({ open, dialog, relays, onChange }) +{ + let origin = null; + let kept = null; + let api = null; + const status = el('p', { className: 'hint', role: 'status' }); + const body = el('div', { className: 'accountbody' }); + + /* What closing must not lose: a key on screen that has not been + saved, which cannot be shown again. Closing a second time does. */ + let unsaved = false; + + const say = (text) => { status.textContent = text; }; + const close = () => + { + if (!unsaved) + { + dialog.close(); + return; + } + + unsaved = false; + say('Your key cannot be shown again once this closes. Close again ' + + 'to close it anyway.'); + }; + + dialog.append( + el('div', { className: 'accounthead' }, + el('span', { className: 'accounttitle', textContent: 'Account' }), + Object.assign(button('\u00d7', close), + { ariaLabel: 'Close', id: 'accountclose' })), + status, body); + dialog.addEventListener('cancel', (e) => + { + e.preventDefault(); + close(); + }); + + const show = (...nodes) => body.replaceChildren(...nodes); + const changed = (k) => + { + kept = k; + keep(origin, k); + open.textContent = k === null ? 'Log in' : 'Account'; + onChange(k?.handle ?? null); + }; + + /* While a request is out, its button is not pressed again. */ + const busy = async (b, run) => + { + b.disabled = true; + + try + { + await run(); + } + catch (e) + { + say(failed(e)); + } + finally + { + b.disabled = false; + } + }; + + function loggedOut () + { + const handle = el('input', { id: 'account-handle', maxLength: 64, + autocomplete: 'off' }); + const create = button('Create account', () => busy(create, async () => + { + const h = normalizeName(handle.value); + + if (h === null) + { + say('A handle needs at least one visible character.'); + return; + } + + const res = await api.register(h); + + changed({ session: res.session, handle: res.account.handle }); + say(''); + saveKey(res.account.handle, res.key, true, () => loggedIn(res)); + })); + const login = keyForm('login', '', 'current-password', 'Log in'); + + login.form.onsubmit = (e) => + { + e.preventDefault(); + busy(login.submit, async () => + { + const res = await api.login(login.key.value); + + changed({ session: res.session, handle: res.account.handle }); + say(`Logged in as ${res.account.handle}.`); + + /* Gone on success: what a password manager watches for. */ + loggedIn(res); + }); + }; + + show(el('p', { textContent: + 'An account is a handle the room knows you by, which nobody ' + + 'else can take. There is no email or password: the relay ' + + 'gives you a key of eight words, and the key is the ' + + 'account. Keep it in your password manager. Without one ' + + 'you join as a guest.' }), + section('Create an account', + el('label', {}, 'Handle ', handle), create), + section('Log in', login.form)); + } + + /* A key just issued, in a form a password manager will save, with + Show, Copy and Download beside it. `then' goes on once saved. */ + function saveKey (handle, key, isNew, then) + { + const form = keyForm('save', handle, 'new-password', 'Save key'); + const note = el('span', { className: 'hint' }); + const onward = () => + { + unsaved = false; + then(); + }; + + unsaved = true; + form.key.value = key; + form.form.onsubmit = (e) => + { + e.preventDefault(); + + /* A browser's own suggested password must not be what the + manager saves. */ + if (form.key.value !== key) + { + form.key.value = key; + say('That was your browser\'s password, not your key. The ' + + 'key is back; save it again.'); + return; + } + + say('Saved. Your password manager should offer to keep it.'); + onward(); + }; + + const showKey = button('Show', () => + { + const shown = form.key.type === 'text'; + + form.key.type = shown ? 'password' : 'text'; + showKey.textContent = shown ? 'Show' : 'Hide'; + }); + + show(section(isNew ? 'Your account key' : 'Your new key', + el('p', { className: 'accountwarn', textContent: + (isNew ? '' : 'The old key no longer works, and every other ' + + 'browser is logged out. ') + + 'This key is the only way into your account, here or on any ' + + 'other browser, and it cannot be recovered. Save it in your ' + + 'password manager now.' }), + form.form, + el('div', { className: 'row' }, showKey, + button('Copy', () => navigator.clipboard.writeText(key).then( + () => { note.textContent = 'Copied.'; }, + () => { note.textContent = 'Could not copy: Show it, ' + + 'and copy it by hand.'; })), + button('Download', () => download(handle, key)), note), + button('I have saved it; continue', onward))); + form.submit.focus(); + } + + function loggedIn ({ account }) + { + const session = kept.session; + const waiting = account.renameAt > Date.now(); + + /* A new handle takes the key too: a borrowed browser renaming the + account would leave its handle to whoever takes it next. */ + const rename = keyForm('rename', account.handle, 'current-password', + 'Change'); + const handle = el('input', { id: 'account-newhandle', maxLength: 64, + autocomplete: 'off' }); + + rename.form.prepend(el('label', {}, 'New ', handle)); + rename.form.onsubmit = (e) => + { + e.preventDefault(); + busy(rename.submit, async () => + { + const h = normalizeName(handle.value); + + if (h === null) + { + say('A handle needs at least one visible character.'); + return; + } + + const res = await api.rename(session, h, rename.key.value); + + changed({ session, handle: res.account.handle }); + say(`You are now ${res.account.handle}. ${account.handle} ` + + 'stays yours for 30 days, and the saved key still works.'); + loggedIn(res); + }); + }; + const replace = keyForm('replace', account.handle, 'current-password', + 'Replace key'); + const remove = keyForm('delete', account.handle, 'current-password', + 'Delete for good'); + + /* Typed out, not filled in: a password manager fills the key. */ + const confirm = el('input', { id: 'account-confirm', maxLength: 64, + autocomplete: 'off' }); + + remove.form.insertBefore(el('label', {}, 'Type it ', confirm), + remove.submit); + const logout = button('Log out', () => busy(logout, async () => + { + /* Logged out here even if the relay cannot be told. */ + await api.logout(session).catch(() => {}); + changed(null); + say('Logged out.'); + loggedOut(); + })); + + rename.form.inert = waiting; + replace.form.onsubmit = (e) => + { + e.preventDefault(); + busy(replace.submit, async () => + { + const res = await api.replaceKey(session, replace.key.value); + + /* The session this page had ended with the old key. */ + changed({ session: res.session, handle: account.handle }); + + say(''); + saveKey(account.handle, res.key, false, + () => loggedIn({ account })); + }); + }; + remove.form.onsubmit = (e) => + { + e.preventDefault(); + busy(remove.submit, async () => + { + await api.remove(session, remove.key.value, confirm.value); + changed(null); + say('Your account is deleted.'); + loggedOut(); + }); + }; + + show(section(`Logged in as ${account.handle}`, + el('p', { textContent: 'Since ' + new Date( + account.createdAt).toLocaleDateString() })), + section('Change handle', + el('p', { className: 'hint', textContent: waiting + ? 'You can change it again on ' + new Date( + account.renameAt).toLocaleDateString() + '.' + : 'Once every 30 days. The old one stays yours ' + + 'for 30 days after.' }), + rename.form), + section('Key', + el('p', { className: 'hint', textContent: + 'A new key needs the current one, and logs out ' + + 'every other browser.' }), + replace.form), + section('Log out', + el('p', { className: 'hint', textContent: + 'Logged out, you join as a guest.' }), logout), + section('Delete account', + el('p', { className: 'hint', textContent: + 'This needs the key itself and your handle typed ' + + 'out, and cannot be undone. The handle stays ' + + 'nobody\'s for 30 days.' }), + remove.form)); + } + + open.addEventListener('click', async () => + { + say(''); + dialog.showModal(); + + if (kept === null) + { + loggedOut(); + return; + } + + show(); + say('Loading...'); + + try + { + const res = await api.me(kept.session); + + say(''); + changed({ ...kept, handle: res.account.handle }); + loggedIn(res); + } + catch (e) + { + if (e.code !== 'unauthorized' && e.code !== 'banned') + { + say(failed(e)); + return; + } + + changed(null); + say(e.code === 'banned' ? failed(e) + : 'Your session has ended; log in again.'); + loggedOut(); + } + }); + + /* The relays the page worked out (jam.js, relaysOf), in use: which is + home, what is kept for it, and whether it has accounts. A join + hands them in again, and they differ only when the first look at + config.json failed and the join's worked. */ + let using = null; + let usingFor = null; + + const use = (where) => + { + const o = apiOriginOf(where.url); + + /* Home is what config.json says, and only once it has said it. */ + const home = where.read && o !== null && o === apiOriginOf(where.home) + ? o : null; + + if (using !== null && home === usingFor) + return using; + + usingFor = home; + origin = home; + kept = home === null ? null : load(home); + api = null; + open.hidden = true; + + /* Whether the relay has accounts, as its health line says. Only + then is the name the handle, and the button shown: a relay + without them -- rolled back, or with no CORS_ORIGIN -- leaves + the page a guest with a name of its own. A kept session is + asked after, and only this relay's refusal of it logs the page + out; a relay merely out of reach does not. */ + using = (async () => + { + if (origin === null) + return; + + const health = await (await fetch(`${origin}/`)).json(); + + if (health.accounts !== true || usingFor !== home) + return; + + api = client(origin); + open.hidden = false; + + if (kept === null) + return; + + onChange(kept.handle); + api.me(kept.session).then( + (res) => changed({ ...kept, handle: res.account.handle }), + (e) => + { + if (e.code === 'unauthorized' || e.code === 'banned') + changed(null); + }); + })().catch(() => {}); + + return using; + }; + + relays().then(use, () => {}); + + return { + /* The session a join to `where' sends, or null to join as a + guest: only to the home relay, and only once it has said it has + accounts. */ + session: async (where) => + { + await use(where); + return api === null ? null : kept?.session ?? null; + }, + + /* The relay has refused the session: this page is a guest there + now. */ + ended: () => + { + if (origin !== null) + changed(null); + }, + }; +} diff --git a/wasm/web/confusables.js b/wasm/web/confusables.js new file mode 100644 index 00000000..f29d2f6e --- /dev/null +++ b/wasm/web/confusables.js @@ -0,0 +1,298 @@ +/* + * Copyright (C) 2004-2026 Metaphonic Labs + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by the + * Free Software Foundation; either version 2 of the License, or (at your + * option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General + * Public License along with this program; if not, write to the + * Free Software Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ + +/* + * confusables.js -- written by scripts/make-confusables.mjs from Unicode's + * confusables.txt, version 18.0.0 (2026-08-06); do not edit. + * + * Each character that passes for Latin letters or digits, and what it + * passes for: hex code point, then its skeleton. + * + * The notice above is this project's, for the code. The table is derived + * from Unicode's data, which is Unicode's, and is distributed under its + * own license: + * + * UNICODE LICENSE V3 + * + * COPYRIGHT AND PERMISSION NOTICE + * + * Copyright © 1991-2026 Unicode, Inc. + * + * NOTICE TO USER: Carefully read the following legal agreement. BY + * DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR + * SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE + * TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT + * DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE. + * + * Permission is hereby granted, free of charge, to any person obtaining a + * copy of data files and any associated documentation (the "Data Files") or + * software and any associated documentation (the "Software") to deal in the + * Data Files or Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, and/or sell + * copies of the Data Files or Software, and to permit persons to whom the + * Data Files or Software are furnished to do so, provided that either (a) + * this copyright and permission notice appear with all copies of the Data + * Files or Software, or (b) this copyright and permission notice appear in + * associated Documentation. + * + * THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY + * KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF + * THIRD PARTY RIGHTS. + * + * IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE + * BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, + * OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, + * WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, + * ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA + * FILES OR SOFTWARE. + * + * Except as contained in this notice, the name of a copyright holder shall + * not be used in advertising or otherwise to promote the sale, use or other + * dealings in these Data Files or Software without prior written + * authorization of the copyright holder. + */ + +export const SKELETON = new Map(` +30=O 31=l 49=l 69=l 6d=rn 7c=l a1=i a2=c a5=Y c6=AE d0=D d7=x d8=O +e6=ae f8=o fe=p 110=D 111=d 126=H 127=h 131=i 132=lJ 133=ij 141=L 142=l +14b=n 152=OE 153=oe 166=T 167=t 17f=f 180=b 182=b 183=b 184=b 189=D +18b=d 18c=d 18d=g 191=F 192=f 196=l 197=l 199=k 19a=l 19d=N 19e=n 19f=O +1a5=p 1a6=R 1a7=2 1ab=t 1ad=t 1ae=T 1b4=y 1b5=Z 1b6=z 1b7=3 1bb=2 1bc=5 +1bd=s 1bf=p 1c0=l 1c1=ll 1c4=DZ 1c5=Dz 1c6=dz 1c7=LJ 1c8=Lj 1c9=lj +1ca=NJ 1cb=Nj 1cc=nj 1e4=G 1e5=g 1f1=DZ 1f2=Dz 1f3=dz 21c=3 222=8 223=8 +224=Z 225=z 237=j 23c=c 23e=T 244=U 246=E 247=e 248=J 249=j 24c=R 24d=r +24e=Y 24f=y 251=a 253=b 256=d 257=d 25f=j 260=g 261=g 263=y 266=h 268=i +269=i 26a=i 26b=l 26d=l 26f=w 271=rn 272=n 273=n 275=o 27c=r 27d=r +282=s 284=f 28b=u 28f=y 290=z 2a0=q 2a3=dz 2a6=ts 2a9=fn 2aa=ls 2ab=lz +37f=J 391=A 392=B 395=E 396=Z 397=H 398=O 399=l 39a=K 39c=M 39d=N 39f=O +3a1=P 3a4=T 3a5=Y 3a7=X 3b1=a 3b3=y 3b7=n 3b8=O 3b9=i 3bd=v 3bf=o 3c1=p +3c3=o 3c5=u 3d1=O 3d2=Y 3dc=F 3e8=2 3ec=6 3ed=o 3f1=p 3f2=c 3f3=j 3f4=O +3f8=p 3f9=C 3fa=M 405=S 406=l 408=J 410=A 411=b 412=B 415=E 417=3 41a=K +41c=M 41d=H 41e=O 420=P 421=C 422=T 423=Y 425=X 42b=bl 42c=b 42e=lO +430=a 431=6 433=r 435=e 43e=o 440=p 441=c 443=y 445=x 448=w 455=s 456=i +458=j 45b=h 45f=u 461=w 462=b 463=b 472=O 473=o 474=V 475=v 478=Oy +479=oy 47d=w 48c=b 48d=b 493=r 498=3 49a=K 49e=K 4a2=H 4aa=C 4ab=c +4ac=T 4ae=Y 4af=y 4b0=Y 4b1=y 4b2=X 4ba=h 4bb=h 4bd=e 4bf=e 4c0=l 4c7=H +4c9=H 4cd=M 4cf=l 4d4=AE 4d5=ae 4e0=3 4e8=O 4e9=o 501=d 50c=G 51a=Q +51b=q 51c=W 51d=w 545=3 54d=U 54f=S 555=O 560=rn 561=w 563=q 566=q +570=h 572=n 575=j 578=n 57c=n 57d=u 581=g 582=i 584=f 585=o 5c0=l 5d5=l +5d8=v 5df=l 5e1=o 5f0=ll 627=l 629=o 647=o 661=l 665=o 667=V 673=l +6be=o 6c1=o 6c3=o 6d5=o 6f1=l 6f5=o 6f7=V 6ff=o 7c0=O 7ca=l 7cb=o 7cc=Y +7d3=F 7d5=b 7e0=T 840=o 964=l 965=ll 966=o 969=3 9e6=o 9ea=8 9ed=9 +a66=o a67=9 a6a=8 ae6=o ae9=3 b03=8 b20=O b66=o b68=9 be6=o c02=o c66=o +c82=o ce6=O d02=o d1f=s d20=o d66=o d6d=9 d82=o e50=o ed0=o 1004=c +1010=o 101d=o 1040=o 104a=l 104b=ll 105a=c 10b9=h 10bd=S 10c7=2 10cd=Z +10d7=o 10e7=y 10fd=S 10ff=o 110b=o 1147=oo 11bc=o 11ee=oo 1200=U 12d0=O +1340=O 13a0=D 13a1=R 13a2=T 13a4=O 13a5=i 13a9=Y 13aa=A 13ab=J 13ac=E +13b3=W 13b7=M 13bb=H 13bd=Y 13be=O 13bf=t 13c0=G 13c2=h 13c3=Z 13cc=U +13ce=4 13cf=b 13d2=R 13d4=W 13d5=S 13d9=V 13da=S 13de=L 13df=C 13e2=P +13e6=K 13e7=d 13eb=O 13ee=6 13f2=h 13f3=G 13f4=B 142f=V 144c=U 146d=P +146f=d 1472=b 1473=b 148d=J 14aa=L 14bf=2 1541=x 157c=H 157d=x 1587=R +15af=b 15b4=F 15c5=A 15de=D 15ea=D 15f0=M 15f7=B 1616=2 166d=X 166e=x +16b7=X 16c1=l 16c5=l 16d0=l 16d5=K 16d6=M 1702=3 172a=7 1763=x 17e0=o +1a32=o 1a45=o 1a80=o 1a90=o 1bea=o 1bec=x 1c82=o 1c83=c 1c84=7 1c95=3 +1cb7=2 1cbd=S 1cbf=O 1cf5=X 1d04=c 1d0f=o 1d11=o 1d1c=u 1d20=v 1d21=w +1d22=z 1d26=r 1d6b=ue 1d6e=f 1d6f=rn 1d70=n 1d72=r 1d74=s 1d75=t 1d76=z +1d7b=i 1d7c=i 1d7d=p 1d7e=u 1d83=g 1d8c=y 1e9d=f 1efa=lL 1eff=y 2016=ll +20a1=C 20a5=rn 20a8=Rs 20a9=W 20ab=d 20ad=K 20ae=T 20b6=lt 2102=C +210a=g 210b=H 210c=H 210d=H 210e=h 210f=h 2110=l 2111=l 2112=L 2113=l +2115=N 2116=No 2119=P 211a=Q 211b=R 211c=R 211d=R 2121=TEL 2124=Z +2128=Z 212c=B 212d=C 212e=e 212f=e 2130=E 2131=F 2133=M 2134=o 2139=i +213b=FAX 213d=y 2145=D 2146=d 2147=e 2148=i 2149=j 2160=l 2161=ll +2162=lll 2163=lV 2164=V 2165=Vl 2166=Vll 2167=Vlll 2168=lX 2169=X +216a=Xl 216b=Xll 216c=L 216d=C 216e=D 216f=M 2170=i 2171=ii 2172=iii +2173=iv 2174=v 2175=vi 2176=vii 2177=viii 2178=ix 2179=x 217a=xi +217b=xii 217c=l 217d=c 217e=d 217f=rn 21bf=l 2205=O 221e=oo 2223=l +2225=ll 2228=v 222a=U 2296=O 229d=O 22a4=T 22c1=v 22c3=U 22ff=E 2300=O +2361=T 2365=O 236c=O 2373=i 2374=p 2376=a 2378=i 237a=a 23fd=l 2502=l +2503=l 2573=X 27d9=T 292b=x 292c=x 29e2=w 2a2f=x 2a30=x 2c67=H 2c69=K +2c6b=Z 2c6c=z 2c82=B 2c85=r 2c8c=Z 2c8d=z 2c8e=H 2c90=O 2c91=o 2c92=l +2c93=i 2c94=K 2c98=M 2c9a=N 2c9c=3 2c9e=O 2c9f=o 2ca2=P 2ca3=p 2ca4=C +2ca5=c 2ca6=T 2ca8=Y 2ca9=y 2cac=X 2cbd=w 2cc4=3 2cca=9 2ccb=9 2ccc=3 +2cce=P 2ccf=p 2cd0=L 2cd2=6 2cd3=6 2cdc=6 2d2d=z 2d31=O 2d38=V 2d39=E +2d41=O 2d4a=l 2d4f=l 2d54=O 2d55=Q 2d5d=X 3007=O 3112=T 311a=Y 3147=o +3180=oo 3250=PTE 32cc=Hg 32cd=erg 32ce=eV 32cf=LTD 3371=hPa 3372=da +3373=AU 3374=bar 3375=oV 3376=pc 3377=drn 337a=lU 3380=pA 3381=nA +3383=rnA 3384=kA 3385=KB 3386=MB 3387=GB 3388=cal 3389=kcal 338a=pF +338b=nF 338e=rng 338f=kg 3390=Hz 3391=kHz 3392=MHz 3393=GHz 3394=THz +3396=rnl 3397=dl 3398=kl 3399=frn 339a=nrn 339c=rnrn 339d=crn 339e=krn +33a9=Pa 33aa=kPa 33ab=MPa 33ac=GPa 33ad=rad 33b0=ps 33b1=ns 33b3=rns +33b4=pV 33b5=nV 33b7=rnV 33b8=kV 33b9=MV 33ba=pW 33bb=nW 33bd=rnW +33be=kW 33bf=MW 33c3=Bq 33c4=cc 33c5=cd 33c8=dB 33c9=Gy 33ca=ha 33cb=HP +33cc=in 33cd=KK 33ce=KM 33cf=kt 33d0=lrn 33d1=ln 33d2=log 33d3=lx +33d4=rnb 33d5=rnil 33d6=rnol 33d7=PH 33d9=PPM 33da=PR 33db=sr 33dc=Sv +33dd=Wb 33ff=gal 4e05=T 4e2b=Y a4d0=B a4d1=P a4d2=d a4d3=D a4d4=T +a4d6=G a4d7=K a4d9=J a4da=C a4dc=Z a4dd=F a4df=M a4e0=N a4e1=L a4e2=S +a4e3=R a4e6=V a4e7=H a4ea=W a4eb=X a4ec=Y a4ee=A a4f0=E a4f2=l a4f3=O +a4f4=U a50b=T a516=lll a543=6 a557=B a56f=l a576=S a589=8 a5cb=E a644=2 +a647=i a68c=T a695=h a698=OO a699=oo a6a2=o a6b2=Y a6c9=Z a6df=V a6ef=2 +a728=T3 a731=s a732=AA a733=aa a734=AO a735=ao a736=AU a737=au a738=AV +a739=av a73a=AV a73b=av a73c=AY a73d=ay a740=K a74a=O a74b=o a74e=OO +a74f=oo a75a=2 a761=w a76a=3 a76e=9 a76f=9 a777=tf a781=l a798=F a799=f +a79f=u a7ab=3 a7ae=l a7b2=J a7b3=X a7b4=B a7c5=S a7fa=w a7fe=l a830=l +a8ce=l a8cf=ll a8f6=3 aa5d=l ab32=e ab35=f ab3d=o ab3e=o ab43=co +ab44=co ab47=r ab48=r ab4e=u ab51=rn ab52=u ab5a=y ab63=uo ab64=a +ab74=o ab75=i ab81=r ab83=w ab8e=o ab93=z ab9c=u ab9e=4 aba4=w aba9=v +abaa=s abaf=c abbb=o abbe=6 fb00=ff fb01=fi fb02=fl fb03=ffi fb04=ffl +fb05=ft fb06=st fba4=o fba5=o fba6=o fba7=o fba8=o fba9=o fbaa=o fbab=o +fbac=o fbad=o fcd9=o fd3c=l fd3d=l fe31=l fe81=l fe82=l fe87=l fe88=l +fe8d=l fe8e=l fe93=o fe94=o fee9=o feea=o feeb=o feec=o ff10=O ff11=l +ff12=2 ff13=3 ff14=4 ff15=5 ff16=6 ff17=7 ff18=8 ff19=9 ff21=A ff22=B +ff23=C ff24=D ff25=E ff26=F ff27=G ff28=H ff29=l ff2a=J ff2b=K ff2c=L +ff2d=M ff2e=N ff2f=O ff30=P ff31=Q ff32=R ff33=S ff34=T ff35=U ff36=V +ff37=W ff38=X ff39=Y ff3a=Z ff41=a ff42=b ff43=c ff44=d ff45=e ff46=f +ff47=g ff48=h ff49=i ff4a=j ff4b=k ff4c=l ff4d=rn ff4e=n ff4f=o ff50=p +ff51=q ff52=r ff53=s ff54=t ff55=u ff56=v ff57=w ff58=x ff59=y ff5a=z +ff5c=l ffb7=o ffe0=c ffe5=Y ffe6=W ffe8=l 1017e=f 1018b=d 1018e=N +10196=X 10197=V 10198=llS 10199=ll 10282=B 10286=E 10287=F 1028a=l +10290=X 10292=O 10295=P 10296=S 10297=T 102a0=A 102a1=B 102a2=C 102a5=F +102ab=O 102b0=M 102b1=T 102b2=Y 102b4=X 102cf=H 102f5=Z 10301=B 10302=C +10309=l 1030f=O 10311=M 10315=T 10317=X 1031a=8 1031c=b 10320=l 10322=X +10404=O 10415=C 1041b=L 10420=S 1042c=o 1043d=c 10448=s 104b4=R 104c2=O +104ce=U 104d2=7 104ea=o 104f6=u 10507=Z 1050e=l 10513=N 10516=O 10518=K +1051b=C 1051d=V 1051e=O 10525=F 10526=L 10527=X 10926=l 1092c=o 10c13=X +10c17=O 10c1f=V 10c20=Y 10c21=M 10c3e=l 10c82=X 10ca5=l 10cc2=x 10cfa=l +10cfc=X 10d07=o 11047=l 11048=ll 110c0=l 110c1=ll 11116=o 11124=o +11141=l 11142=ll 111c5=l 111c6=ll 11302=o 113d4=l 113d5=ll 11445=8 +1144b=l 1144c=ll 114c5=w 114d0=o 115c5=l 11641=l 11642=ll 11700=rn +11706=v 1170a=w 1170e=w 1170f=w 118a0=V 118a2=F 118a3=L 118a4=Y 118a6=E +118a9=Z 118ac=9 118ae=E 118af=4 118b2=L 118b5=O 118b8=U 118bb=5 118bc=T +118c0=v 118c1=s 118c2=F 118c3=i 118c4=y 118c6=7 118c8=o 118ca=3 118cc=9 +118d5=6 118d6=9 118d7=o 118d8=u 118dc=y 118e0=O 118e3=rn 118e5=Z +118e6=W 118e9=C 118ec=X 118ef=W 118f2=C 11abc=Z 11abe=N 11c41=l +11c42=ll 11dda=l 11de0=O 11de1=l 1699b=O 169c1=9 169fe=8 16a19=r +16ad6=S 16ae4=l 16ae9=O 16d63=l 16e80=O 16e82=4 16e8a=7 16eaa=l 16eb6=b +16f08=V 16f0a=T 16f16=L 16f28=l 16f35=R 16f3a=S 16f3b=3 16f40=A 16f42=U +16f43=Y 1ccd6=A 1ccd7=B 1ccd8=C 1ccd9=D 1ccda=E 1ccdb=F 1ccdc=G 1ccdd=H +1ccde=l 1ccdf=J 1cce0=K 1cce1=L 1cce2=M 1cce3=N 1cce4=O 1cce5=P 1cce6=Q +1cce7=R 1cce8=S 1cce9=T 1ccea=U 1cceb=V 1ccec=W 1cced=X 1ccee=Y 1ccef=Z +1ccf0=O 1ccf1=l 1ccf2=2 1ccf3=3 1ccf4=4 1ccf5=5 1ccf6=6 1ccf7=7 1ccf8=8 +1ccf9=9 1cefc=V 1d100=l 1d134=c 1d1fe=7 1d206=3 1d207=b 1d20c=W 1d20d=V +1d212=7 1d213=F 1d216=R 1d21a=O 1d22a=L 1d262=ll 1d373=T 1d377=l +1d400=A 1d401=B 1d402=C 1d403=D 1d404=E 1d405=F 1d406=G 1d407=H 1d408=l +1d409=J 1d40a=K 1d40b=L 1d40c=M 1d40d=N 1d40e=O 1d40f=P 1d410=Q 1d411=R +1d412=S 1d413=T 1d414=U 1d415=V 1d416=W 1d417=X 1d418=Y 1d419=Z 1d41a=a +1d41b=b 1d41c=c 1d41d=d 1d41e=e 1d41f=f 1d420=g 1d421=h 1d422=i 1d423=j +1d424=k 1d425=l 1d426=rn 1d427=n 1d428=o 1d429=p 1d42a=q 1d42b=r +1d42c=s 1d42d=t 1d42e=u 1d42f=v 1d430=w 1d431=x 1d432=y 1d433=z 1d434=A +1d435=B 1d436=C 1d437=D 1d438=E 1d439=F 1d43a=G 1d43b=H 1d43c=l 1d43d=J +1d43e=K 1d43f=L 1d440=M 1d441=N 1d442=O 1d443=P 1d444=Q 1d445=R 1d446=S +1d447=T 1d448=U 1d449=V 1d44a=W 1d44b=X 1d44c=Y 1d44d=Z 1d44e=a 1d44f=b +1d450=c 1d451=d 1d452=e 1d453=f 1d454=g 1d456=i 1d457=j 1d458=k 1d459=l +1d45a=rn 1d45b=n 1d45c=o 1d45d=p 1d45e=q 1d45f=r 1d460=s 1d461=t +1d462=u 1d463=v 1d464=w 1d465=x 1d466=y 1d467=z 1d468=A 1d469=B 1d46a=C +1d46b=D 1d46c=E 1d46d=F 1d46e=G 1d46f=H 1d470=l 1d471=J 1d472=K 1d473=L +1d474=M 1d475=N 1d476=O 1d477=P 1d478=Q 1d479=R 1d47a=S 1d47b=T 1d47c=U +1d47d=V 1d47e=W 1d47f=X 1d480=Y 1d481=Z 1d482=a 1d483=b 1d484=c 1d485=d +1d486=e 1d487=f 1d488=g 1d489=h 1d48a=i 1d48b=j 1d48c=k 1d48d=l +1d48e=rn 1d48f=n 1d490=o 1d491=p 1d492=q 1d493=r 1d494=s 1d495=t +1d496=u 1d497=v 1d498=w 1d499=x 1d49a=y 1d49b=z 1d49c=A 1d49e=C 1d49f=D +1d4a2=G 1d4a5=J 1d4a6=K 1d4a9=N 1d4aa=O 1d4ab=P 1d4ac=Q 1d4ae=S 1d4af=T +1d4b0=U 1d4b1=V 1d4b2=W 1d4b3=X 1d4b4=Y 1d4b5=Z 1d4b6=a 1d4b7=b 1d4b8=c +1d4b9=d 1d4bb=f 1d4bd=h 1d4be=i 1d4bf=j 1d4c0=k 1d4c1=l 1d4c2=rn +1d4c3=n 1d4c5=p 1d4c6=q 1d4c7=r 1d4c8=s 1d4c9=t 1d4ca=u 1d4cb=v 1d4cc=w +1d4cd=x 1d4ce=y 1d4cf=z 1d4d0=A 1d4d1=B 1d4d2=C 1d4d3=D 1d4d4=E 1d4d5=F +1d4d6=G 1d4d7=H 1d4d8=l 1d4d9=J 1d4da=K 1d4db=L 1d4dc=M 1d4dd=N 1d4de=O +1d4df=P 1d4e0=Q 1d4e1=R 1d4e2=S 1d4e3=T 1d4e4=U 1d4e5=V 1d4e6=W 1d4e7=X +1d4e8=Y 1d4e9=Z 1d4ea=a 1d4eb=b 1d4ec=c 1d4ed=d 1d4ee=e 1d4ef=f 1d4f0=g +1d4f1=h 1d4f2=i 1d4f3=j 1d4f4=k 1d4f5=l 1d4f6=rn 1d4f7=n 1d4f8=o +1d4f9=p 1d4fa=q 1d4fb=r 1d4fc=s 1d4fd=t 1d4fe=u 1d4ff=v 1d500=w 1d501=x +1d502=y 1d503=z 1d504=A 1d505=B 1d507=D 1d508=E 1d509=F 1d50a=G 1d50d=J +1d50e=K 1d50f=L 1d510=M 1d511=N 1d512=O 1d513=P 1d514=Q 1d516=S 1d517=T +1d518=U 1d519=V 1d51a=W 1d51b=X 1d51c=Y 1d51e=a 1d51f=b 1d520=c 1d521=d +1d522=e 1d523=f 1d524=g 1d525=h 1d526=i 1d527=j 1d528=k 1d529=l +1d52a=rn 1d52b=n 1d52c=o 1d52d=p 1d52e=q 1d52f=r 1d530=s 1d531=t +1d532=u 1d533=v 1d534=w 1d535=x 1d536=y 1d537=z 1d538=A 1d539=B 1d53b=D +1d53c=E 1d53d=F 1d53e=G 1d540=l 1d541=J 1d542=K 1d543=L 1d544=M 1d546=O +1d54a=S 1d54b=T 1d54c=U 1d54d=V 1d54e=W 1d54f=X 1d550=Y 1d552=a 1d553=b +1d554=c 1d555=d 1d556=e 1d557=f 1d558=g 1d559=h 1d55a=i 1d55b=j 1d55c=k +1d55d=l 1d55e=rn 1d55f=n 1d560=o 1d561=p 1d562=q 1d563=r 1d564=s +1d565=t 1d566=u 1d567=v 1d568=w 1d569=x 1d56a=y 1d56b=z 1d56c=A 1d56d=B +1d56e=C 1d56f=D 1d570=E 1d571=F 1d572=G 1d573=H 1d574=l 1d575=J 1d576=K +1d577=L 1d578=M 1d579=N 1d57a=O 1d57b=P 1d57c=Q 1d57d=R 1d57e=S 1d57f=T +1d580=U 1d581=V 1d582=W 1d583=X 1d584=Y 1d585=Z 1d586=a 1d587=b 1d588=c +1d589=d 1d58a=e 1d58b=f 1d58c=g 1d58d=h 1d58e=i 1d58f=j 1d590=k 1d591=l +1d592=rn 1d593=n 1d594=o 1d595=p 1d596=q 1d597=r 1d598=s 1d599=t +1d59a=u 1d59b=v 1d59c=w 1d59d=x 1d59e=y 1d59f=z 1d5a0=A 1d5a1=B 1d5a2=C +1d5a3=D 1d5a4=E 1d5a5=F 1d5a6=G 1d5a7=H 1d5a8=l 1d5a9=J 1d5aa=K 1d5ab=L +1d5ac=M 1d5ad=N 1d5ae=O 1d5af=P 1d5b0=Q 1d5b1=R 1d5b2=S 1d5b3=T 1d5b4=U +1d5b5=V 1d5b6=W 1d5b7=X 1d5b8=Y 1d5b9=Z 1d5ba=a 1d5bb=b 1d5bc=c 1d5bd=d +1d5be=e 1d5bf=f 1d5c0=g 1d5c1=h 1d5c2=i 1d5c3=j 1d5c4=k 1d5c5=l +1d5c6=rn 1d5c7=n 1d5c8=o 1d5c9=p 1d5ca=q 1d5cb=r 1d5cc=s 1d5cd=t +1d5ce=u 1d5cf=v 1d5d0=w 1d5d1=x 1d5d2=y 1d5d3=z 1d5d4=A 1d5d5=B 1d5d6=C +1d5d7=D 1d5d8=E 1d5d9=F 1d5da=G 1d5db=H 1d5dc=l 1d5dd=J 1d5de=K 1d5df=L +1d5e0=M 1d5e1=N 1d5e2=O 1d5e3=P 1d5e4=Q 1d5e5=R 1d5e6=S 1d5e7=T 1d5e8=U +1d5e9=V 1d5ea=W 1d5eb=X 1d5ec=Y 1d5ed=Z 1d5ee=a 1d5ef=b 1d5f0=c 1d5f1=d +1d5f2=e 1d5f3=f 1d5f4=g 1d5f5=h 1d5f6=i 1d5f7=j 1d5f8=k 1d5f9=l +1d5fa=rn 1d5fb=n 1d5fc=o 1d5fd=p 1d5fe=q 1d5ff=r 1d600=s 1d601=t +1d602=u 1d603=v 1d604=w 1d605=x 1d606=y 1d607=z 1d608=A 1d609=B 1d60a=C +1d60b=D 1d60c=E 1d60d=F 1d60e=G 1d60f=H 1d610=l 1d611=J 1d612=K 1d613=L +1d614=M 1d615=N 1d616=O 1d617=P 1d618=Q 1d619=R 1d61a=S 1d61b=T 1d61c=U +1d61d=V 1d61e=W 1d61f=X 1d620=Y 1d621=Z 1d622=a 1d623=b 1d624=c 1d625=d +1d626=e 1d627=f 1d628=g 1d629=h 1d62a=i 1d62b=j 1d62c=k 1d62d=l +1d62e=rn 1d62f=n 1d630=o 1d631=p 1d632=q 1d633=r 1d634=s 1d635=t +1d636=u 1d637=v 1d638=w 1d639=x 1d63a=y 1d63b=z 1d63c=A 1d63d=B 1d63e=C +1d63f=D 1d640=E 1d641=F 1d642=G 1d643=H 1d644=l 1d645=J 1d646=K 1d647=L +1d648=M 1d649=N 1d64a=O 1d64b=P 1d64c=Q 1d64d=R 1d64e=S 1d64f=T 1d650=U +1d651=V 1d652=W 1d653=X 1d654=Y 1d655=Z 1d656=a 1d657=b 1d658=c 1d659=d +1d65a=e 1d65b=f 1d65c=g 1d65d=h 1d65e=i 1d65f=j 1d660=k 1d661=l +1d662=rn 1d663=n 1d664=o 1d665=p 1d666=q 1d667=r 1d668=s 1d669=t +1d66a=u 1d66b=v 1d66c=w 1d66d=x 1d66e=y 1d66f=z 1d670=A 1d671=B 1d672=C +1d673=D 1d674=E 1d675=F 1d676=G 1d677=H 1d678=l 1d679=J 1d67a=K 1d67b=L +1d67c=M 1d67d=N 1d67e=O 1d67f=P 1d680=Q 1d681=R 1d682=S 1d683=T 1d684=U +1d685=V 1d686=W 1d687=X 1d688=Y 1d689=Z 1d68a=a 1d68b=b 1d68c=c 1d68d=d +1d68e=e 1d68f=f 1d690=g 1d691=h 1d692=i 1d693=j 1d694=k 1d695=l +1d696=rn 1d697=n 1d698=o 1d699=p 1d69a=q 1d69b=r 1d69c=s 1d69d=t +1d69e=u 1d69f=v 1d6a0=w 1d6a1=x 1d6a2=y 1d6a3=z 1d6a4=i 1d6a5=j 1d6a8=A +1d6a9=B 1d6ac=E 1d6ad=Z 1d6ae=H 1d6af=O 1d6b0=l 1d6b1=K 1d6b3=M 1d6b4=N +1d6b6=O 1d6b8=P 1d6b9=O 1d6bb=T 1d6bc=Y 1d6be=X 1d6c2=a 1d6c4=y 1d6c8=n +1d6c9=O 1d6ca=i 1d6ce=v 1d6d0=o 1d6d2=p 1d6d4=o 1d6d6=u 1d6dd=O 1d6e0=p +1d6e2=A 1d6e3=B 1d6e6=E 1d6e7=Z 1d6e8=H 1d6e9=O 1d6ea=l 1d6eb=K 1d6ed=M +1d6ee=N 1d6f0=O 1d6f2=P 1d6f3=O 1d6f5=T 1d6f6=Y 1d6f8=X 1d6fc=a 1d6fe=y +1d702=n 1d703=O 1d704=i 1d708=v 1d70a=o 1d70c=p 1d70e=o 1d710=u 1d717=O +1d71a=p 1d71c=A 1d71d=B 1d720=E 1d721=Z 1d722=H 1d723=O 1d724=l 1d725=K +1d727=M 1d728=N 1d72a=O 1d72c=P 1d72d=O 1d72f=T 1d730=Y 1d732=X 1d736=a +1d738=y 1d73c=n 1d73d=O 1d73e=i 1d742=v 1d744=o 1d746=p 1d748=o 1d74a=u +1d751=O 1d754=p 1d756=A 1d757=B 1d75a=E 1d75b=Z 1d75c=H 1d75d=O 1d75e=l +1d75f=K 1d761=M 1d762=N 1d764=O 1d766=P 1d767=O 1d769=T 1d76a=Y 1d76c=X +1d770=a 1d772=y 1d776=n 1d777=O 1d778=i 1d77c=v 1d77e=o 1d780=p 1d782=o +1d784=u 1d78b=O 1d78e=p 1d790=A 1d791=B 1d794=E 1d795=Z 1d796=H 1d797=O +1d798=l 1d799=K 1d79b=M 1d79c=N 1d79e=O 1d7a0=P 1d7a1=O 1d7a3=T 1d7a4=Y +1d7a6=X 1d7aa=a 1d7ac=y 1d7b0=n 1d7b1=O 1d7b2=i 1d7b6=v 1d7b8=o 1d7ba=p +1d7bc=o 1d7be=u 1d7c5=O 1d7c8=p 1d7ca=F 1d7ce=O 1d7cf=l 1d7d0=2 1d7d1=3 +1d7d2=4 1d7d3=5 1d7d4=6 1d7d5=7 1d7d6=8 1d7d7=9 1d7d8=O 1d7d9=l 1d7da=2 +1d7db=3 1d7dc=4 1d7dd=5 1d7de=6 1d7df=7 1d7e0=8 1d7e1=9 1d7e2=O 1d7e3=l +1d7e4=2 1d7e5=3 1d7e6=4 1d7e7=5 1d7e8=6 1d7e9=7 1d7ea=8 1d7eb=9 1d7ec=O +1d7ed=l 1d7ee=2 1d7ef=3 1d7f0=4 1d7f1=5 1d7f2=6 1d7f3=7 1d7f4=8 1d7f5=9 +1d7f6=O 1d7f7=l 1d7f8=2 1d7f9=3 1d7fa=4 1d7fb=5 1d7fc=6 1d7fd=7 1d7fe=8 +1d7ff=9 1df24=tO 1df2d=d 1df2e=dz 1df31=y 1df32=h 1df34=q 1df37=r +1df39=u 1df3c=O 1df3f=w 1df40=A 1df41=a 1df45=g 1df46=h 1df47=h 1df48=K +1df49=k 1df4a=M 1df4b=rn 1df4c=rn 1df4d=N 1df4e=n 1df4f=n 1df51=V +1df52=v 1df55=y 1df5a=a 1df5d=ie 1df5e=oi 1df5f=ou 1df64=th 1df65=wh +1df6a=A 1df6e=l 1df7d=w 1df81=E 1e140=O 1e141=l 1e145=V 1e2f0=O 1e2f2=9 +1e8c7=l 1e8cb=8 1ed01=l 1ee00=l 1ee24=o 1ee64=ol 1ee80=l 1ee84=o +1f16d=cc 1f16e=C 1f190=DJ 1f700=QE 1f707=AR 1f708=V 1f714=O 1f74c=C +1f75c=sss 1f768=T 1f76b=MB 1f76c=VB 1fbf0=O 1fbf1=l 1fbf2=2 1fbf3=3 +1fbf4=4 1fbf5=5 1fbf6=6 1fbf7=7 1fbf8=8 1fbf9=9 +`.trim().split(/\s+/).map((e) => +{ + const [hex, to] = e.split('='); + + return [String.fromCodePoint(parseInt(hex, 16)), to]; +})); diff --git a/wasm/web/jam.html b/wasm/web/jam.html index 9d9e9017..f17ec777 100644 --- a/wasm/web/jam.html +++ b/wasm/web/jam.html @@ -26,6 +26,8 @@

thinksynth — a room

+ + @@ -45,6 +47,9 @@

thinksynth — a room

+ + @@ -239,6 +244,8 @@

thinksynth — a room

+ + diff --git a/wasm/web/jam.js b/wasm/web/jam.js index 57fef0af..ab8bced8 100644 --- a/wasm/web/jam.js +++ b/wasm/web/jam.js @@ -39,6 +39,8 @@ import { WebsocketProvider } from 'y-websocket'; import * as Y from 'yjs'; +import { shownName } from './account.js'; +import { createAccounts } from './accountui.js'; import { barBeat, createChat } from './chat.js'; import { AudioClock, TransportClock, frameOfRelayMs } from './clock.js'; import { Dedupe, GRID, KNOB_LEAD, Maker, RELAY, TRANSPORT_LEAD, apply, @@ -79,26 +81,44 @@ const ENOUGH_SAMPLES = 4; /* How long the instrument picker's choice has to stay put, in ms. */ const PICK_SETTLE = 600; -/* Where the relay is: the URL's `relay', then the build's config.json, - then the page's own host on the relay's usual port. */ -async function relayUrl (params) +/* Where the relay is -- the URL's `relay', then the site's own + (homeRelay) -- and the site's own, worked out once: the join, the room + list and the accounts all go by these two values, so a session kept for + the one is never sent to another that a second look came up with. */ +let relays = null; + +function relaysOf (params) +{ + relays ??= homeRelay().then(({ home, read }) => + { + /* A config.json that could not be read -- an installed page opened + before the network -- is read again at the next join, rather + than its default kept for the rest of the load. */ + if (!read) + relays = null; + + return { url: params.get('relay') || home, home, read }; + }); + + return relays; +} + +/* The relay the site names: the build's config.json, then the page's own + host on the relay's usual port. Accounts are this one's (accountui.js). */ +async function homeRelay () { - if (params.get('relay')) - return params.get('relay'); + const fallback = `ws://${location.hostname}:8787`; try { const cfg = await (await fetch('config.json')).json(); - if (cfg.relay) - return cfg.relay; + return { home: cfg.relay || fallback, read: true }; } catch { - /* No config: the default below. */ + return { home: fallback, read: false }; } - - return `ws://${location.hostname}:8787`; } /* ---- state ---- */ @@ -173,6 +193,7 @@ let keys = null; /* the computer keyboard as a musical one */ let keyfocus = null; /* and who has it, the page or the keys */ let midiIn = null; /* the MIDI in button (midi.js) */ let chat = null; /* the room's text (chat.js) */ +let accounts = null; /* who this page is (accountui.js) */ let maker = null; const dedupe = new Dedupe(); @@ -1927,8 +1948,14 @@ async function showRooms () try { - const health = (await relayUrl(new URLSearchParams(location.search))) - .replace(/^ws/, 'http').replace(/\/*$/, '/'); + const where = await relaysOf(new URLSearchParams(location.search)); + + /* A relay guessed at, with no config.json read, is not asked. */ + if (!where.read) + throw new Error('no config.json'); + + const health = where.url.replace(/^ws/, 'http') + .replace(/\/*$/, '/'); rooms = (await (await fetch(health)).json()).rooms ?? []; } @@ -1962,22 +1989,22 @@ async function showRooms () setTimeout(showRooms, ROOMS_EVERY_MS); } -async function join () +/* A room socket, its handlers on, connected: resolves once welcomed, as + room.js's connect does. */ +function openRoom (url, roomName, name, opts) { - const params = new URLSearchParams(location.search); - const roomName = $('room').value.trim() || 'lobby'; - /* Cut where the relay cuts it, so the cursor's color is the one the - chat derives from the name the relay hands back. */ - const name = $('name').value.trim().slice(0, 32) || `guest-${Math.floor( - Math.random() * 1000)}`; - const url = await relayUrl(params); - - $('join').disabled = true; - status(`Joining ${roomName} at ${url}...`); + const r = new Room(url, roomName, name, opts); - room = new Room(url, roomName, name, - { piece: $('newpiece').value || params.get('piece') }); - room.on('peers', () => { showPeers(); chat.peers(room.peers); }) + /* Not until it is the page's room: a rejoin's welcome comes while the + lost one still is. */ + r.on('peers', () => + { + if (r === room) + { + showPeers(); + chat.peers(room.peers); + } + }) .on('chat', (m) => chat.said(m)) .on('refused', (m) => { @@ -1993,26 +2020,65 @@ async function join () .on('clock', () => { showNumbers(); enable(); }) .on('transport', (from, data) => receive(from, data)) .on('error', (text) => log(`relay: ${text}`)) - .on('close', () => status('The relay went away.')); + .on('close', (refused) => lost(r, refused)); + + return r.connect().then(() => r); +} + +async function join () +{ + const params = new URLSearchParams(location.search); + const roomName = $('room').value.trim() || 'lobby'; + let relay = await relaysOf(params); + + /* A config.json that could not be read at the load is read again now: + relaysOf has dropped the default it fell back on. */ + if (relays === null) + relay = await relaysOf(params); + + const { url } = relay; + + $('join').disabled = true; + status(`Joining ${roomName} at ${url}...`); + + const session = await accounts.session(relay); try { - await room.connect(); + room = await openRoom(url, roomName, $('name').value.trim() || + `guest-${Math.floor(Math.random() * 1000)}`, + { piece: $('newpiece').value || + params.get('piece'), + session }); } catch (e) { status(e.message); $('join').disabled = false; + + if (e.why === 'session') + accounts.ended(); + return; } + chat.peers(room.peers); + + /* The name the relay gave us -- a handle, or the guest name cleaned + up -- as everyone else sees it. */ + const name = shownName(room.identity); + maker = new Maker(room.peer, transportNow, { edits: () => editsSeen }); $('knoblead').value = maker.knobLead; $('transportlead').value = maker.transportLead; /* The document. */ doc = new Y.Doc(); - provider = new WebsocketProvider(`${url}/doc`, roomName, doc); + provider = new WebsocketProvider(`${url}/doc`, roomName, doc, + room.ticket === null + ? {} : { params: { ticket: + room.ticket } }); + followTickets(); const c = colourOf(name); @@ -2031,12 +2097,7 @@ async function join () $('piece').title = 'This relay is older than the page and cannot ' + 'switch pieces.'; - /* The mesh. */ - mesh = new Mesh(room, (from, cmd) => receive(from, cmd)); - mesh.on('change', showPeers) - .on('fallback', (peer, why) => - log(`${room.peers.get(peer)?.name ?? peer}: through the relay ` + - `(${why})`)); + openMesh(); $('joinrow').hidden = true; $('roompanel').hidden = false; @@ -2058,10 +2119,132 @@ async function join () : {}) }; history.replaceState(null, '', `?${new URLSearchParams( - { ...where, name })}`); + { ...where, name: room.identity.name })}`); invite = new URL(`?${new URLSearchParams(where)}`, location.href).href; } +/* The document socket's next reconnect goes in with the room socket's + latest ticket. */ +function followTickets () +{ + room.on('ticket', (ticket) => { provider.params = { ticket }; }); +} + +function openMesh () +{ + mesh = new Mesh(room, (from, cmd) => receive(from, cmd)); + mesh.on('change', showPeers) + .on('fallback', (peer, why) => + log(`${room.peers.get(peer)?.name ?? peer}: through the relay ` + + `(${why})`)); +} + +/* How long a lost room waits before it is joined again, doubling to the + last, and how many tries it makes before it leaves it to Rejoin. */ +const REJOIN_FIRST_MS = 1000; +const REJOIN_MAX_MS = 30 * 1000; +const REJOIN_TRIES = 8; + +let rejoinTimer = null; +let rejoinTries = 0; + +/* The room socket `r' closed. Its tickets went with it, so the document + socket would be refused at every retry and the editor would type into + a document nobody else sees: the document stops, and stays as it is on + this page, the editor read only, until the room is joined again -- by + itself, after a relay restart or a dropped network, or with Rejoin + when the relay said no or would not answer. */ +function lost (r, refused) +{ + if (r !== room) + return; + + provider?.disconnect(); + mesh?.close(); + $('editor').inert = true; + $('rejoin').hidden = false; + + if (refused?.why === 'session') + accounts.ended(); + + if (refused !== null || rejoinTries >= REJOIN_TRIES) + { + status(refused !== null + ? `The relay closed the room: ${refused.text}.` + : 'The relay went away. Press Rejoin to try again.'); + return; + } + + const wait = Math.min(REJOIN_FIRST_MS * 2 ** rejoinTries++, + REJOIN_MAX_MS); + + status(`The relay went away; joining again in ${Math.round( + wait / 1000)} s...`); + clearTimeout(rejoinTimer); + rejoinTimer = setTimeout(rejoin, wait); +} + +/* The room again, as whoever this page is now, with this page's document + as it stands: a new room socket and with it a new ticket, the document + socket put back on it, a new mesh, and the seat taken again. */ +async function rejoin () +{ + clearTimeout(rejoinTimer); + $('rejoin').hidden = true; + + const was = room; + const seat = was.seat; + const where = await relaysOf(new URLSearchParams(location.search)); + let next; + + try + { + /* Seeded, if the relay lost the room, with the piece this page + has, so that what comes back is the one document. */ + /* A guest's name again; an account's, if its session has ended + since, is the account's and not a guest's to take. */ + next = await openRoom(where.url, was.roomName, + was.identity.account === true + ? `guest-${Math.floor(Math.random() * 1000)}` + : was.identity.name, + { piece: pieceName(doc), + session: await accounts.session(where) }); + } + catch (e) + { + if (e.why === 'session') + accounts.ended(); + + /* Refused: said, and left to Rejoin. Unreachable: tried again. */ + lost(was, e.why === undefined ? null : { text: e.message, + why: e.why }); + return; + } + + rejoinTries = 0; + room = next; + + maker = new Maker(room.peer, transportNow, + { edits: () => editsSeen, knobLead: maker.knobLead, + transportLead: maker.transportLead }); + provider.params = room.ticket === null ? {} : { ticket: room.ticket }; + followTickets(); + provider.connect(); + openMesh(); + $('editor').inert = false; + + if (seat !== null) + room.claim(seat); + + showPeers(); + chat.peers(room.peers); + status(`Back in ${room.roomName}.`); + + if (synth !== null && room.playing !== null && + room.runKey !== appliedRun) + await joinRun(); +} + /* The room's address without the name in it (join). */ let invite = ''; @@ -2303,6 +2486,19 @@ function init () $('room').value = params.get('room') ?? 'lobby'; $('name').value = params.get('name') ?? ''; + + /* Logged in, the name is the handle, and not this page's to change. */ + accounts = createAccounts({ + open: $('account'), dialog: $('accountdialog'), + relays: () => relaysOf(params), + onChange: (handle) => + { + if (handle !== null || $('name').disabled) + $('name').value = handle ?? ''; + + $('name').disabled = handle !== null; + }, + }); showPieces(params.get('piece')); keyboard = new Keyboard($('keys'), { onPress: press, onRelease: release }); @@ -2348,6 +2544,11 @@ function init () }); $('join').addEventListener('click', join); + $('rejoin').addEventListener('click', () => + { + rejoinTries = 0; + rejoin(); + }); $('room').addEventListener('input', showNewPiece); $('invite').addEventListener('click', copyInvite); showRooms(); diff --git a/wasm/web/jamtest.mjs b/wasm/web/jamtest.mjs index df015170..812c4e04 100644 --- a/wasm/web/jamtest.mjs +++ b/wasm/web/jamtest.mjs @@ -75,9 +75,13 @@ * tabs and the menu follow the text, and a graph it names that the room * lacks comes in at the Play. * - * Last, the two pages talk: a line each way through the room's chat, and + * Then the two pages talk: a line each way through the room's chat, and * a Play from one reported in the other's feed. * + * Last, one page makes an account in the account dialog and logs in with + * its key on a reload, and the other joins as a guest: each shows the + * handle as it is and the guest marked as one. + * * Live rather than offline, because two peers have to agree on a clock * and an offline context has none. A headless browser has no sound card, * but it renders an AudioContext in real time all the same, and real time @@ -968,10 +972,15 @@ async function switchTogether (pages, browser) null, { timeout: 15000 }); await page.click('#start'); + /* Fifteen seconds, not five: Start opens an audio context and + waits for four clock samples of it and of the relay before the + catch-up begins, then loads the switched piece's document and + steps through the run -- and on a loaded runner the joiner has + been seen still catching up at five, its clocks long ready. */ if (await page.waitForFunction( () => window.jam.ready() && !window.jam.catching() && window.jam.probe().running, - null, { timeout: 5000 }).then(() => true, () => false)) + null, { timeout: 15000 }).then(() => true, () => false)) C = { label, page }; else { @@ -1061,7 +1070,8 @@ async function switchTogether (pages, browser) after.push(await page.evaluate(() => ({ tape: window.jam.tape(), sent: window.jam.sent() }))); - const stopped = after[1].sent.findLast((c) => c.op === 'stop'); + const stopped = after[1].sent.findLast((c) => c.op === 'stop' && + c.at >= 0); const theirs = after.map((r) => tapeBefore(r.tape, stopped.at)); const colony = reference(PAINT_PIECE, nodeBuild, { commands: [stopped], stopAt: stopped.at }); @@ -1105,7 +1115,8 @@ async function switchRacePlaying (pages) }))) }); const piece = results[0].piece; - const stop = results[0].sent.findLast((c) => c.op === 'stop'); + const stop = results[0].sent.findLast((c) => c.op === 'stop' && + c.at >= 0); if (!['ebb.gen', SWITCH_PIECE].includes(piece) || results[1].piece !== piece || stop === undefined || stop.at < 0) @@ -1209,7 +1220,8 @@ async function passedTogether (pages) late: window.jam.late().seen, }))) }); - const stop = results[0].sent.findLast((c) => c.op === 'stop'); + const stop = results[0].sent.findLast((c) => c.op === 'stop' && + c.at >= 0); const want = reference(piece, nodeBuild, { commands: [stop], stopAt: stop.at }); @@ -1629,7 +1641,10 @@ async function chatTogether (pages) { timeout: 5000 }); await A.page.evaluate(() => window.jam.play()); - if (await shows(B, 'chatactivity', `^${A.label} pressed Play$`)) + /* Both are guests, and are shown as guests. */ + const guest = (label) => `${label} \\(guest\\)`; + + if (await shows(B, 'chatactivity', `^${guest(A.label)} pressed Play$`)) ok(`${B.label}'s feed says ${A.label} pressed Play`); else fail(`${B.label}'s feed never said ${A.label} pressed Play: ` + @@ -1641,7 +1656,8 @@ async function chatTogether (pages) await A.page.keyboard.type('switch at 17'); await A.page.keyboard.press('Enter'); - if (await shows(B, 'chatline', `^\\d+\\.\\d+ ${A.label}: switch at 17$`)) + if (await shows(B, 'chatline', + `^\\d+\\.\\d+ ${guest(A.label)}: switch at 17$`)) ok(`a line typed on ${A.label} is on ${B.label} with its name and ` + 'bar.beat'); else @@ -1663,7 +1679,8 @@ async function chatTogether (pages) await B.page.keyboard.type('zsxdcvgbhnjm'); await B.page.keyboard.press('Enter'); - const went = await shows(A, 'chatline', `${B.label}: zsxdcvgbhnjm$`); + const went = await shows(A, 'chatline', + `${guest(B.label)}: zsxdcvgbhnjm$`); const after = await notes(); if (held > before && after === held && went) @@ -1676,6 +1693,274 @@ async function chatTogether (pages) await A.page.evaluate(() => window.jam.stop()); } +/* An account and a guest in one room. The first page creates an account + * in the dialog and saves its key, logs out, reloads, and logs in again + * with the key typed as a person might; the second joins as a guest, + * after being turned away under the account's handle. Both see the handle + * as it is and the guest marked as one: in the peers, in chat, on a seat, + * and on the cursor in the editor. + */ +async function accountsTogether (pages) +{ + const [A, B] = pages; + const lobby = `${url}&room=jamaccounts&piece=${HANDS_PIECE}`; + const dialog = (page) => page.locator('#accountdialog'); + + await A.page.goto(lobby); + await A.page.click('#account', { timeout: 10000 }); + await A.page.fill('#account-handle', 'Ann'); + await dialog(A.page).getByRole('button', { name: 'Create account' }) + .click(); + await A.page.waitForSelector('#account-save-key'); + + const key = await A.page.inputValue('#account-save-key'); + + await dialog(A.page).getByRole('button', { name: 'Save key' }).click(); + await dialog(A.page).getByRole('button', { name: 'Log out' }).click(); + await A.page.waitForSelector('#account-login-key'); + await A.page.click('#accountclose'); + + if (key.split('-').length === 8 && + await A.page.evaluate(() => !document.getElementById('name').disabled)) + ok(`an account is made in the dialog, and its key is shown once`); + else + fail(`the dialog's key was "${key}"`); + + await A.page.reload(); + await A.page.click('#account', { timeout: 10000 }); + await A.page.fill('#account-login-key', + key.toUpperCase().replaceAll('-', ' ')); + await dialog(A.page).getByRole('button', { name: 'Log in' }).click(); + await A.page.waitForSelector('#account-newhandle'); + await A.page.click('#accountclose'); + + const named = await A.page.evaluate(() => + [document.getElementById('name').value, + document.getElementById('name').disabled]); + + if (named[0] === 'Ann' && named[1]) + ok('the key logs in on a reload, and the name is the handle'); + else + fail(`logged in, the name box holds ${JSON.stringify(named)}`); + + /* Sent to another relay, the page is a guest there: no button, and + the session neither sent nor dropped. */ + const other = await relay({ port: 0, host: '127.0.0.1', tree: top, + corsOrigin: '*' }); + + try + { + await A.page.goto(`${url}&room=jamelsewhere&name=Mal&relay=` + + `ws://127.0.0.1:${other.address().port}`); + await A.page.waitForFunction( + () => !document.getElementById('roompanel').hidden, null, + { timeout: 15000 }); + + const there = [...other.rooms.get('jamelsewhere').peers.values()]; + const kept = await A.page.evaluate(() => Object.keys(localStorage) + .filter((k) => k.startsWith('thinksynth:account:')).length); + const button = await A.page.isVisible('#account'); + + if (there.length === 1 && there[0].account === null && + there[0].name === 'Mal' && kept === 1 && !button) + ok('another relay is joined as a guest, and not shown the ' + + 'session'); + else + fail(`at another relay: ${JSON.stringify(there.map((p) => + [p.name, p.account]))}, ${kept} sessions kept, the ` + + `button ${button ? 'shown' : 'hidden'}`); + } + finally + { + other.shutdown(); + } + + /* A relay that is the site's but offers no accounts -- rolled back, + or run without CORS_ORIGIN -- leaves a session kept for it alone: + the name is the page's to change, and there is no button. */ + const bare = await relay({ port: 0, host: '127.0.0.1', tree: top }); + const bareUrl = `ws://127.0.0.1:${bare.address().port}`; + + try + { + await A.page.route('**/config.json', + (r) => r.fulfill({ json: { relay: bareUrl } })); + await A.page.evaluate((origin) => localStorage.setItem( + `thinksynth:account:${origin}`, + JSON.stringify({ session: `s_${'3'.repeat(32)}`, + handle: 'Ann' })), + bareUrl.replace(/^ws/, 'http')); + await A.page.goto(`${url}&room=jambare`); + await new Promise((r) => setTimeout(r, 1500)); + + const free = await A.page.evaluate(() => + [document.getElementById('name').disabled, + document.getElementById('account').hidden]); + + if (!free[0] && free[1]) + ok('a home relay without accounts leaves the name free and ' + + 'shows no button'); + else + fail(`a home relay without accounts: name ${free[0] + ? 'locked' : 'free'}, button ${free[1] ? 'hidden' : 'shown'}`); + + await A.page.evaluate((origin) => localStorage.removeItem( + `thinksynth:account:${origin}`), bareUrl.replace(/^ws/, 'http')); + await A.page.unroute('**/config.json'); + } + finally + { + bare.shutdown(); + } + + /* The site's config.json read once a load: were the join to read it + again and get nothing, it would go to the default relay with the + session kept for this one. */ + let configs = 0; + + await A.page.route('**/config.json', + (r) => (configs++ === 0 ? r.continue() : r.abort())); + await A.page.goto(lobby); + await A.page.waitForFunction( + () => document.getElementById('name').value === 'Ann', null, + { timeout: 10000 }); + + await A.page.click('#join'); + + if (await A.page.waitForFunction( + () => !document.getElementById('roompanel').hidden, null, + { timeout: 15000 }).then(() => true, () => false)) + ok(`the join goes where the page found its relay, config.json ` + + `read ${configs} time${configs === 1 ? '' : 's'}`); + else + fail('the join went elsewhere: ' + await why(A.page)); + + await A.page.unroute('**/config.json'); + + await B.page.goto(`${lobby}&name=ann`); + + if (await B.page.waitForFunction( + () => /account's handle/.test(document.getElementById('status') + .textContent), + null, { timeout: 10000 }).then(() => true, () => false)) + ok('a guest is turned away under the account\'s handle'); + else + fail('a guest named ann joined: ' + await why(B.page)); + + /* Opened before the network: the first config.json read fails, and + the join reads it again rather than going to the default relay. */ + let unread = true; + + await B.page.route('**/config.json', (r) => + { + if (unread) + { + unread = false; + return r.abort(); + } + + return r.continue(); + }); + await B.page.goto(`${lobby}&name=Bo`); + + if (await B.page.waitForFunction( + () => !document.getElementById('roompanel').hidden, null, + { timeout: 15000 }).then(() => true, () => false)) + ok('a config.json that failed at the load is read again to join'); + else + fail('a join after a failed config.json: ' + await why(B.page)); + + await B.page.unroute('**/config.json'); + await B.page.evaluate(() => window.jam.seat(0)); + + const peersOf = (page) => page.evaluate(() => + [...document.querySelectorAll('#peers .peer')] + .map((p) => p.firstChild.textContent).sort().join(', ')); + const want = 'Ann, Bo (guest) (channel 1)'; + + for (const who of [A, B]) + { + const seen = await who.page.waitForFunction( + (w) => [...document.querySelectorAll('#peers .peer')] + .map((p) => p.firstChild.textContent).sort().join(', ') === w, + want, { timeout: 10000 }).then(() => true, () => false); + + if (seen) + ok(`${who.label}'s peers are ${want}`); + else + fail(`${who.label}'s peers are ${await peersOf(who.page)}`); + } + + const said = (who, cls, src) => who.page.waitForFunction( + ([c, s]) => [...document.querySelectorAll(`#chatfeed .${c}`)] + .some((li) => new RegExp(s).test(li.textContent)), + [cls, src], { timeout: 5000 }).then(() => true, () => false); + + for (const [from, to, line, src] of [ + [A, B, 'from an account', '^Ann: from an account$'], + [B, A, 'from a guest', '^Bo \\(guest\\): from a guest$']]) + { + await from.page.fill('#chatinput', line); + await from.page.press('#chatinput', 'Enter'); + + if (await said(to, 'chatline', src)) + ok(`${to.label}'s chat says ${src}`); + else + fail(`${to.label}'s chat never said ${src}`); + } + + if (await said(A, 'chatactivity', '^Bo \\(guest\\) took channel 1$')) + ok('and the guest\'s seat is marked as a guest\'s'); + else + fail('the guest\'s seat is not in the feed as a guest\'s'); + + /* A cursor each, with its name over it in the other's editor. */ + for (const { page } of [A, B]) + await page.click('#editor .cm-content'); + + for (const [who, name] of [[A, 'Bo (guest)'], [B, 'Ann']]) + { + const seen = await who.page.waitForFunction( + (n) => [...document.querySelectorAll('.cm-ySelectionInfo')] + .some((e) => e.textContent === n), + name, { timeout: 10000 }).then(() => true, () => false); + + if (seen) + ok(`${who.label}'s editor names the other cursor ${name}`); + else + fail(`${who.label}'s editor has no cursor named ${name}`); + } + + /* The relay drops the account's room socket, as a restart or a lost + network does. The page joins again by itself, and an edit made + after reaches the other page. */ + for (const p of relayServer.rooms.get('jamaccounts').peers.values()) + if (p.name === 'Ann') + p.ws.terminate(); + + const back = await A.page.waitForFunction( + () => /^Back in jamaccounts/.test( + document.getElementById('status').textContent), + null, { timeout: 15000 }).then(() => true, () => false); + + await A.page.evaluate(() => window.jam.setFile( + 'hands.gen', `# after the rejoin\n${window.jam.file('hands.gen')}`)); + + const synced = await B.page.waitForFunction( + () => window.jam.file('hands.gen')?.startsWith('# after the rejoin'), + null, { timeout: 10000 }).then(() => true, () => false); + + if (back && synced) + ok('a page whose room socket is cut joins again, and its edits ' + + 'reach the room'); + else + fail(`after its room socket was cut: ${back ? 'rejoined' : 'not ' + + 'rejoined'}, the edit ${synced ? 'synced' : 'not synced'} -- ` + + await why(A.page)); + + await A.page.evaluate(() => localStorage.clear()); +} + /* A stage's parameter, typed into the popover beside its box. * * The panel is the module's description of the stage (src/StagePanel.cpp) @@ -2022,7 +2307,8 @@ if (!fs.existsSync(path.join(build, 'jam.js'))) process.exit(1); } -const relayServer = await relay({ port: 0, host: '127.0.0.1', tree: top }); +const relayServer = await relay({ port: 0, host: '127.0.0.1', tree: top, + corsOrigin: '*' }); const relayUrl = `ws://127.0.0.1:${relayServer.address().port}`; const site = await serve(build, 0, '127.0.0.1', relayUrl); /* The document rather than the tiled layout. Both are the page -- panes.js @@ -2118,7 +2404,7 @@ try null, { timeout: 15000 }).catch(() => {}); const peers = await page.evaluate(() => window.jam.peers()); - const other = peers.find((p) => p.name !== label); + const other = peers.find((p) => p.name !== `${label} (guest)`); if (other === undefined) fail(`${label} does not see the other peer`); @@ -2442,6 +2728,10 @@ try await chatTogether(pages); + /* ---- as an account, and a guest ---- */ + + await accountsTogether(pages); + for (const e of errors) fail(`page error: ${e}`); } diff --git a/wasm/web/package.json b/wasm/web/package.json index 74d21ec2..6e23e6c4 100644 --- a/wasm/web/package.json +++ b/wasm/web/package.json @@ -8,6 +8,7 @@ "piececheck": "node piececheck.mjs", "protocoltest": "node protocoltest.mjs", "relaytest": "node relaytest.mjs", + "accountstest": "node accountstest.mjs", "relay": "node relay.mjs", "browsertest": "node browsertest.mjs", "pagetest": "node pagetest.mjs", diff --git a/wasm/web/relay.mjs b/wasm/web/relay.mjs index ebe8af46..195f2500 100644 --- a/wasm/web/relay.mjs +++ b/wasm/web/relay.mjs @@ -22,8 +22,9 @@ * and the way peers find each other. * * node wasm/web/relay.mjs [--port 8787] [--tree DIR] + * node wasm/web/relay.mjs admin (accounts.mjs, runAdmin) * - * One process, one port, no database. It does three jobs and is + * One process, one port. It does three jobs and is * authoritative for none of the music: it holds the shared document so a * late joiner has somewhere to fetch it from; it answers pings so every * peer can agree on one clock; and it says who is in a room, on which @@ -38,20 +39,31 @@ * origin, and is playing the room's piece from there (commands.js, * catchUp). * - * GET / health: version, and each room's people and piece + * GET / health: version, accounts, each room's people and + * piece * WS /doc/ the Yjs document, y-websocket's protocol * WS /room/ JSON: presence, seats, clock, signalling, chat + * /api/account/... accounts: handles, keys, sessions (accounts.mjs) * * Two sockets per peer rather than one: y-websocket's framing is its * own, and the JSON side is easier to read on the wire and in a harness * when it is not sharing a socket with binary CRDT updates. * + * Who someone is, is the room socket's to say: its hello carries an + * account's session, or nothing for a guest, who goes by a name that is + * nobody's handle. The document socket cannot say anything first -- + * y-websocket opens it and speaks at once -- so the room socket's welcome + * hands out a ticket for it, good for one room for a few minutes, and the + * relay opens no document socket without one. + * * A room is made when the first peer arrives and seeded with a shipped * piece -- the .gen, and every .dsp it names, from the tree -- and kept * for an hour after the last one leaves. A peer can have it seeded again - * with another (`switch'). Nothing is persisted. + * with another (`switch'). Rooms are not persisted; accounts are, in one + * SQLite file (DB=..., beside the relay by default). */ +import crypto from 'node:crypto'; import fs from 'node:fs'; import http from 'node:http'; import path from 'node:path'; @@ -64,6 +76,9 @@ import * as syncProtocol from 'y-protocols/sync'; import * as decoding from 'lib0/decoding'; import * as encoding from 'lib0/encoding'; +import { ACCOUNT_API, normalizeName, shownName } from './account.js'; +import { AccountStore, Accounts, ADMIN_USAGE, accountRoutes, + runAdmin } from './accounts.mjs'; import { RELAY, TRANSPORT_LEAD } from './commands.js'; import { DEFAULT_PIECE, dspNames, files, hashOfFiles, hasSeen, meta, pieceName, putFile, readSeen, seenOf, snapshot } from './doc.js'; @@ -74,6 +89,10 @@ export const PROTOCOL = 1; const MSG_SYNC = 0; const MSG_AWARENESS = 1; +/* A cursor color as editor.js's colourOf writes it, with or without the + selection's alpha. */ +const CURSOR_COLOR = /^hsl\(\d{1,3} 70% 45%( \/ 0\.25)?\)$/; + /* How long a start waits for the relay's copy of the document to reach the revision it names before keeping what is there. */ const SNAPSHOT_WAIT = 10 * 1000; @@ -88,6 +107,18 @@ const SWITCH_GATHER_MS = 50; caught up with rather than handed part of it. */ const LOG_MAX = 200000; +/* And the most it keeps in bytes, of JSON, and of one command: an edit + carries a piece's texts, and nothing a page sends is longer than + that. */ +const LOG_BYTES_MAX = 32 * 1024 * 1024; +const LOG_ENTRY_MAX = 512 * 1024; + +/* Awareness clients one document socket may speak for, and a room may + hold: a page is one, and a reconnect briefly has the old socket's + too. */ +const CLIENTS_PER_SOCKET = 2; +const CLIENTS_PER_ROOM = 256; + /* How long an empty room is kept, and how often that is looked at. */ const EMPTY_FOR = 60 * 60 * 1000; const SWEEP_EVERY = 60 * 1000; @@ -99,6 +130,33 @@ const CHAT_MAX = 500; const CHAT_BURST = 5; const CHAT_PER_SECOND = 5; +/* A document ticket's life. A room socket is handed a new one when two + fifths of it have gone, so the ticket a page reconnects its document + with is never one about to lapse. */ +const TICKET_MS = 5 * 60 * 1000; + +/* The largest frame a socket may send: a room socket's are JSON lines, + the longest a start carrying its snapshot (doc.js, SEEN_MAX); a + document socket's are Yjs updates, the largest a whole piece pasted or + a first sync of everything a page holds. */ +const ROOM_FRAME_MAX = 1024 * 1024; +const DOC_FRAME_MAX = 4 * 1024 * 1024; + +/* Document sockets one room socket may have open at once: a page has + one, and a reconnect briefly two. */ +const DOCS_PER_PEER = 4; + +/* How often every socket is pinged; one that has not answered the last + ping by the next is cut. A document socket says nothing while nobody + types, and one whose page went away without a close would otherwise + hold its cursor in the room for good. */ +const HEARTBEAT_MS = 30 * 1000; + +/* How often the sessions behind open room sockets are looked at again: + the admin commands end sessions from another process, which has no way + to tell this one. */ +const SESSION_CHECK_MS = 60 * 1000; + const here = path.dirname(fileURLToPath(import.meta.url)); /* The relay's clock: milliseconds as a double, from the monotonic clock @@ -173,17 +231,25 @@ function newId () return Math.random().toString(36).slice(2, 8); } -/* One room: a document and the peers in it. */ +/* One room: a document and the peers in it. `accounts' says who a + session is of, and `tickets' is the relay's, which the document sockets + are let in by. */ class Room { - constructor (name, seedWith, tree) + constructor (name, seedWith, tree, + { accounts, tickets, ticketMs, sessions }) { this.name = name; this.tree = tree; + this.accounts = accounts; + this.tickets = tickets; + this.ticketMs = ticketMs; + this.sessions = sessions; /* whether a hello's counts */ this.doc = new Y.Doc(); this.awareness = new awarenessProtocol.Awareness(this.doc); this.docConns = new Set(); /* document sockets */ - this.peers = new Map(); /* id -> { ws, name, seat } */ + this.peers = new Map(); /* id -> { ws, name, seat, + account, tickets, docs } */ this.seats = new Map(); /* seat -> peer id */ this.playing = null; /* the last transport start */ this.emptySince = relayNow(); @@ -203,6 +269,9 @@ class Room /* The awareness protocol's own clients: what to forget when a socket closes. */ this.controlled = new Map(); /* ws -> Set of client ids */ + this.clientSocket = new Map(); /* client id -> its ws */ + this.docOwner = new Map(); /* ws -> its room socket's peer */ + this.leaving = new Map(); /* room ws -> its leave() */ seedFiles(this.doc, seedWith, tree); @@ -223,14 +292,18 @@ class Room if (origin !== null && origin !== undefined && this.controlled.has(origin)) - { - const ids = this.controlled.get(origin); - for (const id of added) - ids.add(id); + { + this.controlled.get(origin).add(id); + this.clientSocket.set(id, origin); + } - for (const id of removed) - ids.delete(id); + /* Gone whoever said so: the socket, its close, or the + awareness timing a quiet client out. */ + for (const id of removed) + { + this.controlled.get(this.clientSocket.get(id))?.delete(id); + this.clientSocket.delete(id); } const enc = encoding.createEncoder(); @@ -260,7 +333,7 @@ class Room begin (start, files = this.snapshotAt(start.piece ?? {})) { this.playing = start; - this.run = { start, log: [], overflowed: false, files }; + this.run = { start, log: [], bytes: 0, overflowed: false, files }; } /* A switch made while the room plays, played: a start of the relay's @@ -299,10 +372,16 @@ class Room if (run === null || runKey !== runKeyOf(run.start)) return; - if (run.log.length >= LOG_MAX) + const bytes = Buffer.byteLength(JSON.stringify(cmd)); + + if (run.log.length >= LOG_MAX || bytes > LOG_ENTRY_MAX || + run.bytes + bytes > LOG_BYTES_MAX) run.overflowed = true; else + { run.log.push(cmd); + run.bytes += bytes; + } } /* The document at the revision `hash' names: now, if the relay's copy @@ -379,18 +458,166 @@ class Room }); } + /* A ticket for `peer''s document socket, which goes when the room + socket does: whatever opened that and is let in by this should not + outlast it. */ + issue (peer) + { + const now = relayNow(); + const ticket = `t_${crypto.randomBytes(16).toString('hex')}`; + + for (const t of peer.tickets) + if (!(this.tickets.get(t)?.until > now)) + { + this.tickets.delete(t); + peer.tickets.delete(t); + } + + this.tickets.set(ticket, { room: this, peer, + until: now + this.ticketMs }); + peer.tickets.add(ticket); + return ticket; + } + + /* The room sockets whose account `ends' says is over -- logged out, + banned, deleted -- told why and closed. Their tickets and document + sockets go now rather than when the close completes, which a + client that has stopped answering can hold off for half a + minute. */ + endSessions (ends, why) + { + for (const p of this.peers.values()) + if (p.account !== null && ends(p.account) && + p.ws.readyState === p.ws.OPEN) + { + p.ws.send(JSON.stringify({ type: 'error', why: 'session', + text: why })); + p.ws.close(); + this.leaving.get(p.ws)(); + } + } + + /* Whether a peer is an account, as the room is told: null on a relay + without accounts, where nobody is a guest for not being one. */ + isAccount (peer) + { + return this.sessions ? peer.account !== null : null; + } + + /* A peer's way into the document, gone. */ + revoke (peer) + { + for (const t of peer.tickets) + this.tickets.delete(t); + + for (const d of peer.docs) + d.terminate(); + } + /* ---- the document socket ---- */ - attachDoc (ws) + /* An awareness update from `ws', as the relay will pass it on: the + name on a cursor is the one its room socket goes by, a guest's + marked as one, and not whatever the page put there; and no socket + speaks for a client another peer's does. One of the same peer's + takes the client over: that is a page's document socket + reconnecting, the old one not yet known to be dead. */ + vouched (update, ws, owner) { + const dec = decoding.createDecoder(update); + const enc = encoding.createEncoder(); + const kept = []; + const fresh = new Set(); + const n0 = decoding.readVarUint(dec); + + if (n0 > CLIENTS_PER_SOCKET) + throw new Error(`${n0} awareness states in one update`); + + for (let n = n0; n > 0; n--) + { + const client = decoding.readVarUint(dec); + const clock = decoding.readVarUint(dec); + let state = JSON.parse(decoding.readVarString(dec)); + const holder = this.clientSocket.get(client); + + if (holder !== undefined && holder !== ws && + this.docOwner.get(holder) !== owner) + continue; + + if (holder !== undefined && holder !== ws) + { + this.controlled.get(holder).delete(client); + this.controlled.get(ws).add(client); + this.clientSocket.set(client, ws); + } + + /* A page is one client. Every id a socket makes up is a state + the relay keeps and hands to everyone, so a socket that + claims more than a page could is cut, as is any past a + room's worth. */ + if (holder === undefined && state !== null) + { + fresh.add(client); + + if (this.controlled.get(ws).size + fresh.size > + CLIENTS_PER_SOCKET || + this.clientSocket.size + fresh.size > CLIENTS_PER_ROOM) + throw new Error('more awareness clients than a page has'); + } + + /* A state, unless it is the one saying the client is gone: and + every one with the relay's name on it, whatever the page put + there or left out, so that none goes nameless and unmarked. */ + if (state !== null) + { + if (typeof state !== 'object' || Array.isArray(state)) + state = {}; + + state.user = { + ...(typeof state.user === 'object' ? state.user : {}), + name: shownName({ name: owner.name, + account: this.isAccount(owner) }), + account: this.isAccount(owner), + }; + + /* The colors end up in other pages' style attributes, so + only the shape editor.js's colourOf makes goes through. */ + for (const k of ['color', 'colorLight']) + if (!CURSOR_COLOR.test(String(state.user[k]))) + delete state.user[k]; + } + + kept.push([client, clock, state]); + } + + encoding.writeVarUint(enc, kept.length); + + for (const [client, clock, state] of kept) + { + encoding.writeVarUint(enc, client); + encoding.writeVarUint(enc, clock); + encoding.writeVarString(enc, JSON.stringify(state)); + } + + return encoding.toUint8Array(enc); + } + + attachDoc (ws, owner) + { + owner.docs.add(ws); this.docConns.add(ws); this.controlled.set(ws, new Set()); + this.docOwner.set(ws, owner); ws.binaryType = 'arraybuffer'; ws.on('message', (data) => { let bytes; + /* A socket cut for what it sent is not heard while it closes. */ + if (ws.readyState !== ws.OPEN) + return; + if (data instanceof ArrayBuffer) bytes = new Uint8Array(data); else if (Array.isArray(data)) @@ -421,7 +648,9 @@ class Room case MSG_AWARENESS: awarenessProtocol.applyAwarenessUpdate( - this.awareness, decoding.readVarUint8Array(dec), + this.awareness, + this.vouched(decoding.readVarUint8Array(dec), ws, + owner), ws); break; } @@ -436,10 +665,12 @@ class Room ws.on('close', () => { + owner.docs.delete(ws); this.docConns.delete(ws); awarenessProtocol.removeAwarenessStates( this.awareness, [...(this.controlled.get(ws) ?? [])], null); this.controlled.delete(ws); + this.docOwner.delete(ws); this.touch(); }); @@ -471,6 +702,7 @@ class Room attachRoom (ws) { let id = null; + let ticketing = null; let chatTokens = CHAT_BURST; let chatAt = relayNow(); @@ -524,6 +756,12 @@ class Room ws.on('message', (data) => { + /* Closing is final: after a refused hello, or a session ended + (endSessions), whatever else a client sends before the close + completes -- up to half a minute of it -- is not heard. */ + if (ws.readyState !== ws.OPEN) + return; + let m; try @@ -558,20 +796,91 @@ class Room return; } + /* A page from before tickets would join the room and + wait for ever on a document socket that is never let + in; told now, it says so, and a reload is the fix. */ + if (m.tickets !== true) + { + send({ type: 'error', why: 'old', + text: 'this page is older than the relay: press ' + + 'Update above, or close thinksynth\'s ' + + 'other tabs and reload' }); + ws.close(); + return; + } + + /* An account plays under its handle. A session the relay + no longer knows is refused rather than made a guest, or + somebody would play the room believing they were logged + in. A guest goes by the name asked for, if that is not + an account's. */ + let account; + let asked; + + /* A relay without accounts has no sessions to know. */ + const session = this.sessions ? m.session : undefined; + + try + { + account = session === undefined ? null + : this.accounts.sessionAccount({ session }); + asked = account?.handle ?? + normalizeName(String(m.name ?? '')); + + if (this.sessions && account === null && + asked !== null && !this.accounts.nameFree(asked)) + asked = undefined; + } + catch (e) + { + process.stderr.write(`relay: accounts: ${e.message}\n`); + send({ type: 'error', why: 'accounts', + text: 'the relay cannot look up accounts right ' + + 'now; try again in a moment' }); + ws.close(); + return; + } + + if (session !== undefined && account === null) + { + send({ type: 'error', why: 'session', + text: 'your session has ended; log in again' }); + ws.close(); + return; + } + + if (asked === undefined) + { + send({ type: 'error', why: 'name', + text: `${normalizeName(String(m.name))} is an ` + + 'account\'s handle; log in, or pick ' + + 'another name' }); + ws.close(); + return; + } + id = newId(); while (this.peers.has(id) || id === RELAY) id = newId(); - const name = String(m.name ?? '').slice(0, 32) || id; + const name = asked ?? id; + const peer = { ws, name, seat: null, account, + tickets: new Set(), docs: new Set() }; - this.peers.set(id, { ws, name, seat: null }); + this.peers.set(id, peer); + ticketing = setInterval( + () => send({ type: 'ticket', ticket: this.issue(peer) }), + this.ticketMs * 2 / 5); send({ type: 'welcome', peer: id, + identity: { name, account: this.isAccount(peer) }, + ticket: this.issue(peer), peers: [...this.peers].map(([pid, p]) => - ({ peer: pid, name: p.name, seat: p.seat })), + ({ peer: pid, name: p.name, seat: p.seat, + account: this.isAccount(p) })), seats: seatMap(), piece: this.doc.getMap('meta').get('piece') ?? null, playing: this.playing, @@ -579,7 +888,8 @@ class Room features: ['switch'], }); - others({ type: 'joined', peer: id, name }); + others({ type: 'joined', peer: id, name, + account: this.isAccount(peer) }); return; } @@ -649,6 +959,11 @@ class Room if (typeof m.data !== 'object' || m.data === null) break; + /* Who made it is the relay's to say: a late joiner is + told whose Play it catches up with, and the run's + commands are told apart by sender and count. */ + m.data.from = id; + if (m.data.op === 'start' && m.data.piece?.seen !== undefined && readSeen(m.data.piece.seen) === null) @@ -684,7 +999,7 @@ class Room others have it. */ case 'log': if (typeof m.data === 'object' && m.data !== null) - this.record(m.data, m.run); + this.record({ ...m.data, from: id }, m.run); break; /* A line of text, to everyone in the room and back to its @@ -725,7 +1040,8 @@ class Room chatTokens--; const line = { type: 'chat', channel: m.channel, from: id, - name: me.name, text }; + name: me.name, + account: this.isAccount(me), text }; if (typeof m.bar === 'string' && /^\d{1,6}\.\d{1,2}$/.test(m.bar)) @@ -770,7 +1086,9 @@ class Room .then(async (hash) => { const line = { type: 'switched', from: id, - name: me.name, piece, hash }; + name: me.name, + account: this.isAccount(me), + piece, hash }; send(line); others(line); @@ -821,13 +1139,19 @@ class Room } }); - ws.on('close', () => + /* Gone from the room: at the close, or at once when the session + ends. */ + const leave = () => { - if (id === null) - return; + clearInterval(ticketing); const me = this.peers.get(id); + if (id === null || me?.ws !== ws) + return; + + this.revoke(me); + if (me?.seat !== null && me?.seat !== undefined) this.seats.delete(me.seat); @@ -835,9 +1159,15 @@ class Room others({ type: 'left', peer: id }); others({ type: 'seats', seats: seatMap() }); this.touch(); - }); + }; + ws.on('close', () => + { + this.leaving.delete(ws); + leave(); + }); ws.on('error', () => ws.close()); + this.leaving.set(ws, leave); } touch () @@ -862,11 +1192,32 @@ class Room } } -/* The server. Resolves with it listening; `address().port' says where. */ +/* The server. Resolves with it listening; `address().port' says where. + `db' is the accounts' file, or ':memory:'; `corsOrigin' and + `trustProxy' are accountRoutes'. The two times are for a harness. */ export function relay ({ port = 8787, host = '0.0.0.0', - tree = path.join(here, '..', '..') } = {}) + tree = path.join(here, '..', '..'), db = ':memory:', + corsOrigin = null, trustProxy = 0, + ticketMs = TICKET_MS, heartbeatMs = HEARTBEAT_MS, + sessionCheckMs = SESSION_CHECK_MS } = {}) { const rooms = new Map(); + const tickets = new Map(); /* ticket -> { room, peer, until } */ + const store = new AccountStore(db); + + /* Sessions ended over HTTP: one, or all of an account's but one. */ + const accounts = new Accounts({ + store, + onSessionsEnded: (ended, why) => + { + for (const r of rooms.values()) + r.endSessions(ended.session !== undefined + ? (a) => a.sessionHash === ended.session + : (a) => a.id === ended.account && + a.sessionHash !== ended.except, why); + }, + }); + const api = accountRoutes(accounts, { corsOrigin, trustProxy }); const room = (name, seedWith) => { @@ -874,7 +1225,9 @@ export function relay ({ port = 8787, host = '0.0.0.0', if (r === undefined) { - r = new Room(name, seedWith, tree); + r = new Room(name, seedWith, tree, + { accounts, tickets, ticketMs, + sessions: corsOrigin !== null }); rooms.set(name, r); } @@ -885,12 +1238,24 @@ export function relay ({ port = 8787, host = '0.0.0.0', { const url = new URL(req.url, 'http://localhost'); + /* Accounts are for the page at CORS_ORIGIN; without one there is + no such page, and no accounts at all. */ + if (url.pathname.startsWith(`${ACCOUNT_API}/`) && corsOrigin !== null) + { + api(req, res); + return; + } + if (url.pathname === '/') { res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }); res.end(JSON.stringify({ thinksynth: 'relay', protocol: PROTOCOL, + + /* Only a page at CORS_ORIGIN can use them, so without it + there are none to offer. */ + accounts: corsOrigin !== null, rooms: [...rooms].map(([name, r]) => ({ name, peers: r.peers.size, piece: pieceName(r.doc), playing: r.playing !== null })), @@ -901,9 +1266,27 @@ export function relay ({ port = 8787, host = '0.0.0.0', res.writeHead(404).end(); }); - const wss = new WebSocketServer({ noServer: true }); + const roomWss = new WebSocketServer({ noServer: true, + maxPayload: ROOM_FRAME_MAX }); + const docWss = new WebSocketServer({ noServer: true, + maxPayload: DOC_FRAME_MAX }); + /* Nothing a client sends may throw out of here: an exception in an + upgrade listener is the whole process. */ server.on('upgrade', (req, socket, head) => + { + try + { + upgrade(req, socket, head); + } + catch (e) + { + process.stderr.write(`relay: upgrade failed: ${e.stack}\n`); + socket.destroy(); + } + }); + + const upgrade = (req, socket, head) => { const url = new URL(req.url, 'http://localhost'); const m = /^\/(doc|room)\/([A-Za-z0-9_.-]{1,64})$/.exec(url.pathname); @@ -917,21 +1300,85 @@ export function relay ({ port = 8787, host = '0.0.0.0', /* The piece a new room is seeded with is named in the query, and only counts for the first socket to reach the room. */ const seedWith = url.searchParams.get('piece') ?? DEFAULT_PIECE; + const given = url.searchParams.get('ticket'); + const ticket = m[1] === 'doc' ? tickets.get(given) : null; + + if (m[1] === 'doc' && !(ticket !== undefined && + ticket.room === rooms.get(m[2]) && + ticket.until > relayNow() && + ticket.peer.docs.size < DOCS_PER_PEER)) + { + socket.end('HTTP/1.1 403 Forbidden\r\n' + + 'Connection: close\r\n\r\n'); + return; + } + + const wss = m[1] === 'doc' ? docWss : roomWss; wss.handleUpgrade(req, socket, head, (ws) => { - const r = room(m[2], seedWith); + ws.alive = true; + ws.on('pong', () => { ws.alive = true; }); - if (m[1] === 'doc') - r.attachDoc(ws); - else - r.attachRoom(ws); + try + { + /* The room socket may have gone while this one upgraded. */ + if (m[1] === 'doc' && !tickets.has(given)) + ws.close(); + else if (m[1] === 'doc') + ticket.room.attachDoc(ws, ticket.peer); + else + room(m[2], seedWith).attachRoom(ws); + } + catch (e) + { + process.stderr.write(`relay: upgrade failed: ${e.stack}\n`); + ws.terminate(); + } }); - }); + }; + + const heartbeat = setInterval(() => + { + for (const ws of [...roomWss.clients, ...docWss.clients]) + { + if (!ws.alive) + { + ws.terminate(); + continue; + } + + ws.alive = false; + ws.ping(); + } + }, heartbeatMs); + + heartbeat.unref(); + + /* Sessions ended by the admin commands, which another process ran. */ + const recheck = setInterval(() => + { + try + { + for (const r of rooms.values()) + r.endSessions((a) => accounts.sessionAccount(a) === null, + 'your session has ended; log in again'); + } + catch (e) + { + process.stderr.write(`relay: checking sessions: ${e.message}\n`); + } + }, sessionCheckMs); + + recheck.unref(); - /* Empty rooms go after an hour. */ + /* Empty rooms go after an hour, and lapsed tickets with them. */ const sweep = setInterval(() => { + for (const [t, { until }] of tickets) + if (until <= relayNow()) + tickets.delete(t); + for (const [name, r] of rooms) if (r.empty && relayNow() - r.emptySince > EMPTY_FOR) { @@ -947,11 +1394,14 @@ export function relay ({ port = 8787, host = '0.0.0.0', server.shutdown = () => { clearInterval(sweep); + clearInterval(recheck); + clearInterval(heartbeat); for (const r of rooms.values()) r.destroy(); rooms.clear(); + store.close(); server.closeAllConnections?.(); server.close(); }; @@ -960,6 +1410,7 @@ export function relay ({ port = 8787, host = '0.0.0.0', server.listen(port, host, () => { server.rooms = rooms; + server.accounts = accounts; resolve(server); })); } @@ -968,7 +1419,48 @@ if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) { const args = process.argv.slice(2); - const opts = {}; + + /* DB names the accounts' file; CORS_ORIGIN the page's origin, without + which there are no accounts; TRUST_PROXY how many proxies in front + append to X-Forwarded-For (1 behind nginx alone). */ + const opts = { db: process.env.DB || path.join(here, 'relay.db'), + corsOrigin: process.env.CORS_ORIGIN || null, + trustProxy: Number(process.env.TRUST_PROXY ?? 0) }; + + if (args[0] === 'admin') + { + if (opts.db === ':memory:' || !fs.existsSync(opts.db)) + { + process.stderr.write(`relay.mjs: no accounts at ${opts.db}; set ` + + `DB to the relay's file\n${ADMIN_USAGE}\n`); + process.exit(2); + } + + const store = new AccountStore(opts.db); + const status = runAdmin(args.slice(1), store, + (line) => process.stdout.write(`${line}\n`)); + + store.close(); + process.exit(status); + } + + /* An origin is what a browser sends in Origin, and nothing else: one + with a path or a slash after it matches no request at all, and `*' + would let any site spend its visitors' registrations here. */ + if (opts.corsOrigin !== null && + (!URL.canParse(opts.corsOrigin) || + new URL(opts.corsOrigin).origin !== opts.corsOrigin)) + { + process.stderr.write(`relay.mjs: CORS_ORIGIN is ${opts.corsOrigin}; ` + + 'it is scheme://host[:port], nothing after\n'); + process.exit(2); + } + + if (!(Number.isInteger(opts.trustProxy) && opts.trustProxy >= 0)) + { + process.stderr.write('relay.mjs: TRUST_PROXY is a count of proxies\n'); + process.exit(2); + } for (let i = 0; i < args.length; i++) { @@ -981,7 +1473,8 @@ if (process.argv[1] !== undefined && else { process.stderr.write( - 'usage: relay.mjs [--port N] [--host ADDR] [--tree DIR]\n'); + 'usage: relay.mjs [--port N] [--host ADDR] [--tree DIR]\n' + + ' relay.mjs admin \n'); process.exit(2); } } @@ -997,5 +1490,6 @@ if (process.argv[1] !== undefined && process.stdout.write(`relay on ws://${a.address}:${a.port}/ ` + `(rooms seeded from ${path.resolve( - opts.tree ?? path.join(here, '..', '..'))})\n`); + opts.tree ?? path.join(here, '..', '..'))}, ` + + `accounts in ${opts.db})\n`); } diff --git a/wasm/web/relaytest.mjs b/wasm/web/relaytest.mjs index b6aa3e2f..a39f59d3 100644 --- a/wasm/web/relaytest.mjs +++ b/wasm/web/relaytest.mjs @@ -38,15 +38,20 @@ * Exit status is the number of failures. */ +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import WebSocket, { WebSocketServer } from 'ws'; import * as Y from 'yjs'; import { WebsocketProvider } from 'y-websocket'; +import * as encoding from 'lib0/encoding'; import { dspNames, fileNames, hashOf, pieceText, readFile, seenOf } from './doc.js'; +import { AccountStore, runAdmin } from './accounts.mjs'; import { PROTOCOL, relay } from './relay.mjs'; import { Room } from './room.js'; @@ -145,6 +150,539 @@ class Client } } +/* Whether a socket is refused, or closed within `ms'. */ +function refused (ws, ms = 2000) +{ + return new Promise((r) => + { + const timer = setTimeout(() => r(false), ms); + + ws.on('error', () => {}); + ws.on('close', () => { clearTimeout(timer); r(true); }); + }); +} + +/* Who a room socket is, and what lets its document in: a relay of its + own, on a file the admin commands can open beside it, and with times + short enough to wait out. */ +async function accountsInRooms () +{ + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'relaytest-')); + const db = path.join(dir, 'relay.db'); + const acct = await relay({ port: 0, host: '127.0.0.1', tree, db, + corsOrigin: 'https://page.example.org', + ticketMs: 1000, sessionCheckMs: 200, + heartbeatMs: 300 }); + const at = `127.0.0.1:${acct.address().port}`; + const post = async (route, body, session) => + { + const res = await fetch(`http://${at}/api/account/${route}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', + ...(session ? { Authorization: `Bearer ${session}` } + : {}) }, + body: JSON.stringify(body), + }); + + return res.json(); + }; + const hello = async (room, m) => + { + const c = new Client(`ws://${at}/room/${room}`, m.name ?? 'session'); + + await c.open(); + c.send({ type: 'hello', protocol: PROTOCOL, tickets: true, ...m }); + return c; + }; + const docSocket = (room, ticket) => + new WebSocket(`ws://${at}/doc/${room}` + + (ticket === undefined ? '' : `?ticket=${ticket}`)) + .on('error', () => {}); + + try + { + const ann = await post('register', { handle: 'Ann' }); + const a = await hello('acct', { name: 'not Ann', + session: ann.session }); + const wa = await a.next('welcome'); + + check(wa.identity?.name === 'Ann' && wa.identity.account === true && + wa.peers[0].name === 'Ann' && wa.peers[0].account === true, + 'a hello with a session plays under the account\'s handle'); + + const g = await hello('acct', { name: 'Gus' }); + const [wg, joined] = await Promise.all([g.next('welcome'), + a.next('joined')]); + + check(wg.identity.name === 'Gus' && wg.identity.account === false && + joined.name === 'Gus' && joined.account === false && + wg.peers.find((p) => p.peer === wa.peer)?.account === true, + 'a guest is welcomed as one, and the room is told which is ' + + 'which'); + + g.send({ type: 'chat', channel: 'stage', text: 'hi', n: 1 }); + + const line = await a.next('chat'); + + check(line.name === 'Gus' && line.account === false, + 'a guest\'s chat line says it is a guest\'s'); + + /* A session the relay does not know is refused, and says why: + joining as a guest instead would leave somebody believing they + were logged in. */ + for (const [what, session] of [ + ['an unknown', `s_${'0'.repeat(32)}`], + ['a malformed', 'nonsense'], + ['a logged-out', (await post('login', { key: ann.key })).session]]) + { + if (what === 'a logged-out') + await post('logout', undefined, session); + + const c = await hello('acct', { name: 'Ann', session }); + const e = await c.next('error'); + + check(e.why === 'session' && /log in again/.test(e.text) && + await refused(c.ws), + `${what} session is refused with a reason`); + } + + /* A guest may not go by a handle, folded however. */ + for (const name of ['Ann', 'ANN', ' \uFF41nn ']) + { + const c = await hello('acct', { name }); + const e = await c.next('error'); + + check(e.why === 'name' && await refused(c.ws), + `a guest named ${JSON.stringify(name)} is refused`); + } + + /* Document sockets: in with the room's ticket, and refused without + one, with another room's, or with one that has lapsed. */ + const other = await hello('elsewhere', { name: 'Oz' }); + const { ticket: otherTicket } = await other.next('welcome'); + const open = docSocket('acct', wa.ticket); + + check(await new Promise((r) => + { + open.on('open', () => r(true)); + open.on('error', () => r(false)); + }), 'a document socket with its room\'s ticket is let in'); + + for (const [what, ticket] of [['no', undefined], + ['a made-up', `t_${'0'.repeat(32)}`], + ['another room\'s', otherTicket]]) + check(await refused(docSocket('acct', ticket)), + `a document socket with ${what} ticket is refused`); + + await new Promise((r) => setTimeout(r, 1100)); + + const fresh = a.got.filter((m) => m.type === 'ticket').at(-1); + + check(await refused(docSocket('acct', wa.ticket)), + 'a document socket with a lapsed ticket is refused'); + check(!await refused(docSocket('acct', fresh.ticket), 300), + 'and the one handed out before it lapsed lets it in'); + + /* Sessions ended over HTTP close the sockets made with them. */ + const opened = await new Promise((r) => + { + const d = docSocket('acct', fresh.ticket); + + d.on('open', () => r(d)); + }); + + const docGone = refused(opened); + + await post('logout', undefined, ann.session); + + const ended = await a.next('error'); + + check(ended.why === 'session' && await refused(a.ws) && + await docGone, + 'logging out closes the room socket made with the session, ' + + 'and its document sockets'); + check(!await refused(g.ws, 300), 'and leaves the guest\'s alone'); + + /* And ended from another process -- the admin commands -- within a + check of the relay's. */ + const bo = await post('register', { handle: 'Bo' }); + const b = await hello('acct', { session: bo.session }); + + await b.next('welcome'); + + const store = new AccountStore(db); + + runAdmin(['ban', 'bo'], store, () => {}); + store.close(); + + const banned = await b.next('error', 2000); + + check(banned.why === 'session' && await refused(b.ws), + 'a ban from the admin commands closes the account\'s sockets'); + + const back = await post('login', { key: bo.key }); + + check(back.error === 'banned', 'and its key no longer logs in'); + + check((await (await fetch(`http://${at}/`)).json()).accounts === true, + 'a relay with a page origin offers accounts'); + + /* A document socket to a room nobody has opened, with no ticket: + refused, and the relay still here. */ + check(await refused(docSocket('nosuchroom')) && + (await fetch(`http://${at}/`)).ok, + 'a document socket for a room that is not there is refused, ' + + 'and the relay lives'); + + /* A cursor's name is the relay's to say: the room socket's name, + a guest's marked as one, whatever the page set. And no socket + speaks for a client another one does. */ + { + const cy = await post('register', { handle: 'Cy' }); + const c = await hello('cursors', { session: cy.session }); + const { ticket: ct } = await c.next('welcome'); + const h = await hello('cursors', { name: 'Hob' }); + const { ticket: ht } = await h.next('welcome'); + const docs = []; + const provider = (ticket) => + { + const d = new Y.Doc(); + /* No BroadcastChannel: in one process it would hand the + pages' own states to each other past the relay. */ + const p = new WebsocketProvider(`ws://${at}/doc`, 'cursors', d, + { WebSocketPolyfill: WebSocket, + params: { ticket }, + disableBc: true }); + + docs.push([p, d]); + return p; + }; + const pc = provider(ct); + const ph = provider(ht); + const watcher = provider(ct); + const names = () => [...watcher.awareness.getStates().values()] + .filter((st) => st.user !== undefined) + .map((st) => `${st.user.name}/${st.user.account}`).sort() + .join(' '); + + await Promise.all([pc, ph, watcher].map((p) => new Promise((r) => + p.synced ? r() : p.once('synced', r)))); + pc.awareness.setLocalStateField('user', { + name: 'Admin', color: 'red;background:url(//x)', + colorLight: 'hsl(10 70% 45% / 0.25)' }); + ph.awareness.setLocalStateField('user', { name: 'Cy' }); + await new Promise((r) => setTimeout(r, 300)); + + check(names() === 'Cy/true Hob (guest)/false', + `a cursor goes by its room socket's name (${names()})`); + + const shown = [...watcher.awareness.getStates().values()] + .find((st) => st.user?.name === 'Cy').user; + + check(!('color' in shown) && + shown.colorLight === 'hsl(10 70% 45% / 0.25)', + 'and its colors only in the shape the page draws them'); + + /* The guest's socket, sending the account's client as its + own. */ + const raw = docSocket('cursors', ht); + + await new Promise((r) => raw.on('open', r)); + + const id = pc.awareness.clientID; + const update = encoding.createEncoder(); + const enc = encoding.createEncoder(); + + encoding.writeVarUint(update, 1); + encoding.writeVarUint(update, id); + encoding.writeVarUint(update, + pc.awareness.meta.get(id).clock + 1); + encoding.writeVarString(update, + JSON.stringify({ user: { name: 'X' } })); + encoding.writeVarUint(enc, 1); + encoding.writeVarUint8Array(enc, encoding.toUint8Array(update)); + raw.send(encoding.toUint8Array(enc)); + await new Promise((r) => setTimeout(r, 300)); + + check(names() === 'Cy/true Hob (guest)/false', + 'and one socket cannot speak for another\'s cursor'); + + raw.close(); + + /* An update of ids made up, `entries' at a time. */ + const states = (entries) => + { + const u = encoding.createEncoder(); + const e = encoding.createEncoder(); + + encoding.writeVarUint(u, entries.length); + + for (const [client, state] of entries) + { + encoding.writeVarUint(u, client); + encoding.writeVarUint(u, 1); + encoding.writeVarString(u, JSON.stringify(state)); + } + + encoding.writeVarUint(e, 1); + encoding.writeVarUint8Array(e, encoding.toUint8Array(u)); + return encoding.toUint8Array(e); + }; + const opened = async () => + { + const s = docSocket('cursors', h.got.filter( + (m) => m.type === 'ticket').at(-1)?.ticket ?? ht); + + await new Promise((r) => s.on('open', r)); + return s; + }; + + /* A state with no user on it is still the relay's to name. */ + const bare = await opened(); + + bare.send(states([[4242, {}]])); + await new Promise((r) => setTimeout(r, 300)); + + const named = watcher.awareness.getStates().get(4242)?.user; + + check(named?.name === 'Hob (guest)' && named.account === false, + 'a cursor that names nobody is named by the relay'); + bare.close(); + + /* A page is one client: a socket claiming three, in one update + or one after another, is cut, and none of them is kept. */ + for (const [what, frames] of [ + ['in one update', [[[5001, {}], [5002, {}], [5003, {}]]]], + ['one at a time', [[[6001, {}]], [[6002, {}]], [[6003, {}]]]]]) + { + const s = await opened(); + const cut = refused(s); + + for (const f of frames) + s.send(states(f)); + + check(await cut && !watcher.awareness.getStates().has(5003) && + !watcher.awareness.getStates().has(6003), + `a socket claiming three clients ${what} is cut`); + } + + for (const [p, d] of docs) + { + p.destroy(); + p.awareness.destroy(); + d.destroy(); + } + + c.close(); + h.close(); + } + + /* A page's document socket reconnecting takes its cursor over + from the one it replaces, which nothing has yet found dead; and + one that stops answering pings is cut, cursor and all. */ + { + const jo = await post('register', { handle: 'Jo' }); + const j = await hello('takeover', { session: jo.session }); + const { ticket: jt } = await j.next('welcome'); + const w = await hello('takeover', { name: 'Wes' }); + const { ticket: wt } = await w.next('welcome'); + const opts = (ticket) => ({ WebSocketPolyfill: WebSocket, + params: { ticket }, disableBc: true }); + const dj = new Y.Doc(); + const dw = new Y.Doc(); + const pj = new WebsocketProvider(`ws://${at}/doc`, 'takeover', dj, + opts(jt)); + const pw = new WebsocketProvider(`ws://${at}/doc`, 'takeover', dw, + opts(wt)); + + await Promise.all([pj, pw].map((p) => new Promise((r) => + p.synced ? r() : p.once('synced', r)))); + pj.awareness.setLocalStateField('user', { name: 'Jo' }); + await new Promise((r) => setTimeout(r, 200)); + + const id = pj.awareness.clientID; + const seen = () => pw.awareness.getStates().get(id)?.user; + const again = docSocket('takeover', jt); + const update = encoding.createEncoder(); + const enc = encoding.createEncoder(); + + /* The old socket as a dead one is: hearing nothing, so that + the page behind it does not answer for its own client. */ + await new Promise((r) => again.on('open', r)); + pj.ws._socket.pause(); + encoding.writeVarUint(update, 1); + encoding.writeVarUint(update, id); + encoding.writeVarUint(update, pj.awareness.meta.get(id).clock + 1); + encoding.writeVarString(update, JSON.stringify( + { user: { name: 'Jo', at: 'again' } })); + encoding.writeVarUint(enc, 1); + encoding.writeVarUint8Array(enc, encoding.toUint8Array(update)); + again.send(encoding.toUint8Array(enc)); + await new Promise((r) => setTimeout(r, 200)); + + const taken = seen()?.at === 'again'; + + /* And the old one dies as dead sockets do: no last word. */ + pj.shouldConnect = false; + pj.ws._socket.destroy(); + await new Promise((r) => setTimeout(r, 200)); + + check(taken && seen()?.at === 'again', + 'a document socket of the same peer takes its cursor ' + + 'over, ' + + 'and keeps it when the old one closes'); + + again._socket.pause(); + await new Promise((r) => setTimeout(r, 900)); + + check(seen() === undefined, + 'and a socket that stops answering pings is cut, and its ' + + 'cursor goes with it'); + + /* Four document sockets a peer -- the provider's and three + more -- and a fifth is refused. */ + const live = w.got.filter((m) => m.type === 'ticket').at(-1) + ?.ticket ?? wt; + const four = [1, 2, 3].map(() => docSocket('takeover', live)); + + await Promise.all(four.map((s) => new Promise((r) => + s.on('open', r)))); + check(await refused(docSocket('takeover', live)), + 'a fifth document socket for one peer is refused'); + + for (const s of four) + s.close(); + + pj.destroy(); + pw.destroy(); + pj.awareness.destroy(); + pw.awareness.destroy(); + j.close(); + w.close(); + } + + /* A frame past a megabyte on a room socket closes it. */ + { + const big = await hello('acct', { name: 'Big' }); + + await big.next('welcome'); + big.send({ type: 'chat', channel: 'stage', + text: 'x'.repeat(2 * 1024 * 1024), n: 1 }); + check(await refused(big.ws), 'a room frame over a MiB is refused'); + } + + /* Closing is final. A hello that is refused, then another at once + before the close completes, joins nobody; and a session that + ends leaves the room at once, its socket heard no more. */ + { + const ghost = new Client(`ws://${at}/room/acct`, 'Ghost'); + + await ghost.open(); + ghost.send({ type: 'hello', protocol: PROTOCOL, tickets: true, + session: `s_${'1'.repeat(32)}` }); + ghost.send({ type: 'hello', protocol: PROTOCOL, tickets: true, + name: 'Ghost' }); + + const heard = async (pred, ms) => + { + await new Promise((r) => setTimeout(r, ms)); + return g.got.some(pred); + }; + + check(!await heard((m) => m.type === 'joined' && + m.name === 'Ghost', 400), + 'a second hello after a refused one joins nobody'); + + const kim = await post('register', { handle: 'Kim' }); + const k = await hello('acct', { session: kim.session }); + const { peer } = await k.next('welcome'); + + k.ws._socket.pause(); + await post('logout', undefined, kim.session); + + const left = await heard((m) => m.type === 'left' && + m.peer === peer, 100); + + k.send({ type: 'chat', channel: 'stage', text: 'still here', + n: 9 }); + + check(left && !await heard((m) => m.type === 'chat' && + m.text === 'still here', 400), + 'an ended session leaves the room at once, and is not ' + + 'heard after'); + k.ws.terminate(); + } + + /* Ended sessions take their tickets at once, not when a client + that has stopped answering lets the close complete. */ + { + const dee = await post('register', { handle: 'Dee' }); + const d = await hello('acct', { session: dee.session }); + const { ticket: dt } = await d.next('welcome'); + + d.ws._socket.pause(); + await post('logout', undefined, dee.session); + + check(await refused(docSocket('acct', dt), 1000), + 'a logged-out session\'s ticket is refused at once'); + + d.ws.terminate(); + } + + for (const c of [g, other]) + c.close(); + + /* The same file under a relay without accounts: the handles in it + hold no names, since nobody there can be the account. */ + const off = await relay({ port: 0, host: '127.0.0.1', tree, db }); + + try + { + const k = new Client( + `ws://127.0.0.1:${off.address().port}/room/acct`, 'Kim'); + + await k.open(); + k.send({ type: 'hello', name: 'Kim', protocol: PROTOCOL, + tickets: true }); + + const w = await k.next('welcome'); + + check(w.identity.name === 'Kim' && w.identity.account === null, + 'without accounts a guest may go by a handle the file ' + + 'still has'); + k.close(); + } + finally + { + off.shutdown(); + } + } + finally + { + acct.shutdown(); + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +/* CORS_ORIGIN is an origin or nothing: the relay will not start on one + with a path, which no request's Origin would ever match, or on `*'. */ +for (const [value, status] of [['https://page.example.org', null], + ['*', 2], + ['https://page.example.org/', 2], + ['https://page.example.org/jam', 2], + ['page.example.org', 2]]) +{ + const r = spawnSync(process.execPath, + [path.join(here, 'relay.mjs'), '--port', '0'], + { env: { ...process.env, CORS_ORIGIN: value, + DB: ':memory:' }, + timeout: 1500, encoding: 'utf8' }); + + check(r.status === status, + `the relay ${status === 2 ? 'refuses' : 'takes'} CORS_ORIGIN ` + + `${value} (${r.status ?? r.signal})`); +} + const server = await relay({ port: 0, host: '127.0.0.1', tree }); const port = server.address().port; const base = `ws://127.0.0.1:${port}`; @@ -156,7 +694,7 @@ try const a = new Client(`${base}/room/test?piece=airports.gen`, 'A'); await a.open(); - a.send({ type: 'hello', name: 'Ann', protocol: PROTOCOL }); + a.send({ type: 'hello', name: 'Ann', protocol: PROTOCOL, tickets: true }); const wa = await a.next('welcome'); @@ -168,7 +706,7 @@ try const b = new Client(`${base}/room/test`, 'B'); await b.open(); - b.send({ type: 'hello', name: 'Bo', protocol: PROTOCOL }); + b.send({ type: 'hello', name: 'Bo', protocol: PROTOCOL, tickets: true }); const wb = await b.next('welcome'); const ja = await a.next('joined'); @@ -176,12 +714,52 @@ try check(wb.peers.length === 2 && ja.peer === wb.peer && ja.name === 'Bo', 'a second peer is told who is here, and the first is told'); - const listed = (await (await fetch(`http://127.0.0.1:${port}/`)).json()) - .rooms.find((r) => r.name === 'test'); + const health = await (await fetch(`http://127.0.0.1:${port}/`)).json(); + const listed = health.rooms.find((r) => r.name === 'test'); check(listed?.peers === 2 && listed.piece === 'airports.gen' && listed.playing === false, 'the health line lists the room, its two people and its piece'); + check(health.accounts === false, + 'and offers no accounts with no page origin to serve them to'); + + /* Nor any routes for them, from a page or not, and a hello's session + is nothing it knows. */ + { + const r = await fetch( + `http://127.0.0.1:${port}/api/account/register`, + { method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ handle: 'Curl' }) }); + const s = new Client(`${base}/room/nosessions`, 'S'); + + await s.open(); + s.send({ type: 'hello', name: 'Sid', protocol: PROTOCOL, + tickets: true, session: `s_${'2'.repeat(32)}` }); + + const w = await s.next('welcome'); + + check(r.status === 404 && w.identity.account === null && + w.identity.name === 'Sid' && + w.peers.every((p) => p.account === null), + 'without accounts the routes are not there, a session in a ' + + 'hello joins as anyone does, and nobody is marked a guest'); + s.close(); + } + + /* A page from before tickets is told to reload, not let in to wait on + a document it cannot open. */ + { + const old = new Client(`${base}/room/test`, 'Old'); + + await old.open(); + old.send({ type: 'hello', name: 'Old', protocol: PROTOCOL }); + + const e = await old.next('error'); + + check(/older than the relay: press Update above/.test(e.text) && + await refused(old.ws), + 'a hello without tickets is told the page is old'); + } /* Seats: first claim wins. */ a.send({ type: 'seat', seat: 0 }); @@ -252,7 +830,7 @@ try const c = new Client(`${base}/room/test`, 'C'); await c.open(); - c.send({ type: 'hello', name: 'Cy', protocol: PROTOCOL }); + c.send({ type: 'hello', name: 'Cy', protocol: PROTOCOL, tickets: true }); const wc = await c.next('welcome'); @@ -372,12 +950,21 @@ try /* ---- the document socket ---- */ + /* Let in by a ticket the room socket's welcome hands out. */ + const k = new Client(`${base}/room/test`, 'K'); + + await k.open(); + k.send({ type: 'hello', name: 'Kim', protocol: PROTOCOL, tickets: true }); + + const { ticket } = await k.next('welcome'); const docA = new Y.Doc(); const docB = new Y.Doc(); const provA = new WebsocketProvider(base + '/doc', 'test', docA, - { WebSocketPolyfill: WebSocket }); + { WebSocketPolyfill: WebSocket, + params: { ticket } }); const provB = new WebsocketProvider(base + '/doc', 'test', docB, - { WebSocketPolyfill: WebSocket }); + { WebSocketPolyfill: WebSocket, + params: { ticket } }); const synced = (p) => new Promise((r) => p.synced ? r() : p.once('synced', r)); @@ -427,8 +1014,10 @@ try const g = new Client(`${base}/room/test`, 'G'); await Promise.all([f.open(), g.open()]); - f.send({ type: 'hello', name: 'Fay', protocol: PROTOCOL }); - g.send({ type: 'hello', name: 'Gil', protocol: PROTOCOL }); + f.send({ type: 'hello', name: 'Fay', protocol: PROTOCOL, + tickets: true }); + g.send({ type: 'hello', name: 'Gil', protocol: PROTOCOL, + tickets: true }); const wf = await f.next('welcome'); @@ -507,6 +1096,73 @@ try g.close(); } + /* What a run keeps for a late joiner is bounded in bytes, by the + command and in all, past which it is a run that cannot be caught up + with; and who made a start or a command is the relay's to say. */ + { + const f = new Client(`${base}/room/logcap`, 'F'); + const g = new Client(`${base}/room/logcap`, 'G'); + + await Promise.all([f.open(), g.open()]); + f.send({ type: 'hello', name: 'Fay', protocol: PROTOCOL, + tickets: true }); + + const wf = await f.next('welcome'); + + g.send({ type: 'hello', name: 'Gil', protocol: PROTOCOL, + tickets: true }); + await g.next('welcome'); + + const hash = await hashOf(server.rooms.get('logcap').doc); + const caughtUp = async (seq, log) => + { + f.send({ type: 'transport', + data: { type: 'transport', op: 'start', origin: 1, + piece: { hash }, seed: 1, from: 'forged', + seq, at: -1 } }); + + for (const data of log) + f.send({ type: 'log', data, run: `${wf.peer}#${seq}` }); + + while (f.ws.bufferedAmount > 0) + await new Promise((r) => setTimeout(r, 50)); + + await new Promise((r) => setTimeout(r, 500)); + g.send({ type: 'catchup' }); + return g.next('catchup', 10000); + }; + const edit = (seq, kib) => ({ type: 'edit', at: -1, from: 'forged', + seq, text: 'x'.repeat(kib * 1024) }); + const small = await caughtUp(0, [edit(1, 1)]); + + check(small.start?.from === wf.peer && + small.log?.[0]?.from === wf.peer && !small.overflowed, + 'a start and a command are the sender\'s, whoever they say ' + + 'made them'); + + const one = await caughtUp(2, [edit(3, 600)]); + + check(one.overflowed === true && one.log.length === 0, + 'a command too big to keep makes the run one that cannot be ' + + 'caught up with'); + + const wide = await caughtUp(140, [{ ...edit(141, 0), + text: '\u00e9'.repeat(300 * 1024) }]); + + check(wide.overflowed === true, + 'a command is measured in the bytes it is sent as, not ' + + 'its characters'); + + const many = await caughtUp(4, Array.from({ length: 66 }, + (_, i) => edit(5 + i, 510))); + + check(many.overflowed === true && many.log.length < 66, + 'and so do more commands than the run keeps bytes for'); + + f.close(); + g.close(); + } + /* Chat: to everyone in the room, its sender included, under the name the relay knows the sender by; to nobody in another room; and kept for nobody who arrives later. */ @@ -516,9 +1172,12 @@ try const o = new Client(`${base}/room/elsewhere`, 'O'); await Promise.all([h.open(), i.open(), o.open()]); - h.send({ type: 'hello', name: 'Hal', protocol: PROTOCOL }); - i.send({ type: 'hello', name: 'Ida', protocol: PROTOCOL }); - o.send({ type: 'hello', name: 'Oz', protocol: PROTOCOL }); + h.send({ type: 'hello', name: 'Hal', protocol: PROTOCOL, + tickets: true }); + i.send({ type: 'hello', name: 'Ida', protocol: PROTOCOL, + tickets: true }); + o.send({ type: 'hello', name: 'Oz', protocol: PROTOCOL, + tickets: true }); const wh = await h.next('welcome'); @@ -585,7 +1244,8 @@ try const late = new Client(`${base}/room/chat`, 'L'); await late.open(); - late.send({ type: 'hello', name: 'Lou', protocol: PROTOCOL }); + late.send({ type: 'hello', name: 'Lou', protocol: PROTOCOL, + tickets: true }); await late.next('welcome'); check(await late.none('chat'), 'and a peer who arrives later is handed none of it'); @@ -599,19 +1259,22 @@ try each told to everyone with who made it and the revision it left, and the document is the last one's piece and nothing else. */ { - const docS = new Y.Doc(); - const provS = new WebsocketProvider(base + '/doc', 'switch', docS, - { WebSocketPolyfill: WebSocket }); const s = new Client(`${base}/room/switch`, 'S'); const t = new Client(`${base}/room/switch`, 'T'); - await Promise.all([s.open(), t.open(), synced(provS)]); - s.send({ type: 'hello', name: 'Sue', protocol: PROTOCOL }); - t.send({ type: 'hello', name: 'Tom', protocol: PROTOCOL }); + await Promise.all([s.open(), t.open()]); + s.send({ type: 'hello', name: 'Sue', protocol: PROTOCOL, + tickets: true }); + t.send({ type: 'hello', name: 'Tom', protocol: PROTOCOL, + tickets: true }); const ws = await s.next('welcome'); + const docS = new Y.Doc(); + const provS = new WebsocketProvider(base + '/doc', 'switch', docS, + { WebSocketPolyfill: WebSocket, + params: { ticket: ws.ticket } }); - await t.next('welcome'); + await Promise.all([t.next('welcome'), synced(provS)]); const seeded = await hashOf(docS); @@ -685,7 +1348,8 @@ try const u = new Client(`${base}/room/switch`, 'U'); await u.open(); - u.send({ type: 'hello', name: 'Una', protocol: PROTOCOL }); + u.send({ type: 'hello', name: 'Una', protocol: PROTOCOL, + tickets: true }); const wu = await u.next('welcome'); @@ -795,11 +1459,11 @@ try u.close(); - s.close(); - t.close(); provS.destroy(); provS.awareness.destroy(); docS.destroy(); + s.close(); + t.close(); } /* Awareness: a cursor set on one is seen on the other. */ @@ -823,7 +1487,7 @@ try ['a garbage', new Uint8Array([255, 255, 255, 255, 255])]]) { - const bad = new WebSocket(`${base}/doc/test`); + const bad = new WebSocket(`${base}/doc/test?ticket=${ticket}`); await new Promise((r) => bad.on('open', r)); bad.send(bytes); @@ -843,7 +1507,8 @@ try const e = new Client(`${base}/room/test`, 'E'); await e.open(); - e.send({ type: 'hello', name: 'Eve', protocol: PROTOCOL }); + e.send({ type: 'hello', name: 'Eve', protocol: PROTOCOL, + tickets: true }); const we = await e.next('welcome'); @@ -863,6 +1528,12 @@ try p.awareness.destroy(); d.destroy(); } + + k.close(); + + /* ---- accounts ---- */ + + await accountsInRooms(); } catch (e) { diff --git a/wasm/web/room.js b/wasm/web/room.js index ed1dcdd3..e77b4c42 100644 --- a/wasm/web/room.js +++ b/wasm/web/room.js @@ -26,6 +26,7 @@ * given. */ +import { shownName } from './account.js'; import { RelayClock } from './clock.js'; export const PROTOCOL = 1; @@ -41,19 +42,27 @@ const CATCHUP_WAIT = 15 * 1000; export class Room { - /* `url' is the relay, ws://host:port; `name' is what the others see. - `now' is the wall clock the offset is kept against -- the page's - performance.now, or a harness's. */ + /* `url' is the relay, ws://host:port; `name' is what the others see + of a guest, and `session' an account's, which plays under its + handle instead. `now' is the wall clock the offset is kept against + -- the page's performance.now, or a harness's. */ constructor (url, roomName, name, - { now = () => performance.now(), piece = null } = {}) + { now = () => performance.now(), piece = null, + session = null } = {}) { this.url = url; this.roomName = roomName; this.name = name; + this.session = session; this.now = now; this.piece = piece; /* what a new room is seeded with */ this.peer = null; /* our id, from the welcome */ - this.peers = new Map(); /* id -> { name, seat } */ + this.identity = null; /* { name, account }, likewise */ + this.ticket = null; /* the document socket's way in */ + + /* id -> { name, seat, account }, the name as the room shows it + (account.js, shownName). */ + this.peers = new Map(); this.playing = null; /* the last transport start */ this.clock = new RelayClock(); this.handlers = new Map(); @@ -83,13 +92,17 @@ export class Room (this.piece ? `?piece=${this.piece}` : '')); let welcomed = false; - let refused = null; /* the relay's last word, if it said one */ + + /* The relay's last word, if it said one: { text, why }. */ + let refused = null; this.ws = ws; ws.addEventListener('open', () => this.send({ type: 'hello', name: this.name, - protocol: PROTOCOL })); + protocol: PROTOCOL, tickets: true, + ...(this.session === null + ? {} : { session: this.session }) })); ws.addEventListener('error', () => reject(new Error(`could not reach the relay at ${this.url}`))); @@ -104,14 +117,15 @@ export class Room clean close fires no error event. Without this the join would await a promise that never settles. */ if (!welcomed) - reject(new Error( - refused ?? `the relay at ${this.url} closed the ` + - 'connection before welcoming us')); + reject(Object.assign(new Error( + refused?.text ?? `the relay at ${this.url} closed ` + + 'the connection before welcoming us'), + { why: refused?.why })); for (const c of this.catchups.splice(0)) c.reject(new Error('the relay closed the connection')); - this.emit('close'); + this.emit('close', refused); }); ws.addEventListener('message', (e) => @@ -132,11 +146,14 @@ export class Room case 'welcome': welcomed = true; this.peer = m.peer; + this.identity = m.identity ?? { name: this.name }; + this.ticket = m.ticket ?? null; this.peers.clear(); for (const p of m.peers) - this.peers.set(p.peer, { name: p.name, - seat: p.seat }); + this.peers.set(p.peer, { name: shownName(p), + seat: p.seat, + account: p.account }); this.playing = m.playing; this.features = m.features ?? []; @@ -148,7 +165,9 @@ export class Room break; case 'joined': - this.peers.set(m.peer, { name: m.name, seat: null }); + this.peers.set(m.peer, { name: shownName(m), + seat: null, + account: m.account }); this.emit('peers'); this.emit('joined', m.peer); break; @@ -200,7 +219,14 @@ export class Room break; case 'chat': - this.emit('chat', m); + this.emit('chat', { ...m, name: shownName(m) }); + break; + + /* A new ticket before the last one lapses, for the + document socket's next reconnect. */ + case 'ticket': + this.ticket = m.ticket; + this.emit('ticket', m.ticket); break; case 'refused': @@ -208,7 +234,7 @@ export class Room break; case 'switched': - this.emit('switched', m); + this.emit('switched', { ...m, name: shownName(m) }); break; case 'catchup': @@ -217,7 +243,7 @@ export class Room break; case 'error': - refused = m.text; + refused = { text: m.text, why: m.why }; this.emit('error', m.text); break; } diff --git a/wasm/web/style.css b/wasm/web/style.css index bf667de2..c609ab6b 100644 --- a/wasm/web/style.css +++ b/wasm/web/style.css @@ -875,6 +875,48 @@ body:has(#joinrow:not([hidden])) #roomhead #joinrow > .roomlist { margin-bottom: 0.4em; } #join { align-self: flex-end; } +#account { align-self: flex-start; } + +/* The account dialog over the join card (accountui.js). */ +#accountdialog +{ + width: min(calc(100vw - 2em), 28em); + box-sizing: border-box; + padding: 1em 1.2em; + background: var(--panel); + color: var(--fg); + border: 1px solid var(--line); + border-radius: 6px; +} + +#accountdialog::backdrop { background: rgb(0 0 0 / 0.45); } +#accountdialog .accounthead { display: flex; justify-content: space-between; } +#accountdialog .accounttitle { font-weight: bold; font-size: 1.2em; } +#accountclose +{ + background: none; + border: 0; + color: var(--fg); + font-size: 1.4em; +} + +#accountdialog .accountbody, +#accountdialog section, +#accountdialog form { display: flex; flex-direction: column; gap: 0.6em; } + +#accountdialog .accountbody { gap: 1em; } +#accountdialog h2 { margin: 0; font-size: 1em; } +#accountdialog p { margin: 0; } +#accountdialog .accountwarn { color: var(--warn); } + +#accountdialog label +{ + display: grid; + grid-template-columns: 4em 1fr; + align-items: center; +} + +#accountdialog button { align-self: flex-start; } /* The controls, grouped: the transport, then where your keys go. */ #roomtools { margin: 0 0 0.4em; } diff --git a/wasm/web/wordlist.mjs b/wasm/web/wordlist.mjs new file mode 100644 index 00000000..3c763721 --- /dev/null +++ b/wasm/web/wordlist.mjs @@ -0,0 +1,879 @@ +/* + * Copyright (C) 2004-2026 Metaphonic Labs + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by the + * Free Software Foundation; either version 2 of the License, or (at your + * option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General + * Public License along with this program; if not, write to the + * Free Software Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ + +/* + * wordlist.mjs -- the words account keys are made of: the EFF long + * wordlist (https://www.eff.org/dice), without its four hyphenated words, + * so a key's words can be joined, and split again, by hyphens. 7,772 + * words; eight of them make a key of about 103 bits. + * + * The list is by the Electronic Frontier Foundation, published under the + * Creative Commons Attribution 3.0 United States license + * (https://creativecommons.org/licenses/by/3.0/us/). + */ + +/* The words, in the list's own (alphabetical) order. */ +export const KEY_WORDS = ` +abacus abdomen abdominal abide abiding ability ablaze able abnormal abrasion +abrasive abreast abridge abroad abruptly absence absentee absently absinthe +absolute absolve abstain abstract absurd accent acclaim acclimate accompany +account accuracy accurate accustom acetone achiness aching acid acorn acquaint +acquire acre acrobat acronym acting action activate activator active activism +activist activity actress acts acutely acuteness aeration aerobics aerosol +aerospace afar affair affected affecting affection affidavit affiliate affirm +affix afflicted affluent afford affront aflame afloat aflutter afoot afraid +afterglow afterlife aftermath aftermost afternoon aged ageless agency agenda +agent aggregate aghast agile agility aging agnostic agonize agonizing agony +agreeable agreeably agreed agreeing agreement aground ahead ahoy aide aids aim +ajar alabaster alarm albatross album alfalfa algebra algorithm alias alibi +alienable alienate aliens alike alive alkaline alkalize almanac almighty +almost aloe aloft aloha alone alongside aloof alphabet alright although +altitude alto aluminum alumni always amaretto amaze amazingly amber ambiance +ambiguity ambiguous ambition ambitious ambulance ambush amendable amendment +amends amenity amiable amicably amid amigo amino amiss ammonia ammonium +amnesty amniotic among amount amperage ample amplifier amplify amply amuck +amulet amusable amused amusement amuser amusing anaconda anaerobic anagram +anatomist anatomy anchor anchovy ancient android anemia anemic aneurism +anew angelfish angelic anger angled angler angles angling angrily angriness +anguished angular animal animate animating animation animator anime animosity +ankle annex annotate announcer annoying annually annuity anointer another +answering antacid antarctic anteater antelope antennae anthem anthill +anthology antibody antics antidote antihero antiquely antiques antiquity +antirust antitoxic antitrust antiviral antivirus antler antonym antsy anvil +anybody anyhow anymore anyone anyplace anything anytime anyway anywhere +aorta apache apostle appealing appear appease appeasing appendage appendix +appetite appetizer applaud applause apple appliance applicant applied apply +appointee appraisal appraiser apprehend approach approval approve apricot +april apron aptitude aptly aqua aqueduct arbitrary arbitrate ardently area +arena arguable arguably argue arise armadillo armband armchair armed armful +armhole arming armless armoire armored armory armrest army aroma arose +around arousal arrange array arrest arrival arrive arrogance arrogant arson +art ascend ascension ascent ascertain ashamed ashen ashes ashy aside askew +asleep asparagus aspect aspirate aspire aspirin astonish astound astride +astrology astronaut astronomy astute atlantic atlas atom atonable atop +atrium atrocious atrophy attach attain attempt attendant attendee attention +attentive attest attic attire attitude attractor attribute atypical auction +audacious audacity audible audibly audience audio audition augmented august +authentic author autism autistic autograph automaker automated automatic +autopilot available avalanche avatar avenge avenging avenue average aversion +avert aviation aviator avid avoid await awaken award aware awhile awkward +awning awoke awry axis babble babbling babied baboon backache backboard +backboned backdrop backed backer backfield backfire backhand backing +backlands backlash backless backlight backlit backlog backpack backpedal +backrest backroom backshift backside backslid backspace backspin backstab +backstage backtalk backtrack backup backward backwash backwater backyard +bacon bacteria bacterium badass badge badland badly badness baffle baffling +bagel bagful baggage bagged baggie bagginess bagging baggy bagpipe baguette +baked bakery bakeshop baking balance balancing balcony balmy balsamic +bamboo banana banish banister banjo bankable bankbook banked banker banking +banknote bankroll banner bannister banshee banter barbecue barbed barbell +barber barcode barge bargraph barista baritone barley barmaid barman barn +barometer barrack barracuda barrel barrette barricade barrier barstool +bartender barterer bash basically basics basil basin basis basket batboy +batch bath baton bats battalion battered battering battery batting battle +bauble bazooka blabber bladder blade blah blame blaming blanching blandness +blank blaspheme blasphemy blast blatancy blatantly blazer blazing bleach +bleak bleep blemish blend bless blighted blimp bling blinked blinker +blinking blinks blip blissful blitz blizzard bloated bloating blob blog +bloomers blooming blooper blot blouse blubber bluff bluish blunderer blunt +blurb blurred blurry blurt blush blustery boaster boastful boasting boat +bobbed bobbing bobble bobcat bobsled bobtail bodacious body bogged boggle +bogus boil bok bolster bolt bonanza bonded bonding bondless boned bonehead +boneless bonelike boney bonfire bonnet bonsai bonus bony boogeyman boogieman +book boondocks booted booth bootie booting bootlace bootleg boots boozy borax +boring borough borrower borrowing boss botanical botanist botany botch both +bottle bottling bottom bounce bouncing bouncy bounding boundless bountiful +bovine boxcar boxer boxing boxlike boxy breach breath breeches breeching +breeder breeding breeze breezy brethren brewery brewing briar bribe brick +bride bridged brigade bright brilliant brim bring brink brisket briskly +briskness bristle brittle broadband broadcast broaden broadly broadness +broadside broadways broiler broiling broken broker bronchial bronco bronze +bronzing brook broom brought browbeat brownnose browse browsing bruising +brunch brunette brunt brush brussels brute brutishly bubble bubbling bubbly +buccaneer bucked bucket buckle buckshot buckskin bucktooth buckwheat buddhism +buddhist budding buddy budget buffalo buffed buffer buffing buffoon buggy +bulb bulge bulginess bulgur bulk bulldog bulldozer bullfight bullfrog +bullhorn bullion bullish bullpen bullring bullseye bullwhip bully bunch +bundle bungee bunion bunkbed bunkhouse bunkmate bunny bunt busboy bush +busily busload bust busybody buzz cabana cabbage cabbie cabdriver cable +caboose cache cackle cacti cactus caddie caddy cadet cadillac cadmium +cage cahoots cake calamari calamity calcium calculate calculus caliber +calibrate calm caloric calorie calzone camcorder cameo camera camisole +camper campfire camping campsite campus canal canary cancel candied +candle candy cane canine canister cannabis canned canning cannon cannot +canola canon canopener canopy canteen canyon capable capably capacity +cape capillary capital capitol capped capricorn capsize capsule caption +captivate captive captivity capture caramel carat caravan carbon cardboard +carded cardiac cardigan cardinal cardstock carefully caregiver careless +caress caretaker cargo caring carless carload carmaker carnage carnation +carnival carnivore carol carpenter carpentry carpool carport carried +carrot carrousel carry cartel cartload carton cartoon cartridge cartwheel +carve carving carwash cascade case cash casing casino casket cassette +casually casualty catacomb catalog catalyst catalyze catapult cataract +catatonic catcall catchable catcher catching catchy caterer catering +catfight catfish cathedral cathouse catlike catnap catnip catsup cattail +cattishly cattle catty catwalk caucasian caucus causal causation cause +causing cauterize caution cautious cavalier cavalry caviar cavity cedar +celery celestial celibacy celibate celtic cement census ceramics ceremony +certainly certainty certified certify cesarean cesspool chafe chaffing +chain chair chalice challenge chamber chamomile champion chance change +channel chant chaos chaperone chaplain chapped chaps chapter character +charbroil charcoal charger charging chariot charity charm charred charter +charting chase chasing chaste chastise chastity chatroom chatter chatting +chatty cheating cheddar cheek cheer cheese cheesy chef chemicals chemist +chemo cherisher cherub chess chest chevron chevy chewable chewer chewing +chewy chief chihuahua childcare childhood childish childless childlike +chili chill chimp chip chirping chirpy chitchat chivalry chive chloride +chlorine choice chokehold choking chomp chooser choosing choosy chop chosen +chowder chowtime chrome chubby chuck chug chummy chump chunk churn chute +cider cilantro cinch cinema cinnamon circle circling circular circulate +circus citable citadel citation citizen citric citrus city civic civil clad +claim clambake clammy clamor clamp clamshell clang clanking clapped clapper +clapping clarify clarinet clarity clash clasp class clatter clause clavicle +claw clay clean clear cleat cleaver cleft clench clergyman clerical clerk +clever clicker client climate climatic cling clinic clinking clip clique +cloak clobber clock clone cloning closable closure clothes clothing cloud +clover clubbed clubbing clubhouse clump clumsily clumsy clunky clustered +clutch clutter coach coagulant coastal coaster coasting coastland coastline +coat coauthor cobalt cobbler cobweb cocoa coconut cod coeditor coerce coexist +coffee cofounder cognition cognitive cogwheel coherence coherent cohesive +coil coke cola cold coleslaw coliseum collage collapse collar collected +collector collide collie collision colonial colonist colonize colony colossal +colt coma come comfort comfy comic coming comma commence commend comment +commerce commode commodity commodore common commotion commute commuting +compacted compacter compactly compactor companion company compare compel +compile comply component composed composer composite compost composure +compound compress comprised computer computing comrade concave conceal +conceded concept concerned concert conch concierge concise conclude concrete +concur condense condiment condition condone conducive conductor conduit cone +confess confetti confidant confident confider confiding configure confined +confining confirm conflict conform confound confront confused confusing +confusion congenial congested congrats congress conical conjoined conjure +conjuror connected connector consensus consent console consoling consonant +constable constant constrain constrict construct consult consumer consuming +contact container contempt contend contented contently contents contest +context contort contour contrite control contusion convene convent copartner +cope copied copier copilot coping copious copper copy coral cork cornball +cornbread corncob cornea corned corner cornfield cornflake cornhusk cornmeal +cornstalk corny coronary coroner corporal corporate corral correct corridor +corrode corroding corrosive corsage corset cortex cosigner cosmetics cosmic +cosmos cosponsor cost cottage cotton couch cough could countable countdown +counting countless country county courier covenant cover coveted coveting +coyness cozily coziness cozy crabbing crabgrass crablike crabmeat cradle +cradling crafter craftily craftsman craftwork crafty cramp cranberry crane +cranial cranium crank crate crave craving crawfish crawlers crawling crayfish +crayon crazed crazily craziness crazy creamed creamer creamlike crease +creasing creatable create creation creative creature credible credibly +credit creed creme creole crepe crept crescent crested cresting crestless +crevice crewless crewman crewmate crib cricket cried crier crimp crimson +cringe cringing crinkle crinkly crisped crisping crisply crispness crispy +criteria critter croak crock crook croon crop cross crouch crouton crowbar +crowd crown crucial crudely crudeness cruelly cruelness cruelty crumb +crummiest crummy crumpet crumpled cruncher crunching crunchy crusader +crushable crushed crusher crushing crust crux crying cryptic crystal +cubbyhole cube cubical cubicle cucumber cuddle cuddly cufflink culinary +culminate culpable culprit cultivate cultural culture cupbearer cupcake +cupid cupped cupping curable curator curdle cure curfew curing curled +curler curliness curling curly curry curse cursive cursor curtain curtly +curtsy curvature curve curvy cushy cusp cussed custard custodian custody +customary customer customize customs cut cycle cyclic cycling cyclist +cylinder cymbal cytoplasm cytoplast dab dad daffodil dagger daily daintily +dainty dairy daisy dallying dance dancing dandelion dander dandruff dandy +danger dangle dangling daredevil dares daringly darkened darkening darkish +darkness darkroom darling darn dart darwinism dash dastardly data datebook +dating daughter daunting dawdler dawn daybed daybreak daycare daydream +daylight daylong dayroom daytime dazzler dazzling deacon deafening deafness +dealer dealing dealmaker dealt dean debatable debate debating debit debrief +debtless debtor debug debunk decade decaf decal decathlon decay deceased +deceit deceiver deceiving december decency decent deception deceptive decibel +decidable decimal decimeter decipher deck declared decline decode decompose +decorated decorator decoy decrease decree dedicate dedicator deduce deduct +deed deem deepen deeply deepness deface defacing defame default defeat +defection defective defendant defender defense defensive deferral deferred +defiance defiant defile defiling define definite deflate deflation deflator +deflected deflector defog deforest defraud defrost deftly defuse defy degraded +degrading degrease degree dehydrate deity dejected delay delegate delegator +delete deletion delicacy delicate delicious delighted delirious delirium +deliverer delivery delouse delta deluge delusion deluxe demanding demeaning +demeanor demise democracy democrat demote demotion demystify denatured +deniable denial denim denote dense density dental dentist denture deny +deodorant deodorize departed departure depict deplete depletion deplored +deploy deport depose depraved depravity deprecate depress deprive depth +deputize deputy derail deranged derby derived desecrate deserve deserving +designate designed designer designing deskbound desktop deskwork desolate +despair despise despite destiny destitute destruct detached detail detection +detective detector detention detergent detest detonate detonator detoxify +detract deuce devalue deviancy deviant deviate deviation deviator device +devious devotedly devotee devotion devourer devouring devoutly dexterity +dexterous diabetes diabetic diabolic diagnoses diagnosis diagram dial +diameter diaper diaphragm diary dice dicing dictate dictation dictator +difficult diffused diffuser diffusion diffusive dig dilation diligence +diligent dill dilute dime diminish dimly dimmed dimmer dimness dimple diner +dingbat dinghy dinginess dingo dingy dining dinner diocese dioxide diploma +dipped dipper dipping directed direction directive directly directory +direness dirtiness disabled disagree disallow disarm disarray disaster +disband disbelief disburse discard discern discharge disclose discolor +discount discourse discover discuss disdain disengage disfigure disgrace +dish disinfect disjoin disk dislike disliking dislocate dislodge disloyal +dismantle dismay dismiss dismount disobey disorder disown disparate disparity +dispatch dispense dispersal dispersed disperser displace display displease +disposal dispose disprove dispute disregard disrupt dissuade distance +distant distaste distill distinct distort distract distress district +distrust ditch ditto ditzy dividable divided dividend dividers dividing +divinely diving divinity divisible divisibly division divisive divorcee +dizziness dizzy doable docile dock doctrine document dodge dodgy doily +doing dole dollar dollhouse dollop dolly dolphin domain domelike domestic +dominion dominoes donated donation donator donor donut doodle doorbell +doorframe doorknob doorman doormat doornail doorpost doorstep doorstop +doorway doozy dork dormitory dorsal dosage dose dotted doubling douche +dove down dowry doze drab dragging dragonfly dragonish dragster drainable +drainage drained drainer drainpipe dramatic dramatize drank drapery drastic +draw dreaded dreadful dreadlock dreamboat dreamily dreamland dreamless +dreamlike dreamt dreamy drearily dreary drench dress drew dribble dried +drier drift driller drilling drinkable drinking dripping drippy drivable +driven driver driveway driving drizzle drizzly drone drool droop dropbox +dropkick droplet dropout dropper drove drown drowsily drudge drum dry dubbed +dubiously duchess duckbill ducking duckling ducktail ducky duct dude duffel +dugout duh duke duller dullness duly dumping dumpling dumpster duo dupe +duplex duplicate duplicity durable durably duration duress during dusk dust +dutiful duty duvet dwarf dweeb dwelled dweller dwelling dwindle dwindling +dynamic dynamite dynasty dyslexia dyslexic each eagle earache eardrum +earflap earful earlobe early earmark earmuff earphone earpiece earplugs +earring earshot earthen earthlike earthling earthly earthworm earthy earwig +easeful easel easiest easily easiness easing eastbound eastcoast easter +eastward eatable eaten eatery eating eats ebay ebony ebook ecard eccentric +echo eclair eclipse ecologist ecology economic economist economy ecosphere +ecosystem edge edginess edging edgy edition editor educated education +educator eel effective effects efficient effort eggbeater egging eggnog +eggplant eggshell egomaniac egotism egotistic either eject elaborate elastic +elated elbow eldercare elderly eldest electable election elective elephant +elevate elevating elevation elevator eleven elf eligible eligibly eliminate +elite elitism elixir elk ellipse elliptic elm elongated elope eloquence +eloquent elsewhere elude elusive elves email embargo embark embassy embattled +embellish ember embezzle emblaze emblem embody embolism emboss embroider +emcee emerald emergency emission emit emote emoticon emotion empathic empathy +emperor emphases emphasis emphasize emphatic empirical employed employee +employer emporium empower emptier emptiness empty emu enable enactment +enamel enchanted enchilada encircle enclose enclosure encode encore +encounter encourage encroach encrust encrypt endanger endeared endearing +ended ending endless endnote endocrine endorphin endorse endowment endpoint +endurable endurance enduring energetic energize energy enforced enforcer +engaged engaging engine engorge engraved engraver engraving engross engulf +enhance enigmatic enjoyable enjoyably enjoyer enjoying enjoyment enlarged +enlarging enlighten enlisted enquirer enrage enrich enroll enslave ensnare +ensure entail entangled entering entertain enticing entire entitle entity +entomb entourage entrap entree entrench entrust entryway entwine enunciate +envelope enviable enviably envious envision envoy envy enzyme epic epidemic +epidermal epidermis epidural epilepsy epileptic epilogue epiphany episode +equal equate equation equator equinox equipment equity equivocal eradicate +erasable erased eraser erasure ergonomic errand errant erratic error erupt +escalate escalator escapable escapade escapist escargot eskimo esophagus +espionage espresso esquire essay essence essential establish estate esteemed +estimate estimator estranged estrogen etching eternal eternity ethanol +ether ethically ethics euphemism evacuate evacuee evade evaluate evaluator +evaporate evasion evasive even everglade evergreen everybody everyday everyone +evict evidence evident evil evoke evolution evolve exact exalted example +excavate excavator exceeding exception excess exchange excitable exciting +exclaim exclude excluding exclusion exclusive excretion excretory excursion +excusable excusably excuse exemplary exemplify exemption exerciser exert +exes exfoliate exhale exhaust exhume exile existing exit exodus exonerate +exorcism exorcist expand expanse expansion expansive expectant expedited +expediter expel expend expenses expensive expert expire expiring explain +expletive explicit explode exploit explore exploring exponent exporter +exposable expose exposure express expulsion exquisite extended extending +extent extenuate exterior external extinct extortion extradite extras +extrovert extrude extruding exuberant fable fabric fabulous facebook +facecloth facedown faceless facelift faceplate faceted facial facility +facing facsimile faction factoid factor factsheet factual faculty fade +fading failing falcon fall false falsify fame familiar family famine +famished fanatic fancied fanciness fancy fanfare fang fanning fantasize +fantastic fantasy fascism fastball faster fasting fastness faucet favorable +favorably favored favoring favorite fax feast federal fedora feeble feed +feel feisty feline feminine feminism feminist feminize femur fence fencing +fender ferment fernlike ferocious ferocity ferret ferris ferry fervor +fester festival festive festivity fetal fetch fever fiber fiction fiddle +fiddling fidelity fidgeting fidgety fifteen fifth fiftieth fifty figment +figure figurine filing filled filler filling film filter filth filtrate +finale finalist finalize finally finance financial finch fineness finer +finicky finished finisher finishing finite finless finlike fiscally fit five +flaccid flagman flagpole flagship flagstick flagstone flail flakily flaky +flame flammable flanked flanking flannels flap flaring flashback flashbulb +flashcard flashily flashing flashy flask flatbed flatfoot flatly flatness +flatten flattered flatterer flattery flattop flatware flatworm flavored +flavorful flavoring flaxseed fled fleshed fleshy flick flier flight flinch +fling flint flip flirt float flock flogging flop floral florist floss +flounder flyable flyaway flyer flying flyover flypaper foam foe fog foil +folic folk follicle follow fondling fondly fondness fondue font food fool +footage football footbath footboard footer footgear foothill foothold +footing footless footman footnote footpad footpath footprint footrest +footsie footsore footwear footwork fossil foster founder founding fountain +fox foyer fraction fracture fragile fragility fragment fragrance fragrant +frail frame framing frantic fraternal frayed fraying frays freckled freckles +freebase freebee freebie freedom freefall freehand freeing freeload freely +freemason freeness freestyle freeware freeway freewill freezable freezing +freight french frenzied frenzy frequency frequent fresh fretful fretted +friction friday fridge fried friend frighten frightful frigidity frigidly +frill fringe frisbee frisk fritter frivolous frolic from front frostbite +frosted frostily frosting frostlike frosty froth frown frozen fructose +frugality frugally fruit frustrate frying gab gaffe gag gainfully gaining +gains gala gallantly galleria gallery galley gallon gallows gallstone +galore galvanize gambling game gaming gamma gander gangly gangrene gangway +gap garage garbage garden gargle garland garlic garment garnet garnish +garter gas gatherer gathering gating gauging gauntlet gauze gave gawk +gazing gear gecko geek geiger gem gender generic generous genetics genre +gentile gentleman gently gents geography geologic geologist geology geometric +geometry geranium gerbil geriatric germicide germinate germless germproof +gestate gestation gesture getaway getting getup giant gibberish giblet +giddily giddiness giddy gift gigabyte gigahertz gigantic giggle giggling +giggly gigolo gilled gills gimmick girdle giveaway given giver giving +gizmo gizzard glacial glacier glade gladiator gladly glamorous glamour +glance glancing glandular glare glaring glass glaucoma glazing gleaming +gleeful glider gliding glimmer glimpse glisten glitch glitter glitzy +gloater gloating gloomily gloomy glorified glorifier glorify glorious +glory gloss glove glowing glowworm glucose glue gluten glutinous glutton +gnarly gnat goal goatskin goes goggles going goldfish goldmine goldsmith +golf goliath gonad gondola gone gong good gooey goofball goofiness goofy +google goon gopher gore gorged gorgeous gory gosling gossip gothic gotten +gout gown grab graceful graceless gracious gradation graded grader gradient +grading gradually graduate graffiti grafted grafting grain granddad grandkid +grandly grandma grandpa grandson granite granny granola grant granular grape +graph grapple grappling grasp grass gratified gratify grating gratitude +gratuity gravel graveness graves graveyard gravitate gravity gravy gray +grazing greasily greedily greedless greedy green greeter greeting grew +greyhound grid grief grievance grieving grievous grill grimace grimacing +grime griminess grimy grinch grinning grip gristle grit groggily groggy +groin groom groove grooving groovy grope ground grouped grout grove grower +growing growl grub grudge grudging grueling gruffly grumble grumbling +grumbly grumpily grunge grunt guacamole guidable guidance guide guiding +guileless guise gulf gullible gully gulp gumball gumdrop gumminess gumming +gummy gurgle gurgling guru gush gusto gusty gutless guts gutter guy guzzler +gyration habitable habitant habitat habitual hacked hacker hacking hacksaw +had haggler haiku half halogen halt halved halves hamburger hamlet hammock +hamper hamster hamstring handbag handball handbook handbrake handcart +handclap handclasp handcraft handcuff handed handful handgrip handgun +handheld handiness handiwork handlebar handled handler handling handmade +handoff handpick handprint handrail handsaw handset handsfree handshake +handstand handwash handwork handwoven handwrite handyman hangnail hangout +hangover hangup hankering hankie hanky haphazard happening happier happiest +happily happiness happy harbor hardcopy hardcore hardcover harddisk hardened +hardener hardening hardhat hardhead hardiness hardly hardness hardship +hardware hardwired hardwood hardy harmful harmless harmonica harmonics +harmonize harmony harness harpist harsh harvest hash hassle haste hastily +hastiness hasty hatbox hatchback hatchery hatchet hatching hatchling hate +hatless hatred haunt haven hazard hazelnut hazily haziness hazing hazy +headache headband headboard headcount headdress headed header headfirst +headgear heading headlamp headless headlock headphone headpiece headrest +headroom headscarf headset headsman headstand headstone headway headwear +heap heat heave heavily heaviness heaving hedge hedging heftiness hefty +helium helmet helper helpful helping helpless helpline hemlock hemstitch +hence henchman henna herald herbal herbicide herbs heritage hermit heroics +heroism herring herself hertz hesitancy hesitant hesitate hexagon hexagram +hubcap huddle huddling huff hug hula hulk hull human humble humbling humbly +humid humiliate humility humming hummus humongous humorist humorless humorous +humpback humped humvee hunchback hundredth hunger hungrily hungry hunk hunter +hunting huntress huntsman hurdle hurled hurler hurling hurray hurricane +hurried hurry hurt husband hush husked huskiness hut hybrid hydrant hydrated +hydration hydrogen hydroxide hyperlink hypertext hyphen hypnoses hypnosis +hypnotic hypnotism hypnotist hypnotize hypocrisy hypocrite ibuprofen ice +iciness icing icky icon icy idealism idealist idealize ideally idealness +identical identify identity ideology idiocy idiom idly igloo ignition ignore +iguana illicitly illusion illusive image imaginary imagines imaging imbecile +imitate imitation immature immerse immersion imminent immobile immodest +immorally immortal immovable immovably immunity immunize impaired impale +impart impatient impeach impeding impending imperfect imperial impish +implant implement implicate implicit implode implosion implosive imply +impolite important importer impose imposing impotence impotency impotent +impound imprecise imprint imprison impromptu improper improve improving +improvise imprudent impulse impulsive impure impurity iodine iodize ion +ipad iphone ipod irate irk iron irregular irrigate irritable irritably +irritant irritate islamic islamist isolated isolating isolation isotope +issue issuing italicize italics item itinerary itunes ivory ivy jab jackal +jacket jackknife jackpot jailbird jailbreak jailer jailhouse jalapeno jam +janitor january jargon jarring jasmine jaundice jaunt java jawed jawless +jawline jaws jaybird jaywalker jazz jeep jeeringly jellied jelly jersey +jester jet jiffy jigsaw jimmy jingle jingling jinx jitters jittery job jockey +jockstrap jogger jogging john joining jokester jokingly jolliness jolly jolt +jot jovial joyfully joylessly joyous joyride joystick jubilance jubilant +judge judgingly judicial judiciary judo juggle juggling jugular juice +juiciness juicy jujitsu jukebox july jumble jumbo jump junction juncture +june junior juniper junkie junkman junkyard jurist juror jury justice +justifier justify justly justness juvenile kabob kangaroo karaoke karate +karma kebab keenly keenness keep keg kelp kennel kept kerchief kerosene +kettle kick kiln kilobyte kilogram kilometer kilowatt kilt kimono kindle +kindling kindly kindness kindred kinetic kinfolk king kinship kinsman +kinswoman kissable kisser kissing kitchen kite kitten kitty kiwi kleenex +knapsack knee knelt knickers knoll koala kooky kosher krypton kudos +kung labored laborer laboring laborious labrador ladder ladies ladle +ladybug ladylike lagged lagging lagoon lair lake lance landed landfall +landfill landing landlady landless landline landlord landmark landmass +landmine landowner landscape landside landslide language lankiness lanky +lantern lapdog lapel lapped lapping laptop lard large lark lash lasso +last latch late lather latitude latrine latter latticed launch launder +laundry laurel lavender lavish laxative lazily laziness lazy lecturer +left legacy legal legend legged leggings legible legibly legislate lego +legroom legume legwarmer legwork lemon lend length lens lent leotard lesser +letdown lethargic lethargy letter lettuce level leverage levers levitate +levitator liability liable liberty librarian library licking licorice lid +life lifter lifting liftoff ligament likely likeness likewise liking lilac +lilly lily limb limeade limelight limes limit limping limpness line lingo +linguini linguist lining linked linoleum linseed lint lion lip liquefy +liqueur liquid lisp list litigate litigator litmus litter little livable +lived lively liver livestock lividly living lizard lubricant lubricate lucid +luckily luckiness luckless lucrative ludicrous lugged lukewarm lullaby lumber +luminance luminous lumpiness lumping lumpish lunacy lunar lunchbox luncheon +lunchroom lunchtime lung lurch lure luridness lurk lushly lushness luster +lustfully lustily lustiness lustrous lusty luxurious luxury lying lyrically +lyricism lyricist lyrics macarena macaroni macaw mace machine machinist +magazine magenta maggot magical magician magma magnesium magnetic magnetism +magnetize magnifier magnify magnitude magnolia mahogany maimed majestic +majesty majorette majority makeover maker makeshift making malformed malt +mama mammal mammary mammogram manager managing manatee mandarin mandate +mandatory mandolin manger mangle mango mangy manhandle manhole manhood +manhunt manicotti manicure manifesto manila mankind manlike manliness +manly manmade manned mannish manor manpower mantis mantra manual many +map marathon marauding marbled marbles marbling march mardi margarine +margarita margin marigold marina marine marital maritime marlin marmalade +maroon married marrow marry marshland marshy marsupial marvelous marxism +mascot masculine mashed mashing massager masses massive mastiff matador +matchbook matchbox matcher matching matchless material maternal maternity +math mating matriarch matrimony matrix matron matted matter maturely +maturing maturity mauve maverick maximize maximum maybe mayday mayflower +moaner moaning mobile mobility mobilize mobster mocha mocker mockup +modified modify modular modulator module moisten moistness moisture molar +molasses mold molecular molecule molehill mollusk mom monastery monday +monetary monetize moneybags moneyless moneywise mongoose mongrel monitor +monkhood monogamy monogram monologue monopoly monorail monotone monotype +monoxide monsieur monsoon monstrous monthly monument moocher moodiness +moody mooing moonbeam mooned moonlight moonlike moonlit moonrise moonscape +moonshine moonstone moonwalk mop morale morality morally morbidity morbidly +morphine morphing morse mortality mortally mortician mortified mortify +mortuary mosaic mossy most mothball mothproof motion motivate motivator +motive motocross motor motto mountable mountain mounted mounting mourner +mournful mouse mousiness moustache mousy mouth movable move movie moving +mower mowing much muck mud mug mulberry mulch mule mulled mullets multiple +multiply multitask multitude mumble mumbling mumbo mummified mummify mummy +mumps munchkin mundane municipal muppet mural murkiness murky murmuring +muscular museum mushily mushiness mushroom mushy music musket muskiness +musky mustang mustard muster mustiness musty mutable mutate mutation mute +mutilated mutilator mutiny mutt mutual muzzle myself myspace mystified +mystify myth nacho nag nail name naming nanny nanometer nape napkin napped +napping nappy narrow nastily nastiness national native nativity natural +nature naturist nautical navigate navigator navy nearby nearest nearly +nearness neatly neatness nebula nebulizer nectar negate negation negative +neglector negligee negligent negotiate nemeses nemesis neon nephew nerd +nervous nervy nest net neurology neuron neurosis neurotic neuter neutron +never next nibble nickname nicotine niece nifty nimble nimbly nineteen +ninetieth ninja nintendo ninth nuclear nuclei nucleus nugget nullify +number numbing numbly numbness numeral numerate numerator numeric numerous +nuptials nursery nursing nurture nutcase nutlike nutmeg nutrient nutshell +nuttiness nutty nuzzle nylon oaf oak oasis oat obedience obedient obituary +object obligate obliged oblivion oblivious oblong obnoxious oboe obscure +obscurity observant observer observing obsessed obsession obsessive obsolete +obstacle obstinate obstruct obtain obtrusive obtuse obvious occultist +occupancy occupant occupier occupy ocean ocelot octagon octane october +octopus ogle oil oink ointment okay old olive olympics omega omen ominous +omission omit omnivore onboard oncoming ongoing onion online onlooker only +onscreen onset onshore onslaught onstage onto onward onyx oops ooze oozy +opacity opal open operable operate operating operation operative operator +opium opossum opponent oppose opposing opposite oppressed oppressor opt +opulently osmosis other otter ouch ought ounce outage outback outbid +outboard outbound outbreak outburst outcast outclass outcome outdated +outdoors outer outfield outfit outflank outgoing outgrow outhouse outing +outlast outlet outline outlook outlying outmatch outmost outnumber outplayed +outpost outpour output outrage outrank outreach outright outscore outsell +outshine outshoot outsider outskirts outsmart outsource outspoken outtakes +outthink outward outweigh outwit oval ovary oven overact overall overarch +overbid overbill overbite overblown overboard overbook overbuilt overcast +overcoat overcome overcook overcrowd overdraft overdrawn overdress overdrive +overdue overeager overeater overexert overfed overfeed overfill overflow +overfull overgrown overhand overhang overhaul overhead overhear overheat +overhung overjoyed overkill overlabor overlaid overlap overlay overload +overlook overlord overlying overnight overpass overpay overplant overplay +overpower overprice overrate overreach overreact override overripe overrule +overrun overshoot overshot oversight oversized oversleep oversold overspend +overstate overstay overstep overstock overstuff oversweet overtake overthrow +overtime overtly overtone overture overturn overuse overvalue overview +overwrite owl oxford oxidant oxidation oxidize oxidizing oxygen oxymoron +oyster ozone paced pacemaker pacific pacifier pacifism pacifist pacify +padded padding paddle paddling padlock pagan pager paging pajamas palace +palatable palm palpable palpitate paltry pampered pamperer pampers pamphlet +panama pancake pancreas panda pandemic pang panhandle panic panning panorama +panoramic panther pantomime pantry pants pantyhose paparazzi papaya paper +paprika papyrus parabola parachute parade paradox paragraph parakeet +paralegal paralyses paralysis paralyze paramedic parameter paramount +parasail parasite parasitic parcel parched parchment pardon parish parka +parking parkway parlor parmesan parole parrot parsley parsnip partake +parted parting partition partly partner partridge party passable passably +passage passcode passenger passerby passing passion passive passivism +passover passport password pasta pasted pastel pastime pastor pastrami +pasture pasty patchwork patchy paternal paternity path patience patient +patio patriarch patriot patrol patronage patronize pauper pavement paver +pavestone pavilion paving pawing payable payback paycheck payday payee +payer paying payment payphone payroll pebble pebbly pecan pectin peculiar +peddling pediatric pedicure pedigree pedometer pegboard pelican pellet +pelt pelvis penalize penalty pencil pendant pending penholder penknife +pennant penniless penny penpal pension pentagon pentagram pep perceive +percent perch percolate perennial perfected perfectly perfume periscope +perish perjurer perjury perkiness perky perm peroxide perpetual perplexed +persecute persevere persuaded persuader pesky peso pessimism pessimist +pester pesticide petal petite petition petri petroleum petted petticoat +pettiness petty petunia phantom phobia phoenix phonebook phoney phonics +phoniness phony phosphate photo phrase phrasing placard placate placidly +plank planner plant plasma plaster plastic plated platform plating platinum +platonic platter platypus plausible plausibly playable playback player +playful playgroup playhouse playing playlist playmaker playmate playoff +playpen playroom playset plaything playtime plaza pleading pleat pledge +plentiful plenty plethora plexiglas pliable plod plop plot plow ploy +pluck plug plunder plunging plural plus plutonium plywood poach pod poem +poet pogo pointed pointer pointing pointless pointy poise poison poker +poking polar police policy polio polish politely polka polo polyester +polygon polygraph polymer poncho pond pony popcorn pope poplar popper +poppy popsicle populace popular populate porcupine pork porous porridge +portable portal portfolio porthole portion portly portside poser posh +posing possible possibly possum postage postal postbox postcard posted +poster posting postnasal posture postwar pouch pounce pouncing pound pouring +pout powdered powdering powdery power powwow pox praising prance prancing +pranker prankish prankster prayer praying preacher preaching preachy preamble +precinct precise precision precook precut predator predefine predict preface +prefix preflight preformed pregame pregnancy pregnant preheated prelaunch +prelaw prelude premiere premises premium prenatal preoccupy preorder prepaid +prepay preplan preppy preschool prescribe preseason preset preshow president +presoak press presume presuming preteen pretended pretender pretense pretext +pretty pretzel prevail prevalent prevent preview previous prewar prewashed +prideful pried primal primarily primary primate primer primp princess +print prior prism prison prissy pristine privacy private privatize prize +proactive probable probably probation probe probing probiotic problem +procedure process proclaim procreate procurer prodigal prodigy produce +product profane profanity professed professor profile profound profusely +progeny prognosis program progress projector prologue prolonged promenade +prominent promoter promotion prompter promptly prone prong pronounce pronto +proofing proofread proofs propeller properly property proponent proposal +propose props prorate protector protegee proton prototype protozoan protract +protrude proud provable proved proven provided provider providing province +proving provoke provoking provolone prowess prowler prowling proximity +proxy prozac prude prudishly prune pruning pry psychic public publisher +pucker pueblo pug pull pulmonary pulp pulsate pulse pulverize puma pumice +pummel punch punctual punctuate punctured pungent punisher punk pupil +puppet puppy purchase pureblood purebred purely pureness purgatory purge +purging purifier purify purist puritan purity purple purplish purposely +purr purse pursuable pursuant pursuit purveyor pushcart pushchair pusher +pushiness pushing pushover pushpin pushup pushy putdown putt puzzle +puzzling pyramid pyromania python quack quadrant quail quaintly quake +quaking qualified qualifier qualify quality qualm quantum quarrel quarry +quartered quarterly quarters quartet quench query quicken quickly quickness +quicksand quickstep quiet quill quilt quintet quintuple quirk quit quiver +quizzical quotable quotation quote rabid race racing racism rack racoon +radar radial radiance radiantly radiated radiation radiator radio radish +raffle raft rage ragged raging ragweed raider railcar railing railroad +railway raisin rake raking rally ramble rambling ramp ramrod ranch rancidity +random ranged ranger ranging ranked ranking ransack ranting rants rare +rarity rascal rash rasping ravage raven ravine raving ravioli ravishing +reabsorb reach reacquire reaction reactive reactor reaffirm ream reanalyze +reappear reapply reappoint reapprove rearrange rearview reason reassign +reassure reattach reawake rebalance rebate rebel rebirth reboot reborn +rebound rebuff rebuild rebuilt reburial rebuttal recall recant recapture +recast recede recent recess recharger recipient recital recite reckless +reclaim recliner reclining recluse reclusive recognize recoil recollect +recolor reconcile reconfirm reconvene recopy record recount recoup +recovery recreate rectal rectangle rectified rectify recycled recycler +recycling reemerge reenact reenter reentry reexamine referable referee +reference refill refinance refined refinery refining refinish reflected +reflector reflex reflux refocus refold reforest reformat reformed reformer +reformist refract refrain refreeze refresh refried refueling refund refurbish +refurnish refusal refuse refusing refutable refute regain regalia regally +reggae regime region register registrar registry regress regretful regroup +regular regulate regulator rehab reheat rehire rehydrate reimburse reissue +reiterate rejoice rejoicing rejoin rekindle relapse relapsing relatable +related relation relative relax relay relearn release relenting reliable +reliably reliance reliant relic relieve relieving relight relish relive +reload relocate relock reluctant rely remake remark remarry rematch remedial +remedy remember reminder remindful remission remix remnant remodeler remold +remorse remote removable removal removed remover removing rename renderer +rendering rendition renegade renewable renewably renewal renewed renounce +renovate renovator rentable rental rented renter reoccupy reoccur reopen +reorder repackage repacking repaint repair repave repaying repayment repeal +repeated repeater repent rephrase replace replay replica reply reporter +repose repossess repost repressed reprimand reprint reprise reproach +reprocess reproduce reprogram reps reptile reptilian repugnant repulsion +repulsive repurpose reputable reputably request require requisite reroute +rerun resale resample rescuer reseal research reselect reseller resemble +resend resent reset reshape reshoot reshuffle residence residency resident +residual residue resigned resilient resistant resisting resize resolute +resolved resonant resonate resort resource respect resubmit result resume +resupply resurface resurrect retail retainer retaining retake retaliate +retention rethink retinal retired retiree retiring retold retool retorted +retouch retrace retract retrain retread retreat retrial retrieval retriever +retry return retying retype reunion reunite reusable reuse reveal reveler +revenge revenue reverb revered reverence reverend reversal reverse reversing +reversion revert revisable revise revision revisit revivable revival reviver +reviving revocable revoke revolt revolver revolving reward rewash rewind +rewire reword rework rewrap rewrite rhyme ribbon ribcage rice riches richly +richness rickety ricotta riddance ridden ride riding rifling rift rigging +rigid rigor rimless rimmed rind rink rinse rinsing riot ripcord ripeness +ripening ripping ripple rippling riptide rise rising risk risotto ritalin +ritzy rival riverbank riverbed riverboat riverside riveter riveting roamer +roaming roast robbing robe robin robotics robust rockband rocker rocket +rockfish rockiness rocking rocklike rockslide rockstar rocky rogue roman +romp rope roping roster rosy rotten rotting rotunda roulette rounding +roundish roundness roundup roundworm routine routing rover roving royal +rubbed rubber rubbing rubble rubdown ruby ruckus rudder rug ruined rule +rumble rumbling rummage rumor runaround rundown runner running runny runt +runway rupture rural ruse rush rust rut sabbath sabotage sacrament sacred +sacrifice sadden saddlebag saddled saddling sadly sadness safari safeguard +safehouse safely safeness saffron saga sage sagging saggy said saint sake +salad salami salaried salary saline salon saloon salsa salt salutary salute +salvage salvaging salvation same sample sampling sanction sanctity sanctuary +sandal sandbag sandbank sandbar sandblast sandbox sanded sandfish sanding +sandlot sandpaper sandpit sandstone sandstorm sandworm sandy sanitary +sanitizer sank santa sapling sappiness sappy sarcasm sarcastic sardine +sash sasquatch sassy satchel satiable satin satirical satisfied satisfy +saturate saturday sauciness saucy sauna savage savanna saved savings savior +savor saxophone say scabbed scabby scalded scalding scale scaling scallion +scallop scalping scam scandal scanner scanning scant scapegoat scarce +scarcity scarecrow scared scarf scarily scariness scarring scary scavenger +scenic schedule schematic scheme scheming schilling schnapps scholar +science scientist scion scoff scolding scone scoop scooter scope scorch +scorebook scorecard scored scoreless scorer scoring scorn scorpion scotch +scoundrel scoured scouring scouting scouts scowling scrabble scraggly +scrambled scrambler scrap scratch scrawny screen scribble scribe scribing +scrimmage script scroll scrooge scrounger scrubbed scrubber scruffy scrunch +scrutiny scuba scuff sculptor sculpture scurvy scuttle secluded secluding +seclusion second secrecy secret sectional sector secular securely security +sedan sedate sedation sedative sediment seduce seducing segment seismic +seizing seldom selected selection selective selector self seltzer semantic +semester semicolon semifinal seminar semisoft semisweet senate senator +send senior senorita sensation sensitive sensitize sensually sensuous sepia +september septic septum sequel sequence sequester series sermon serotonin +serpent serrated serve service serving sesame sessions setback setting +settle settling setup sevenfold seventeen seventh seventy severity shabby +shack shaded shadily shadiness shading shadow shady shaft shakable shakily +shakiness shaking shaky shale shallot shallow shame shampoo shamrock shank +shanty shape shaping share sharpener sharper sharpie sharply sharpness +shawl sheath shed sheep sheet shelf shell shelter shelve shelving sherry +shield shifter shifting shiftless shifty shimmer shimmy shindig shine +shingle shininess shining shiny ship shirt shivering shock shone shoplift +shopper shopping shoptalk shore shortage shortcake shortcut shorten shorter +shorthand shortlist shortly shortness shorts shortwave shorty shout shove +showbiz showcase showdown shower showgirl showing showman shown showoff +showpiece showplace showroom showy shrank shrapnel shredder shredding +shrewdly shriek shrill shrimp shrine shrink shrivel shrouded shrubbery +shrubs shrug shrunk shucking shudder shuffle shuffling shun shush shut shy +siamese siberian sibling siding sierra siesta sift sighing silenced silencer +silent silica silicon silk silliness silly silo silt silver similarly +simile simmering simple simplify simply sincere sincerity singer singing +single singular sinister sinless sinner sinuous sip siren sister sitcom +sitter sitting situated situation sixfold sixteen sixth sixties sixtieth +sixtyfold sizable sizably size sizing sizzle sizzling skater skating +skedaddle skeletal skeleton skeptic sketch skewed skewer skid skied skier +skies skiing skilled skillet skillful skimmed skimmer skimming skimpily +skincare skinhead skinless skinning skinny skintight skipper skipping +skirmish skirt skittle skydiver skylight skyline skype skyrocket skyward +slab slacked slacker slacking slackness slacks slain slam slander slang +slapping slapstick slashed slashing slate slather slaw sled sleek sleep sleet +sleeve slept sliceable sliced slicer slicing slick slider slideshow sliding +slighted slighting slightly slimness slimy slinging slingshot slinky slip +slit sliver slobbery slogan sloped sloping sloppily sloppy slot slouching +slouchy sludge slug slum slurp slush sly small smartly smartness smasher +smashing smashup smell smelting smile smilingly smirk smite smith smitten +smock smog smoked smokeless smokiness smoking smoky smolder smooth smother +smudge smudgy smuggler smuggling smugly smugness snack snagged snaking snap +snare snarl snazzy sneak sneer sneeze sneezing snide sniff snippet snipping +snitch snooper snooze snore snoring snorkel snort snout snowbird snowboard +snowbound snowcap snowdrift snowdrop snowfall snowfield snowflake snowiness +snowless snowman snowplow snowshoe snowstorm snowsuit snowy snub snuff snuggle +snugly snugness speak spearfish spearhead spearman spearmint species specimen +specked speckled specks spectacle spectator spectrum speculate speech speed +spellbind speller spelling spendable spender spending spent spew sphere +spherical sphinx spider spied spiffy spill spilt spinach spinal spindle +spinner spinning spinout spinster spiny spiral spirited spiritism spirits +spiritual splashed splashing splashy splatter spleen splendid splendor +splice splicing splinter splotchy splurge spoilage spoiled spoiler spoiling +spoils spoken spokesman sponge spongy sponsor spoof spookily spooky spool +spoon spore sporting sports sporty spotless spotlight spotted spotter +spotting spotty spousal spouse spout sprain sprang sprawl spray spree +sprig spring sprinkled sprinkler sprint sprite sprout spruce sprung spry +spud spur sputter spyglass squabble squad squall squander squash squatted +squatter squatting squeak squealer squealing squeamish squeegee squeeze +squeezing squid squiggle squiggly squint squire squirt squishier squishy +stability stabilize stable stack stadium staff stage staging stagnant +stagnate stainable stained staining stainless stalemate staleness stalling +stallion stamina stammer stamp stand stank staple stapling starboard starch +stardom stardust starfish stargazer staring stark starless starlet starlight +starlit starring starry starship starter starting startle startling startup +starved starving stash state static statistic statue stature status statute +statutory staunch stays steadfast steadier steadily steadying steam steed +steep steerable steering steersman stegosaur stellar stem stench stencil +step stereo sterile sterility sterilize sterling sternness sternum stew +stick stiffen stiffly stiffness stifle stifling stillness stilt stimulant +stimulate stimuli stimulus stinger stingily stinging stingray stingy +stinking stinky stipend stipulate stir stitch stock stoic stoke stole +stomp stonewall stoneware stonework stoning stony stood stooge stool +stoop stoplight stoppable stoppage stopped stopper stopping stopwatch +storable storage storeroom storewide storm stout stove stowaway stowing +straddle straggler strained strainer straining strangely stranger strangle +strategic strategy stratus straw stray streak stream street strength +strenuous strep stress stretch strewn stricken strict stride strife strike +striking strive striving strobe strode stroller strongbox strongly strongman +struck structure strudel struggle strum strung strut stubbed stubble stubbly +stubborn stucco stuck student studied studio study stuffed stuffing stuffy +stumble stumbling stump stung stunned stunner stunning stunt stupor sturdily +sturdy styling stylishly stylist stylized stylus suave subarctic subatomic +subdivide subdued subduing subfloor subgroup subheader subject sublease +sublet sublevel sublime submarine submerge submersed submitter subpanel +subpar subplot subprime subscribe subscript subsector subside subsiding +subsidize subsidy subsoil subsonic substance subsystem subtext subtitle +subtly subtotal subtract subtype suburb subway subwoofer subzero succulent +such suction sudden sudoku suds sufferer suffering suffice suffix suffocate +suffrage sugar suggest suing suitable suitably suitcase suitor sulfate +sulfide sulfite sulfur sulk sullen sulphate sulphuric sultry superbowl +superglue superhero superior superjet superman supermom supernova supervise +supper supplier supply support supremacy supreme surcharge surely sureness +surface surfacing surfboard surfer surgery surgical surging surname surpass +surplus surprise surreal surrender surrogate surround survey survival survive +surviving survivor sushi suspect suspend suspense sustained sustainer swab +swaddling swagger swampland swan swapping swarm sway swear sweat sweep +swell swept swerve swifter swiftly swiftness swimmable swimmer swimming +swimsuit swimwear swinger swinging swipe swirl switch swivel swizzle swooned +swoop swoosh swore sworn swung sycamore sympathy symphonic symphony symptom +synapse syndrome synergy synopses synopsis synthesis synthetic syrup system +tabasco tabby tableful tables tablet tableware tabloid tackiness tacking +tackle tackling tacky taco tactful tactical tactics tactile tactless +tadpole taekwondo tag tainted take taking talcum talisman tall talon +tamale tameness tamer tamper tank tanned tannery tanning tantrum tapeless +tapered tapering tapestry tapioca tapping taps tarantula target tarmac +tarnish tarot tartar tartly tartness task tassel taste tastiness tasting +tasty tattered tattle tattling tattoo taunt tavern thank that thaw theater +theatrics thee theft theme theology theorize thermal thermos thesaurus these +thesis thespian thicken thicket thickness thieving thievish thigh thimble +thing think thinly thinner thinness thinning thirstily thirsting thirsty +thirteen thirty thong thorn those thousand thrash thread threaten threefold +thrift thrill thrive thriving throat throbbing throng throttle throwaway +throwback thrower throwing thud thumb thumping thursday thus thwarting +thyself tiara tibia tidal tidbit tidiness tidings tidy tiger tighten +tightly tightness tightrope tightwad tigress tile tiling till tilt timid +timing timothy tinderbox tinfoil tingle tingling tingly tinker tinkling +tinsel tinsmith tint tinwork tiny tipoff tipped tipper tipping tiptoeing +tiptop tiring tissue trace tracing track traction tractor trade trading +tradition traffic tragedy trailing trailside train traitor trance tranquil +transfer transform translate transpire transport transpose trapdoor trapeze +trapezoid trapped trapper trapping traps trash travel traverse travesty +tray treachery treading treadmill treason treat treble tree trekker +tremble trembling tremor trench trend trespass triage trial triangle +tribesman tribunal tribune tributary tribute triceps trickery trickily +tricking trickle trickster tricky tricolor tricycle trident tried trifle +trifocals trillion trilogy trimester trimmer trimming trimness trinity trio +tripod tripping triumph trivial trodden trolling trombone trophy tropical +tropics trouble troubling trough trousers trout trowel truce truck truffle +trump trunks trustable trustee trustful trusting trustless truth try tubby +tubeless tubular tucking tuesday tug tuition tulip tumble tumbling tummy +turban turbine turbofan turbojet turbulent turf turkey turmoil turret +turtle tusk tutor tutu tux tweak tweed tweet tweezers twelve twentieth +twenty twerp twice twiddle twiddling twig twilight twine twins twirl +twistable twisted twister twisting twisty twitch twitter tycoon tying +tyke udder ultimate ultimatum ultra umbilical umbrella umpire unabashed +unable unadorned unadvised unafraid unaired unaligned unaltered unarmored +unashamed unaudited unawake unaware unbaked unbalance unbeaten unbend +unbent unbiased unbitten unblended unblessed unblock unbolted unbounded +unboxed unbraided unbridle unbroken unbuckled unbundle unburned unbutton +uncanny uncapped uncaring uncertain unchain unchanged uncharted uncheck +uncivil unclad unclaimed unclamped unclasp uncle unclip uncloak unclog +unclothed uncoated uncoiled uncolored uncombed uncommon uncooked uncork +uncorrupt uncounted uncouple uncouth uncover uncross uncrown uncrushed +uncured uncurious uncurled uncut undamaged undated undaunted undead +undecided undefined underage underarm undercoat undercook undercut underdog +underdone underfed underfeed underfoot undergo undergrad underhand underline +underling undermine undermost underpaid underpass underpay underrate undertake +undertone undertook undertow underuse underwear underwent underwire undesired +undiluted undivided undocked undoing undone undrafted undress undrilled +undusted undying unearned unearth unease uneasily uneasy uneatable uneaten +unedited unelected unending unengaged unenvied unequal unethical uneven +unexpired unexposed unfailing unfair unfasten unfazed unfeeling unfiled +unfilled unfitted unfitting unfixable unfixed unflawed unfocused unfold +unfounded unframed unfreeze unfrosted unfrozen unfunded unglazed ungloved +unglue ungodly ungraded ungreased unguarded unguided unhappily unhappy +unharmed unhealthy unheard unhearing unheated unhelpful unhidden unhinge +unhitched unholy unhook unicorn unicycle unified unifier uniformed uniformly +unify unimpeded uninjured uninstall uninsured uninvited union uniquely +unisexual unison unissued unit universal universe unjustly unkempt unkind +unknotted unknowing unknown unlaced unlatch unlawful unleaded unlearned +unleash unless unleveled unlighted unlikable unlimited unlined unlinked +unlisted unlit unlivable unloaded unloader unlocked unlocking unlovable +unloved unlovely unloving unluckily unlucky unmade unmanaged unmanned unmapped +unmarked unmasked unmasking unmatched unmindful unmixable unmixed unmolded +unmoral unmovable unmoved unmoving unnamable unnamed unnatural unneeded +unnerve unnerving unnoticed unopened unopposed unpack unpadded unpaid +unpainted unpaired unpaved unpeeled unpicked unpiloted unpinned unplanned +unplanted unpleased unpledged unplowed unplug unpopular unproven unquote +unranked unrated unraveled unreached unread unreal unreeling unrefined +unrelated unrented unrest unretired unrevised unrigged unripe unrivaled +unroasted unrobed unroll unruffled unruly unrushed unsaddle unsafe unsaid +unsalted unsaved unsavory unscathed unscented unscrew unsealed unseated +unsecured unseeing unseemly unseen unselect unselfish unsent unsettled +unshackle unshaken unshaved unshaven unsheathe unshipped unsightly unsigned +unskilled unsliced unsmooth unsnap unsocial unsoiled unsold unsolved unsorted +unspoiled unspoken unstable unstaffed unstamped unsteady unsterile unstirred +unstitch unstopped unstuck unstuffed unstylish unsubtle unsubtly unsuited +unsure unsworn untagged untainted untaken untamed untangled untapped untaxed +unthawed unthread untidy untie until untimed untimely untitled untoasted +untold untouched untracked untrained untreated untried untrimmed untrue +untruth unturned untwist untying unusable unused unusual unvalued unvaried +unvarying unveiled unveiling unvented unviable unvisited unvocal unwanted +unwarlike unwary unwashed unwatched unweave unwed unwelcome unwell unwieldy +unwilling unwind unwired unwitting unwomanly unworldly unworn unworried +unworthy unwound unwoven unwrapped unwritten unzip upbeat upchuck upcoming +upcountry update upfront upgrade upheaval upheld uphill uphold uplifted +uplifting upload upon upper upright uprising upriver uproar uproot upscale +upside upstage upstairs upstart upstate upstream upstroke upswing uptake +uptight uptown upturned upward upwind uranium urban urchin urethane urgency +urgent urging urologist urology usable usage useable used uselessly user +usher usual utensil utility utilize utmost utopia utter vacancy vacant vacate +vacation vagabond vagrancy vagrantly vaguely vagueness valiant valid valium +valley valuables value vanilla vanish vanity vanquish vantage vaporizer +variable variably varied variety various varmint varnish varsity varying +vascular vaseline vastly vastness veal vegan veggie vehicular velcro velocity +velvet vendetta vending vendor veneering vengeful venomous ventricle venture +venue venus verbalize verbally verbose verdict verify verse version versus +vertebrae vertical vertigo very vessel vest veteran veto vexingly viability +viable vibes vice vicinity victory video viewable viewer viewing viewless +viewpoint vigorous village villain vindicate vineyard vintage violate +violation violator violet violin viper viral virtual virtuous virus visa +viscosity viscous viselike visible visibly vision visiting visitor visor +vista vitality vitalize vitally vitamins vivacious vividly vividness +vixen vocalist vocalize vocally vocation voice voicing void volatile +volley voltage volumes voter voting voucher vowed vowel voyage wackiness +wad wafer waffle waged wager wages waggle wagon wake waking walk walmart +walnut walrus waltz wand wannabe wanted wanting wasabi washable washbasin +washboard washbowl washcloth washday washed washer washhouse washing washout +washroom washstand washtub wasp wasting watch water waviness waving wavy +whacking whacky wham wharf wheat whenever whiff whimsical whinny whiny +whisking whoever whole whomever whoopee whooping whoops why wick widely +widen widget widow width wieldable wielder wife wifi wikipedia wildcard +wildcat wilder wildfire wildfowl wildland wildlife wildly wildness willed +willfully willing willow willpower wilt wimp wince wincing wind wing winking +winner winnings winter wipe wired wireless wiring wiry wisdom wise wish +wisplike wispy wistful wizard wobble wobbling wobbly wok wolf wolverine +womanhood womankind womanless womanlike womanly womb woof wooing wool woozy +word work worried worrier worrisome worry worsening worshiper worst wound +woven wow wrangle wrath wreath wreckage wrecker wrecking wrench wriggle +wriggly wrinkle wrinkly wrist writing written wrongdoer wronged wrongful +wrongly wrongness wrought xbox xerox yahoo yam yanking yapping yard yarn +yeah yearbook yearling yearly yearning yeast yelling yelp yen yesterday +yiddish yield yin yippee yodel yoga yogurt yonder yoyo yummy zap zealous +zebra zen zeppelin zero zestfully zesty zigzagged zipfile zipping zippy +zips zit zodiac zombie zone zoning zookeeper zoologist zoology zoom +`.trim().split(/\s+/);