@streetmesh/hub 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 StreetMesh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,133 @@
1
+ <picture>
2
+ <source media="(prefers-color-scheme: dark)" srcset="https://protocol.streetmesh.com/brand/dark/svg/streetmesh-mark-dark.svg">
3
+ <img alt="StreetMesh" src="https://protocol.streetmesh.com/brand/svg/streetmesh-mark.svg" width="96">
4
+ </picture>
5
+
6
+ # StreetMesh Hub
7
+
8
+ **The authoritative half of a venue: rooms, and who is allowed in them.**
9
+
10
+ > Developed in [`Laravel`](https://github.com/StreetMesh/Laravel), at `hub/`,
11
+ > and published here. Issues and pull requests belong there.
12
+ >
13
+ > Most people never install this. A venue builds its own hub out of it —
14
+ > `php artisan hub:build` copies this in beside the rooms its experiences
15
+ > ship, and rewrites the import. Installing it directly is for writing a room
16
+ > against, and for reading.
17
+
18
+ Anything people do together needs one place that decides what is true *right
19
+ now* — whose turn it is, where the video is paused, what the current bid stands
20
+ at. Not a place that collects opinions from the participants and takes the
21
+ majority view: a place that decides, and refuses a browser saying otherwise,
22
+ whether that browser is confused or lying.
23
+
24
+ That is this. It is the only part of StreetMesh authoritative over the present
25
+ moment, and it is not authoritative over anything else — what *happened* belongs
26
+ to the venue, which signs the record, and to the person whose records it goes
27
+ into.
28
+
29
+ **Hub itself knows no rules.** It provides a room and a door. What makes a room
30
+ a game of chess rather than a watch party is an experience, and experiences are
31
+ somebody else's package.
32
+
33
+ > Chess is the example used throughout this README, because it is the smallest
34
+ > thing that needs the whole of it: two people, strict turns, a rule about what
35
+ > is legal, and an outcome worth keeping afterwards. Where it is a poor guide,
36
+ > the text says so — a watch party needs less than chess and an auction needs
37
+ > more, and neither is built in here either.
38
+
39
+ ## What it knows, and what it deliberately does not
40
+
41
+ Hub knows nothing about federation. It cannot resolve a handle, has never heard
42
+ of a DID directory, and holds no permission over anybody's records.
43
+
44
+ What it receives is a **ticket**: a short-lived assertion, signed by the venue
45
+ with the key that venue already publishes, saying *this person may sit in this
46
+ seat in this room*. Hub fetches the venue's DID document, checks a signature,
47
+ and that is the whole of its security model.
48
+
49
+ So it holds **no credential**. No shared secret, no private key, nothing to
50
+ steal and nothing it could use to assert anything back to the venue. If this
51
+ process were entirely compromised, what an attacker would gain is the ability to
52
+ lie about what is happening in a room — not to forge a record, not to reach
53
+ anybody's domicile, and not to impersonate the venue.
54
+
55
+ ```
56
+ venue ──signs a ticket──▶ browser ──presents it──▶ hub
57
+
58
+ venue ◀──── asks what happened, when it wants to ──────┘
59
+ ```
60
+
61
+ Trust runs one way. Hub never calls the venue, never pushes, never asserts.
62
+
63
+ ## State, and what survives a restart
64
+
65
+ **Hub is authoritative over the moment. The venue is authoritative over the
66
+ record.**
67
+
68
+ A room is a fast, rebuildable view of state the venue owns. Restart Hub and
69
+ rooms reopen from the venue; something nobody is currently doing has no room at
70
+ all. Which gives one rule an experience author has to think about:
71
+
72
+ > Anything that must survive a restart has to reach the venue before it is
73
+ > acknowledged to a participant.
74
+
75
+ How much that costs depends entirely on what is being built, which is why it is
76
+ declared rather than decided here:
77
+
78
+ - a **watch party** can lose where the video was paused — everyone re-seeks, and
79
+ nothing was lost that anybody minded losing
80
+ - an **auction** cannot lose a bid, ever, so every bid pays for a round trip
81
+ before the bidder is told it landed
82
+ - **chess** sits between them, and even moves within itself: a fast game can
83
+ acknowledge here and persist a beat later, where a crash between the two costs
84
+ one move; a game played over days cannot afford that at all
85
+
86
+ The framework asks an author which of those they are building, rather than
87
+ choosing for them and being wrong most of the time.
88
+
89
+ ## Checking it
90
+
91
+ ```sh
92
+ ./check-ticket # does a ticket PHP signed verify here?
93
+ ./check-join # does it open a door, and does nothing else?
94
+ ./check-blip # does an announcement over the shared secret land?
95
+ ./check-party # can a table see that somebody is in a party?
96
+ ```
97
+
98
+ The second matters because the first can pass while the room admits everybody
99
+ anyway — a signature check being correct and a door being shut are two different
100
+ properties, and only one of them keeps strangers out. `check-join` stands a real
101
+ hub up and connects real websocket clients to it.
102
+
103
+ `check-ticket` mints a real ticket in PHP and verifies it here, which is the
104
+ seam least likely to be caught by either side's own tests. Between the two languages sit base58, a
105
+ multicodec prefix, a compressed curve point whose y coordinate has to be
106
+ recovered by solving the curve equation, base64url without padding, and an ECDSA
107
+ signature as a raw r‖s pair rather than the DER most libraries hand you. A test
108
+ written here that minted its own tickets would pass with every one of those
109
+ wrong.
110
+
111
+ It needs a StreetMesh server running locally — see
112
+ [`Server`](https://github.com/StreetMesh/Server).
113
+
114
+ **Node does not read the system keychain.** The script points it at Herd's
115
+ certificate authority, because without that every fetch of a `.test` DID
116
+ document fails and the failure looks exactly like a ticket that will not verify.
117
+ That cost the prototype time twice, which is why it is a script rather than a
118
+ command to remember.
119
+
120
+ ## Where this fits
121
+
122
+ | Where | What it is |
123
+ | --- | --- |
124
+ | [**Glossary**](https://github.com/StreetMesh/Protocol/blob/main/GLOSSARY.md) | Every term here in plain words — venue, ticket, attestation, domicile — and whether each one is ours or borrowed. Start here if any of the above was unfamiliar. |
125
+ | [<code>Protocol</code>](https://github.com/StreetMesh/Protocol) | What StreetMesh is. Guides, decisions, conformance vectors. |
126
+ | [<code>Protocol&#8209;PHP</code>](https://github.com/StreetMesh/Protocol-PHP) | The framework-free implementation. |
127
+ | [<code>Protocol&#8209;Laravel</code>](https://github.com/StreetMesh/Protocol-Laravel) | The same, bound to Laravel — including minting the tickets this checks. |
128
+ | [<code>Server</code>](https://github.com/StreetMesh/Server) | Where to start if you want to run one. |
129
+ | <code><b>Hub</b></code> | This. The authoritative multiplayer host. |
130
+
131
+ ## License
132
+
133
+ MIT.
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@streetmesh/hub",
3
+ "version": "0.1.0",
4
+ "description": "The authoritative multiplayer host for a StreetMesh venue.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "src/mod.ts",
8
+ "exports": {
9
+ ".": "./src/mod.ts"
10
+ },
11
+ "files": [
12
+ "src",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "scripts": {
20
+ "types:check": "tsc --noEmit",
21
+ "check:ticket": "./check-ticket",
22
+ "check:join": "./check-join",
23
+ "check:blip": "./check-blip",
24
+ "check:party": "./check-party"
25
+ },
26
+ "dependencies": {
27
+ "@colyseus/core": "^0.16.0",
28
+ "@colyseus/schema": "^3.0.0",
29
+ "@colyseus/tools": "^0.16.20",
30
+ "@colyseus/ws-transport": "^0.16.0",
31
+ "colyseus": "^0.16.0",
32
+ "express": "^5.2.1"
33
+ },
34
+ "devDependencies": {
35
+ "@types/express": "^5.0.6",
36
+ "@types/node": "^22.10.0",
37
+ "colyseus.js": "^0.16.22",
38
+ "tsx": "^4.19.0",
39
+ "typescript": "^5.7.0"
40
+ },
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "git+https://github.com/StreetMesh/Hub.git"
44
+ }
45
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Telling a venue what happened in one of its rooms.
3
+ *
4
+ * The only thing this hub says unprompted. Everything else is the other way
5
+ * round — a ticket is signed by the venue and merely verified here, and a
6
+ * result is asked for rather than offered — and that asymmetry is deliberate:
7
+ * a hub that could assert things would be a hub a venue had to authenticate.
8
+ *
9
+ * Two things make asking impossible, and both happen when nobody is looking.
10
+ * A table empties, and the venue has no reason to ask. A game ends after both
11
+ * players have closed their tabs, and there is nobody left to knock — the room
12
+ * is disposed shortly afterwards and the result is gone for good.
13
+ *
14
+ * So: a shared secret, because a hub holds no key of its own. It is worth being
15
+ * plain about what that buys. The venue believes the state of a room it opened
16
+ * and the result of a game it started. Nothing here says who anybody is; that
17
+ * came from a ticket the venue signed and it does not come back this way.
18
+ */
19
+
20
+ /**
21
+ * Where a venue is, worked out from the ticket that opened the room.
22
+ *
23
+ * Not configured. Every ticket names the venue that signed it — the hub already
24
+ * resolved that DID to fetch the key it verified with — so the address to call
25
+ * back on arrives with the authority to open the room in the first place.
26
+ *
27
+ * A hub serving several venues therefore cannot be talked into calling the
28
+ * wrong one, and an operator has one fewer setting to keep in step with
29
+ * reality.
30
+ */
31
+ export function venueFor(issuer: string): string | null {
32
+ // did:web:games.example → https://games.example
33
+ // did:web:games.example:venues:one → https://games.example/venues/one
34
+ if (!issuer.startsWith('did:web:')) {
35
+ return null
36
+ }
37
+
38
+ const [host, ...path] = issuer.slice('did:web:'.length).split(':').map(decodeURIComponent)
39
+
40
+ if (!host) {
41
+ return null
42
+ }
43
+
44
+ return path.length === 0 ? `https://${host}` : `https://${host}/${path.join('/')}`
45
+ }
46
+
47
+ export type Announcement = {
48
+ room: string
49
+ occupants: Array<{ name: string; seat: string }>
50
+ result?: Record<string, unknown> | null
51
+ }
52
+
53
+ /**
54
+ * Say it, and do not wait to be thanked.
55
+ *
56
+ * A venue that is down or slow must not hold up a room: the people in it are
57
+ * playing a game, and none of what is being said here is anything they are
58
+ * waiting on. A failure is logged and dropped — the venue asks for occupancy
59
+ * when it next needs it, and a result that did not arrive is one the next
60
+ * messenger carries.
61
+ */
62
+ export async function announce(issuer: string, secret: string, what: Announcement): Promise<void> {
63
+ const venue = venueFor(issuer)
64
+
65
+ if (!venue || !secret) {
66
+ return
67
+ }
68
+
69
+ try {
70
+ const answer = await fetch(`${venue}/realtime`, {
71
+ method: 'POST',
72
+ headers: {
73
+ 'Content-Type': 'application/json',
74
+ Authorization: `Bearer ${secret}`,
75
+ },
76
+ body: JSON.stringify(what),
77
+ })
78
+
79
+ if (!answer.ok && answer.status !== 404) {
80
+ // 404 is ordinary: a venue may have forgotten a gathering this is still
81
+ // holding. Anything else is worth seeing in a log.
82
+ console.warn(`[hub] ${venue} answered ${answer.status} about ${what.room}`)
83
+ }
84
+ } catch (unreachable) {
85
+ console.warn(`[hub] could not reach ${venue} about ${what.room}:`, (unreachable as Error).message)
86
+ }
87
+ }
package/src/answers.ts ADDED
@@ -0,0 +1,133 @@
1
+ /**
2
+ * What a venue may ask a hub about its own rooms.
3
+ *
4
+ * Two questions, and the venue has to know the name of the table to ask either.
5
+ * That name came from the venue in the first place — it minted the ticket — so
6
+ * nothing here is discoverable by somebody who did not already have it. There is
7
+ * deliberately no endpoint that lists anything: the prototype had one, and it
8
+ * published every live table to whoever asked.
9
+ */
10
+
11
+ import type { Express, Request, Response } from 'express'
12
+
13
+ /**
14
+ * Every room currently open, by the venue's name for it.
15
+ *
16
+ * The hub holds this because Colyseus does not: its own registry is keyed on
17
+ * the room id it invented, and the only name the venue knows is the one it put
18
+ * in the ticket. Kept here rather than in room metadata, which Colyseus
19
+ * publishes in a listing — a table's occupants are nobody else's business.
20
+ *
21
+ * One process. A hub spread across several would need this somewhere shared,
22
+ * and would need to say so rather than quietly answer "no such room" for half
23
+ * of them.
24
+ */
25
+ const open = new Map<string, Resultful>()
26
+
27
+ /**
28
+ * Only what this file needs of a room, so that rooms may import from here
29
+ * without this having to import them back.
30
+ */
31
+ type Resultful = {
32
+ result(): Record<string, unknown> | null
33
+ present(): Array<{ name: string; seat: string }>
34
+ }
35
+
36
+ export function remember(room: Resultful, name: string): void {
37
+ open.set(name, room)
38
+ }
39
+
40
+ export function forget(name: string): void {
41
+ open.delete(name)
42
+ }
43
+
44
+ /**
45
+ * This process, distinct from any other serving the same name.
46
+ *
47
+ * A hub keeps its rooms in memory, so two processes behind one address are two
48
+ * hubs pretending to be one: people who joined the same table land in different
49
+ * rooms and cannot see each other. It is invisible from outside — every process
50
+ * answers every question plausibly — so this is here to make it countable. Ask
51
+ * for the build a few times; more than one answer means more than one hub.
52
+ */
53
+ const process_id = Math.random().toString(36).slice(2, 8)
54
+
55
+ /**
56
+ * Which build this hub is running.
57
+ *
58
+ * The venue generates the hub from what it has installed and knows the
59
+ * fingerprint of what it generated. Being able to ask a running hub the same
60
+ * question is what lets a deploy skip when nothing changed — and a hub restart
61
+ * ends every game in progress, so skipping is not a nicety.
62
+ *
63
+ * Unknown rather than absent when nothing set it. A hub started by hand is a
64
+ * legitimate thing to be; it simply cannot answer this.
65
+ */
66
+ function answerBuild(_request: Request, response: Response): void {
67
+ response.json({ build: process.env.HUB_BUILD ?? 'unknown', process: process_id })
68
+ }
69
+
70
+ /**
71
+ * How a game ends up in somebody's own records.
72
+ *
73
+ * The venue asks; the hub answers only for a room that is over. The venue signs
74
+ * what comes back, which is why this is the one thing here worth being careful
75
+ * about: it is the hub's only influence on what gets written into a person's
76
+ * repository, and it cannot sign anything itself.
77
+ */
78
+ function answerResult(request: Request, response: Response): void {
79
+ const room = open.get(String(request.query.room ?? ''))
80
+ const result = room?.result() ?? null
81
+
82
+ if (result === null) {
83
+ // No such table, or one still being played. Deliberately the same answer:
84
+ // whether a game exists is not a question this should help anybody explore.
85
+ response.status(404).json({})
86
+
87
+ return
88
+ }
89
+
90
+ response.json(result)
91
+ }
92
+
93
+ /**
94
+ * Who is actually at a table right now.
95
+ *
96
+ * The venue knows who sat down; only this knows who is still sitting there. A
97
+ * seat survives somebody closing the tab — it has to, or their opponent could
98
+ * take their chair while they reconnected — so a venue counting seats is
99
+ * counting a history rather than a room.
100
+ *
101
+ * Asked about named rooms and never listing them.
102
+ */
103
+ function answerPresence(request: Request, response: Response): void {
104
+ const asked = request.query.room
105
+ const names = Array.isArray(asked) ? asked.map(String) : asked === undefined ? [] : [String(asked)]
106
+ const present: Record<string, Array<{ name: string; seat: string }>> = {}
107
+
108
+ for (const name of names) {
109
+ // Absent rather than empty for a room that is not open, so "nobody is
110
+ // there" and "there is no room" stay different answers.
111
+ const room = open.get(name)
112
+
113
+ if (room) {
114
+ present[name] = room.present()
115
+ }
116
+ }
117
+
118
+ response.json(present)
119
+ }
120
+
121
+ /**
122
+ * Bound onto the application the hub is already serving.
123
+ *
124
+ * Ordinary routes on an ordinary Express app, rather than a second HTTP server
125
+ * of our own. Being bespoke here bought nothing and cost the ability to change
126
+ * transport: WebTransport wants the Express application handed to it, and a
127
+ * bare `http.Server` is not one.
128
+ */
129
+ export function routes(app: Express): void {
130
+ app.get('/build', answerBuild)
131
+ app.get('/result', answerResult)
132
+ app.get('/present', answerPresence)
133
+ }
package/src/hub.ts ADDED
@@ -0,0 +1,87 @@
1
+ /**
2
+ * What a StreetMesh hub is, said once.
3
+ *
4
+ * An ordinary Colyseus application — the options `@colyseus/tools` expects,
5
+ * which is the shape everything that deploys Colyseus expects. What makes it a
6
+ * hub rather than any other Colyseus server is two lines: the rooms a venue
7
+ * installed, and the two questions that venue may ask about them.
8
+ *
9
+ * One definition, used three times: by the venue's generated `app.config.ts`,
10
+ * by `hub-serve` locally, and by the checks in `bin/`. A hub assembled
11
+ * differently in a test than in production would be a hub whose tests prove
12
+ * something about a program nobody runs.
13
+ *
14
+ * Returns the options rather than calling `config()` on them. `config()`
15
+ * validates and hands back what it was given, and it belongs in the generated
16
+ * application next to `export default` — where every other Colyseus project
17
+ * puts it, and where anybody reading it will look for it.
18
+ */
19
+
20
+ import { LocalDriver, LocalPresence } from '@colyseus/core'
21
+ import type { ConfigOptions } from '@colyseus/tools'
22
+ import { install, type Experience } from './install.ts'
23
+ import { routes } from './answers.ts'
24
+
25
+ /**
26
+ * Anything the operator wants on top of what a hub always does.
27
+ *
28
+ * `initializeTransport` is the one worth naming. Colyseus decides it when
29
+ * nothing says otherwise, which is what we want today — and WebTransport, when
30
+ * we come to try it, is this option and nothing else. Welding a transport in
31
+ * here is what the old hand-rolled server did, and it is what made the hub
32
+ * awkward to deploy and impossible to change.
33
+ */
34
+ export type HubOptions = Omit<ConfigOptions, 'initializeGameServer' | 'initializeExpress'> & {
35
+ initializeGameServer?: ConfigOptions['initializeGameServer']
36
+ initializeExpress?: ConfigOptions['initializeExpress']
37
+ }
38
+
39
+ export function hub(experiences: Experience[], also: HubOptions = {}): ConfigOptions {
40
+ const { initializeGameServer, initializeExpress, options, ...rest } = also
41
+
42
+ return {
43
+ ...rest,
44
+
45
+ /*
46
+ * One process, said out loud.
47
+ *
48
+ * `@colyseus/tools` otherwise decides this by counting CPUs: more than one
49
+ * and it reaches for Redis, whether or not there is a Redis to reach. On a
50
+ * developer's machine that quietly works, because a development
51
+ * environment tends to be running one; somewhere else it quietly does not,
52
+ * and the symptom is two players in what looks like the same room who
53
+ * cannot see each other.
54
+ *
55
+ * A hub keeps its room registry in memory — see `answers.ts` — so one
56
+ * process is not a default, it is the assumption the design is built on.
57
+ * Spreading it across several needs that registry somewhere shared, and
58
+ * would be a decision with a Redis address attached rather than a
59
+ * consequence of how many cores somebody's machine has.
60
+ */
61
+ options: {
62
+ driver: new LocalDriver(),
63
+ presence: new LocalPresence(),
64
+ ...options,
65
+ },
66
+
67
+ initializeGameServer: (server) => {
68
+ /*
69
+ * Only what a venue installed.
70
+ *
71
+ * A party used to be defined here too, on the reasoning that it belongs
72
+ * to no experience and is therefore part of what a hub is. It is not:
73
+ * nothing about a party is state a room has to agree on or a rule a room
74
+ * has to enforce, which is all this process is for. It was here to answer
75
+ * who was present, and the venue answers that now on the poll that
76
+ * carries the handshake.
77
+ */
78
+ install(server, experiences)
79
+ initializeGameServer?.(server)
80
+ },
81
+
82
+ initializeExpress: (app) => {
83
+ routes(app)
84
+ initializeExpress?.(app)
85
+ },
86
+ }
87
+ }
package/src/install.ts ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Putting an experience's rooms into a hub.
3
+ *
4
+ * A hub on its own does nothing useful — it has no rooms, because rooms are
5
+ * where the rules live and rules belong to experiences. So this takes them as
6
+ * an argument rather than discovering them: a venue knows what it has
7
+ * installed, and this does not need to.
8
+ *
9
+ * That is the seam. An experience ships a room; a venue collects the ones it
10
+ * has and hands them over; this puts them behind the same door.
11
+ */
12
+
13
+ import type { Room, Server } from '@colyseus/core'
14
+
15
+ /**
16
+ * A room class, as something that can be constructed.
17
+ *
18
+ * Colyseus has this type internally and does not export it from its package
19
+ * index, so it is written out here rather than reached for down a build path
20
+ * that is not ours to depend on. The constructor arguments are Colyseus's
21
+ * business, which is what the `any` says.
22
+ */
23
+ export type RoomClass = new (...args: any[]) => Room
24
+
25
+ export interface Experience {
26
+ /**
27
+ * The room type's name, as an NSID — `com.streetmesh.games.chess`.
28
+ *
29
+ * Named the way collections are, and for the same reason: whoever controls
30
+ * the domain controls the name, so two experiences by different authors
31
+ * cannot collide without somebody doing it on purpose.
32
+ */
33
+ name: string
34
+ room: RoomClass
35
+ }
36
+
37
+ /**
38
+ * The same name, in a form that survives being put in a URL.
39
+ *
40
+ * Colyseus asks for a room type by posting to `matchmake/create/{name}`, and a
41
+ * dot in that path is read as the start of a file extension — so an NSID
42
+ * arrives as `com` and nothing matches. Underscores are not legal in an NSID,
43
+ * which makes them a separator that can never collide with a real name and can
44
+ * always be undone.
45
+ *
46
+ * The NSID stays the name everywhere it matters: in tickets, in scopes, in
47
+ * collections. This is a transport detail and does not leak past this file.
48
+ */
49
+ export function typeNameFor(nsid: string): string {
50
+ return nsid.replaceAll('.', '_')
51
+ }
52
+
53
+ /**
54
+ * Every experience this hub is to serve, defined on the game server.
55
+ *
56
+ * Filtered on the venue's room name, so everybody the venue sent to one table
57
+ * arrives in one room. Without it Colyseus would open a fresh room per person
58
+ * and two players would each be alone in their own game.
59
+ */
60
+ export function install(server: Server, experiences: Experience[]): void {
61
+ for (const experience of experiences) {
62
+ server.define(typeNameFor(experience.name), experience.room).filterBy(['room'])
63
+ }
64
+ }
package/src/mod.ts ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * What an experience is given, and what a venue assembles a hub from.
3
+ *
4
+ * The public surface of this package, and deliberately small: a base room that
5
+ * has already dealt with who may be here, the shape of presence, and one
6
+ * definition of what a StreetMesh hub is. An experience writes rules and
7
+ * nothing else.
8
+ *
9
+ * There is no program in here any more. A hub is generated by the venue that
10
+ * runs it, because only a venue knows what it has installed — this package is
11
+ * the library that generated thing is built out of.
12
+ */
13
+
14
+ export { VenueRoom, type JoinOptions, type Seated } from './room.ts'
15
+ export { Occupant, Occupancy, type OccupantType, type OccupancyType } from './presence.ts'
16
+ export { verifyTicket, type Ticket } from './ticket.ts'
17
+ export { hub, type HubOptions } from './hub.ts'
18
+ export { install, typeNameFor, type Experience, type RoomClass } from './install.ts'
19
+ export { routes, remember, forget } from './answers.ts'
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Reading a key the way a DID document writes one.
3
+ *
4
+ * A verification method publishes `publicKeyMultibase` — a string that says
5
+ * which curve it is and then the key, in one token. This turns that back into
6
+ * something WebCrypto will verify with.
7
+ *
8
+ * Only P-256 is read here, and deliberately so. It is what this project mints,
9
+ * it is what `did:plc` permits alongside secp256k1, and it is the one the
10
+ * platform can verify without a dependency. A document naming another curve is
11
+ * refused rather than guessed at.
12
+ */
13
+
14
+ const BASE58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
15
+
16
+ /** The multicodec prefix for a P-256 public key, varint-encoded. */
17
+ const P256_PREFIX = [0x80, 0x24]
18
+
19
+ /**
20
+ * The prime the curve is defined over, and the parameters needed to recover a
21
+ * y coordinate from an x. A multikey holds the *compressed* point — x, plus one
22
+ * bit saying whether y is odd — so verifying means solving the curve equation
23
+ * for the y that was left out.
24
+ */
25
+ const P = 2n ** 256n - 2n ** 224n + 2n ** 192n + 2n ** 96n - 1n
26
+ const B = 0x5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604bn
27
+
28
+ function decodeBase58(input: string): Uint8Array {
29
+ let value = 0n
30
+
31
+ for (const character of input) {
32
+ const digit = BASE58.indexOf(character)
33
+
34
+ if (digit === -1) {
35
+ throw new Error(`[${input}] is not base58btc.`)
36
+ }
37
+
38
+ value = value * 58n + BigInt(digit)
39
+ }
40
+
41
+ const bytes: number[] = []
42
+
43
+ while (value > 0n) {
44
+ bytes.unshift(Number(value & 0xffn))
45
+ value >>= 8n
46
+ }
47
+
48
+ // Leading zero bytes encode as leading '1's and are lost by the arithmetic.
49
+ for (const character of input) {
50
+ if (character !== '1') break
51
+ bytes.unshift(0)
52
+ }
53
+
54
+ return Uint8Array.from(bytes)
55
+ }
56
+
57
+ /**
58
+ * y² = x³ - 3x + b, solved for y.
59
+ *
60
+ * The square root exists because p ≡ 3 (mod 4), which makes it a single
61
+ * exponentiation rather than anything iterative.
62
+ */
63
+ function recoverY(x: bigint, odd: boolean): bigint {
64
+ const ySquared = (x ** 3n - 3n * x + B) % P
65
+ let y = modPow(ySquared, (P + 1n) / 4n, P)
66
+
67
+ if ((y % 2n === 1n) !== odd) {
68
+ y = P - y
69
+ }
70
+
71
+ if ((y * y - ySquared) % P !== 0n) {
72
+ throw new Error('That key is not a point on P-256.')
73
+ }
74
+
75
+ return y
76
+ }
77
+
78
+ function modPow(base: bigint, exponent: bigint, modulus: bigint): bigint {
79
+ let result = 1n
80
+ let b = base % modulus
81
+
82
+ for (let e = exponent; e > 0n; e >>= 1n) {
83
+ if (e & 1n) result = (result * b) % modulus
84
+ b = (b * b) % modulus
85
+ }
86
+
87
+ return result
88
+ }
89
+
90
+ function toBase64Url(value: bigint): string {
91
+ const hex = value.toString(16).padStart(64, '0')
92
+ const bytes = Uint8Array.from(hex.match(/../g)!.map((pair) => parseInt(pair, 16)))
93
+
94
+ return Buffer.from(bytes).toString('base64url')
95
+ }
96
+
97
+ /**
98
+ * A published key, as something that can check a signature.
99
+ */
100
+ export async function importMultikey(multikey: string): Promise<CryptoKey> {
101
+ if (!multikey.startsWith('z')) {
102
+ throw new Error('A multikey is base58btc, which starts with z.')
103
+ }
104
+
105
+ const decoded = decodeBase58(multikey.slice(1))
106
+
107
+ if (decoded[0] !== P256_PREFIX[0] || decoded[1] !== P256_PREFIX[1]) {
108
+ throw new Error('That key is not on P-256, which is the only curve read here.')
109
+ }
110
+
111
+ const point = decoded.slice(2)
112
+
113
+ if (point.length !== 33) {
114
+ throw new Error('A compressed P-256 point is 33 bytes.')
115
+ }
116
+
117
+ const x = BigInt('0x' + Buffer.from(point.slice(1)).toString('hex'))
118
+ const y = recoverY(x, point[0] === 3)
119
+
120
+ return crypto.subtle.importKey(
121
+ 'jwk',
122
+ { kty: 'EC', crv: 'P-256', x: toBase64Url(x), y: toBase64Url(y) },
123
+ { name: 'ECDSA', namedCurve: 'P-256' },
124
+ false,
125
+ ['verify'],
126
+ )
127
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Who is in the room, as everybody in it sees them.
3
+ *
4
+ * The one piece of state every experience needs and none should have to write.
5
+ * A game wants to know when the second player arrives, a watch party wants to
6
+ * show a list, an auction wants to know who is still bidding — and all three
7
+ * are asking the same question.
8
+ *
9
+ * It is derived entirely from tickets, which means it is the venue's word
10
+ * rather than anybody's self-description. A participant cannot rename
11
+ * themselves here, cannot claim a seat they were not given, and cannot appear
12
+ * twice.
13
+ *
14
+ * Defined without decorators on purpose: decorators are not type syntax, so a
15
+ * file using them cannot be run by Node's own type stripping — and the checks
16
+ * in `bin/` do exactly that. This costs nothing and keeps one toolchain.
17
+ */
18
+
19
+ import { MapSchema, schema } from '@colyseus/schema'
20
+
21
+ export const Occupant = schema({
22
+ /** Their permanent identifier. Survives them changing their handle. */
23
+ did: 'string',
24
+
25
+ /** What to call them, as the venue vouched for it. */
26
+ name: 'string',
27
+
28
+ /**
29
+ * Which seat, where an experience has seats. Empty for the people a watch
30
+ * party would call an audience — present, but not playing.
31
+ */
32
+ seat: 'string',
33
+
34
+ /**
35
+ * The party this person is here with, or empty for somebody on their own.
36
+ *
37
+ * Here rather than kept to themselves, and that is the point of it. Somebody
38
+ * in a party has their voice superseded by the party's, which leaves them
39
+ * present in this room and unhearable in it — and a person who cannot be told
40
+ * that is a person talking to a wall without knowing why.
41
+ *
42
+ * It is also the only thing that makes a private channel legible to the
43
+ * people it is being used beside. A venue with parties on has decided to
44
+ * allow that; it has not decided to hide it.
45
+ *
46
+ * The party's room name rather than a flag, so two people can tell whether
47
+ * they are in the *same* one.
48
+ */
49
+ party: 'string',
50
+ })
51
+
52
+ export const Occupancy = schema({
53
+ /*
54
+ * Keyed by session rather than by DID, because one person may legitimately
55
+ * have two connections — a phone and a laptop — and the room has to be able
56
+ * to tell which one just went away.
57
+ */
58
+ occupants: { map: Occupant },
59
+ })
60
+
61
+ export type OccupantType = InstanceType<typeof Occupant>
62
+ export type OccupancyType = InstanceType<typeof Occupancy>
63
+
64
+ export function occupantsAsMap(): MapSchema<OccupantType> {
65
+ return new MapSchema<OccupantType>()
66
+ }
package/src/room.ts ADDED
@@ -0,0 +1,336 @@
1
+ /**
2
+ * A room nobody enters without a ticket.
3
+ *
4
+ * This is the base every experience builds on, and the only thing it knows how
5
+ * to do is check that a venue said somebody may sit down. It knows nothing
6
+ * about chess, or watch parties, or auctions — and nothing about federation
7
+ * either, which is the point: the venue resolved the address and checked the
8
+ * delegation, and all that arrives here is a signature.
9
+ *
10
+ * What an experience adds is rules. What it never has to add is any of this.
11
+ */
12
+
13
+ import { Room, type Client } from '@colyseus/core'
14
+ import { announce } from './announce.ts'
15
+ import { forget, remember } from './answers.ts'
16
+ import { verifyTicket, type Ticket } from './ticket.ts'
17
+ import { Occupancy, Occupant, type OccupancyType } from './presence.ts'
18
+
19
+ export interface Seated {
20
+ ticket: Ticket
21
+ }
22
+
23
+ /**
24
+ * Which room, in the venue's words.
25
+ *
26
+ * Not the same as Colyseus's `roomId`, which is generated here and means
27
+ * nothing to anybody else. The venue named this room when it minted the ticket,
28
+ * and that name is what the ticket is checked against — so it has to travel
29
+ * with the join and be held by the room.
30
+ */
31
+ export interface JoinOptions {
32
+ ticket?: string
33
+ room?: string
34
+ }
35
+
36
+ /**
37
+ * What the room did, on one line each.
38
+ *
39
+ * A hub is the one part of this nobody can attach a debugger to: it runs
40
+ * somewhere else, and the questions it can be asked from outside are
41
+ * deliberately narrow. When two people who joined the same table cannot see
42
+ * each other, the thing worth knowing is whether they were ever in the same
43
+ * room — and the room is the only thing that knows.
44
+ *
45
+ * The Colyseus id as well as the venue's name for it, because the whole class
46
+ * of bug here is two rooms wearing one name.
47
+ */
48
+ function note(what: string, room: { roomId: string; venueName: string; clients: unknown[] }): void {
49
+ console.log(`[room] ${what} ${room.venueName} as ${room.roomId} (${room.clients.length} here)`)
50
+ }
51
+
52
+ export abstract class VenueRoom<State extends OccupancyType = OccupancyType> extends Room<State> {
53
+ /**
54
+ * How long a table stays up with nobody at it.
55
+ *
56
+ * Colyseus disposes a room the moment it empties, which is right for a lobby
57
+ * and wrong for a game. A browser drops a socket for all sorts of reasons
58
+ * that are not somebody leaving — a reload, a navigation, a laptop lid, a
59
+ * connection that blinked — and if the table dies in that gap it takes the
60
+ * game with it. A new room then opens under the same name, so the venue and
61
+ * everybody else see a table that is fine and a game that never happened.
62
+ *
63
+ * Long enough to cover a reload and a bad minute of signal. Not so long that
64
+ * an abandoned table hangs around: the venue is told when this expires, and
65
+ * that is what settles a game nobody came back to.
66
+ */
67
+ private static readonly EMPTY_FOR_SECONDS = 120
68
+
69
+ private emptying: ReturnType<typeof setTimeout> | null = null
70
+
71
+ protected readonly seats = new Map<string, Ticket>()
72
+
73
+ /**
74
+ * Which seats the venue has filled, which is not the same as who is here.
75
+ *
76
+ * `seats` above is this room's own view, and it empties as people disconnect.
77
+ * This is the venue's, and it is the one an experience wants whenever the
78
+ * question is "are there two players" rather than "who can see this now".
79
+ *
80
+ * Added to and never subtracted from. Every ticket carries the venue's roster
81
+ * as it stood when it was signed, so the union of the tickets this room has
82
+ * seen is the fullest account it has — and a watcher's ticket, which names no
83
+ * seats at all, must not be able to empty a table.
84
+ */
85
+ protected readonly taken = new Set<string>()
86
+
87
+ /** The venue's name for this room, which every ticket must agree with. */
88
+ protected venueRoom = ''
89
+
90
+ /**
91
+ * The venue that signed the ticket that opened this room.
92
+ *
93
+ * Kept so the room can call back without being configured with an address —
94
+ * it arrives with the authority that opened the room, which means a hub
95
+ * serving several venues cannot be talked into telling the wrong one.
96
+ */
97
+ private issuer = ''
98
+
99
+ onCreate(options: JoinOptions): void {
100
+ if (typeof options?.room !== 'string' || options.room === '') {
101
+ throw new Error('A room has to be created under the name the venue gave it.')
102
+ }
103
+
104
+ this.venueRoom = options.room
105
+
106
+ /*
107
+ * Ours to close, not Colyseus's. See EMPTY_FOR_SECONDS.
108
+ */
109
+ this.autoDispose = false
110
+
111
+ /*
112
+ * An experience that wants state of its own sets it in `opened` and calls
113
+ * this first, or extends Occupancy. Either way presence is there before
114
+ * anybody can join, because the first join happens immediately after this
115
+ * returns.
116
+ */
117
+ if (!this.state) {
118
+ this.state = new Occupancy() as State
119
+ }
120
+
121
+ this.opened(options)
122
+
123
+ /*
124
+ * Findable by the name the venue knows it by. Colyseus keys rooms on an id
125
+ * it invented, and the venue only ever knew the name it put in the ticket —
126
+ * without this, a venue asking how a game ended has nothing to ask about.
127
+ */
128
+ remember(this, this.venueRoom)
129
+
130
+ note('opened', { roomId: this.roomId, venueName: this.venueRoom, clients: this.clients })
131
+ }
132
+
133
+ onDispose(): void {
134
+ if (this.emptying !== null) {
135
+ clearTimeout(this.emptying)
136
+ this.emptying = null
137
+ }
138
+
139
+ note('disposed', { roomId: this.roomId, venueName: this.venueRoom, clients: this.clients })
140
+
141
+ forget(this.venueRoom)
142
+
143
+ /*
144
+ * The last word. A room is memory, and after this there is nobody left to
145
+ * ask — a game that ended once both players had closed their tabs would
146
+ * otherwise be a result nobody ever hears, and a table nobody is at would
147
+ * go on being counted until the venue's own cache gave up.
148
+ */
149
+ this.tell()
150
+ }
151
+
152
+ /**
153
+ * Tell the venue what this room looks like now.
154
+ *
155
+ * Called here whenever somebody arrives or leaves. An experience whose state
156
+ * changes in a way the venue needs to know about — a game ending, which
157
+ * nobody is leaving over — calls it too.
158
+ *
159
+ * Not awaited anywhere. The people in this room are playing a game and none
160
+ * of them are waiting on the venue hearing about it, so a venue that is slow
161
+ * or down must not hold anything up.
162
+ */
163
+ protected tell(): void {
164
+ void announce(this.issuer, process.env.STREETMESH_REALTIME_SECRET ?? '', {
165
+ room: this.venueRoom,
166
+ occupants: this.present(),
167
+ result: this.result(),
168
+ })
169
+ }
170
+
171
+ /**
172
+ * Called before a client is admitted, and a throw here is a refusal rather
173
+ * than an error — which is why everything deciding whether somebody may be
174
+ * here belongs in it rather than in `onJoin`.
175
+ */
176
+ async onAuth(client: Client, options: JoinOptions): Promise<Seated> {
177
+ if (typeof options?.ticket !== 'string' || options.ticket === '') {
178
+ throw new Error('A seat here needs a ticket from the venue.')
179
+ }
180
+
181
+ /*
182
+ * Compared against the name this room was opened under, never against the
183
+ * name in the ticket itself. A ticket that vouched for its own room would
184
+ * open any room it was pointed at.
185
+ */
186
+ const ticket = await verifyTicket(options.ticket, this.venueRoom)
187
+
188
+ /*
189
+ * One seat, one occupant — but the occupant is a person, not a connection.
190
+ *
191
+ * A second connection from the same person takes the chair over rather than
192
+ * being refused. Refusing was wrong in every case it actually came up: a
193
+ * tab that crashed, a laptop that slept, a browser that navigated without
194
+ * unloading the page. In all of them the old socket is still holding a seat
195
+ * nobody is sitting in, and the person it belongs to cannot get back to
196
+ * their own game until it times out.
197
+ *
198
+ * Two connections *are* still one occupant. The old one is shown the door
199
+ * here, so the two can never disagree about the room.
200
+ */
201
+ this.replace(ticket.subject)
202
+
203
+ return { ticket }
204
+ }
205
+
206
+ /**
207
+ * Show somebody's earlier connection the door, if they have one.
208
+ *
209
+ * Cleared here as well as in `onLeave`, rather than waiting for it: the new
210
+ * client is admitted immediately after this returns, and a seat still listed
211
+ * under the old session would be a room that briefly holds the same person
212
+ * twice.
213
+ */
214
+ private replace(subject: string): void {
215
+ for (const [sessionId, seated] of this.seats) {
216
+ if (seated.subject !== subject) {
217
+ continue
218
+ }
219
+
220
+ this.seats.delete(sessionId)
221
+ this.state.occupants.delete(sessionId)
222
+
223
+ // 4103: taken over from somewhere else. A code rather than a silent
224
+ // close, so the older screen can say so instead of looking broken.
225
+ this.clients.find((client) => client.sessionId === sessionId)?.leave(4103)
226
+ }
227
+ }
228
+
229
+ onJoin(client: Client, options: JoinOptions, auth: Seated): void {
230
+ this.seats.set(client.sessionId, auth.ticket)
231
+ this.issuer ||= auth.ticket.issuer
232
+
233
+ for (const seat of auth.ticket.taken) {
234
+ this.taken.add(seat)
235
+ }
236
+
237
+ this.state.occupants.set(
238
+ client.sessionId,
239
+ new Occupant({
240
+ did: auth.ticket.subject,
241
+ name: auth.ticket.name,
242
+ seat: auth.ticket.seat,
243
+ party: auth.ticket.party,
244
+ }),
245
+ )
246
+
247
+ this.seated(client, auth.ticket)
248
+
249
+ /*
250
+ * Somebody is here, so the table is not closing. This is the ordinary case
251
+ * after a reload: the same person, seconds later, and nothing was lost.
252
+ */
253
+ if (this.emptying !== null) {
254
+ clearTimeout(this.emptying)
255
+ this.emptying = null
256
+ }
257
+
258
+ note(`joined ${auth.ticket.name} (${auth.ticket.seat})`, {
259
+ roomId: this.roomId,
260
+ venueName: this.venueRoom,
261
+ clients: this.clients,
262
+ })
263
+
264
+ this.tell()
265
+ }
266
+
267
+ /**
268
+ * What this room will say happened, once it is over.
269
+ *
270
+ * Null while there is still a game on, and null forever in a room that has
271
+ * no notion of an ending. A venue asks for this to write somebody a record,
272
+ * so answering early would be the venue signing a result that had not
273
+ * happened yet.
274
+ *
275
+ * Deliberately plain data. The venue signs whatever comes back, so it must
276
+ * be something it can hand to a repository unchanged.
277
+ */
278
+ result(): Record<string, unknown> | null {
279
+ return null
280
+ }
281
+
282
+ /**
283
+ * Who is connected right now, and what they are sitting in.
284
+ *
285
+ * Not who has a seat — that is the venue's record and outlives a dropped
286
+ * connection on purpose. This is the room.
287
+ */
288
+ present(): Array<{ name: string; seat: string }> {
289
+ return [...this.seats.values()].map((ticket) => ({
290
+ name: ticket.name,
291
+ seat: ticket.seat,
292
+ }))
293
+ }
294
+
295
+ onLeave(client: Client): void {
296
+ const ticket = this.seats.get(client.sessionId)
297
+
298
+ this.seats.delete(client.sessionId)
299
+ this.state.occupants.delete(client.sessionId)
300
+
301
+ if (ticket) {
302
+ this.left(client, ticket)
303
+ }
304
+
305
+ note(`left ${ticket?.name ?? 'somebody'}`, {
306
+ roomId: this.roomId,
307
+ venueName: this.venueRoom,
308
+ clients: this.clients,
309
+ })
310
+
311
+ this.tell()
312
+
313
+ if (this.clients.length === 0) {
314
+ this.emptying = setTimeout(() => {
315
+ note('closing, nobody came back', {
316
+ roomId: this.roomId,
317
+ venueName: this.venueRoom,
318
+ clients: this.clients,
319
+ })
320
+
321
+ this.disconnect()
322
+ }, VenueRoom.EMPTY_FOR_SECONDS * 1000)
323
+ }
324
+ }
325
+
326
+ /** Who is here, as the venue vouched for them. */
327
+ protected occupants(): Ticket[] {
328
+ return [...this.seats.values()]
329
+ }
330
+
331
+ protected opened(options: JoinOptions): void {}
332
+
333
+ protected seated(client: Client, ticket: Ticket): void {}
334
+
335
+ protected left(client: Client, ticket: Ticket): void {}
336
+ }
package/src/ticket.ts ADDED
@@ -0,0 +1,239 @@
1
+ /**
2
+ * A permission slip to sit somewhere.
3
+ *
4
+ * The venue has already done everything hard: resolved a federated address,
5
+ * checked a delegation, decided who sits in which seat. This half can do none
6
+ * of that and never needs to. It receives an assertion signed with the venue's
7
+ * own key — the same key already published in its DID document — and has only
8
+ * to check a signature.
9
+ *
10
+ * That is why there is no shared secret on the join path, and why this process
11
+ * holds no credential of any kind. It cannot impersonate the venue, cannot be
12
+ * stolen from usefully, and cannot assert anything back: everything it knows
13
+ * arrived signed by somebody else.
14
+ */
15
+
16
+ import { importMultikey } from './multikey.ts'
17
+
18
+ export interface Ticket {
19
+ /** The venue that issued it, as a DID. */
20
+ issuer: string
21
+ /** Who is sitting down, as a DID. */
22
+ subject: string
23
+ /** What to call them on screen. The venue's word, not theirs. */
24
+ name: string
25
+ /** Which room, and which seat in it. */
26
+ room: string
27
+ seat: string
28
+ /**
29
+ * Every seat the venue has filled, including this one.
30
+ *
31
+ * The venue's record rather than this room's — somebody who has closed their
32
+ * tab still holds their chair. Nothing here can work this out: a room sees
33
+ * connections, and a connection is not a seat.
34
+ */
35
+ taken: string[]
36
+ /**
37
+ * The room name of the party this person is here with, or empty.
38
+ *
39
+ * The venue's word, like everything else on a ticket. A browser that could
40
+ * say which party it was in would be a browser that could put itself in
41
+ * somebody else's.
42
+ */
43
+ party: string
44
+ }
45
+
46
+ interface Cached {
47
+ keys: Map<string, CryptoKey>
48
+ until: number
49
+ }
50
+
51
+ /**
52
+ * Documents are cached briefly, because a key rotation must actually take
53
+ * effect. Long enough that a room filling up does not re-fetch per person;
54
+ * short enough that a retired key stops working in minutes rather than at the
55
+ * next restart.
56
+ */
57
+ const DOCUMENT_SECONDS = 300
58
+
59
+ const documents = new Map<string, Cached>()
60
+
61
+ /**
62
+ * `did:web` only, and that is a deliberate limit for now.
63
+ *
64
+ * The identifier *is* the address, so resolving it needs nothing but a fetch.
65
+ * `did:plc` would mean talking to a directory, which is a dependency this
66
+ * process does not need in order to check that a venue signed something —
67
+ * venues are servers with hostnames, which is exactly what `did:web` is for.
68
+ */
69
+ function documentUrl(did: string): string {
70
+ if (!did.startsWith('did:web:')) {
71
+ throw new Error(`[${did}] is not a did:web identifier, which is all this reads.`)
72
+ }
73
+
74
+ const [host, ...path] = did.slice('did:web:'.length).split(':').map(decodeURIComponent)
75
+
76
+ return path.length === 0
77
+ ? `https://${host}/.well-known/did.json`
78
+ : `https://${host}/${path.join('/')}/did.json`
79
+ }
80
+
81
+ async function keysFor(did: string, now: number): Promise<Map<string, CryptoKey>> {
82
+ const held = documents.get(did)
83
+
84
+ if (held && held.until > now) {
85
+ return held.keys
86
+ }
87
+
88
+ const response = await fetch(documentUrl(did))
89
+
90
+ if (!response.ok) {
91
+ throw new Error(`[${did}] published no document we could read.`)
92
+ }
93
+
94
+ const document = (await response.json()) as {
95
+ id?: string
96
+ verificationMethod?: { id?: string; publicKeyMultibase?: string }[]
97
+ }
98
+
99
+ /*
100
+ * A document reached at the right address that names somebody else is not
101
+ * that identity's document, however it was reached.
102
+ */
103
+ if (document.id !== did) {
104
+ throw new Error(`The document at ${documentUrl(did)} claims to be [${document.id}].`)
105
+ }
106
+
107
+ const keys = new Map<string, CryptoKey>()
108
+
109
+ for (const method of document.verificationMethod ?? []) {
110
+ if (method.id && method.publicKeyMultibase) {
111
+ keys.set(method.id, await importMultikey(method.publicKeyMultibase))
112
+ }
113
+ }
114
+
115
+ documents.set(did, { keys, until: now + DOCUMENT_SECONDS * 1000 })
116
+
117
+ return keys
118
+ }
119
+
120
+ /**
121
+ * One segment of a compact JWS, read as JSON.
122
+ *
123
+ * Anything at all can arrive at this door, so a segment that is not base64url
124
+ * or not JSON is an ordinary refusal rather than something to throw a parser
125
+ * error about. Letting one escape would report "unexpected token" to somebody
126
+ * whose actual problem is that they have no ticket.
127
+ */
128
+ function readSegment(segment: string, called: string): unknown {
129
+ let decoded: string
130
+
131
+ try {
132
+ decoded = Buffer.from(segment, 'base64url').toString()
133
+ } catch {
134
+ throw new Error(`That ticket's ${called} is not base64url.`)
135
+ }
136
+
137
+ try {
138
+ const parsed: unknown = JSON.parse(decoded)
139
+
140
+ if (parsed === null || typeof parsed !== 'object') {
141
+ throw new Error('not an object')
142
+ }
143
+
144
+ return parsed
145
+ } catch {
146
+ throw new Error(`That ticket's ${called} is not readable.`)
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Check a ticket, and say who it seats.
152
+ *
153
+ * @param expectedRoom the room actually being joined, which is not the same as
154
+ * the room the ticket names until it has been compared
155
+ */
156
+ export async function verifyTicket(
157
+ compact: string,
158
+ expectedRoom: string,
159
+ now: number = Date.now(),
160
+ ): Promise<Ticket> {
161
+ const [header, payload, signature] = compact.split('.')
162
+
163
+ if (!header || !payload || !signature) {
164
+ throw new Error('That is not a compact JWS.')
165
+ }
166
+
167
+ const head = readSegment(header, 'header') as { alg?: string; kid?: string }
168
+
169
+ /*
170
+ * Pinned to what we are willing to check, never to what the document asks
171
+ * for. Accepting an algorithm named in an unverified header is the classic
172
+ * JOSE footgun — it lets a document choose how it will be checked.
173
+ */
174
+ if (head.alg !== 'ES256') {
175
+ throw new Error(`That ticket is signed with [${head.alg}], which is not accepted here.`)
176
+ }
177
+
178
+ if (!head.kid) {
179
+ throw new Error('That ticket names no key.')
180
+ }
181
+
182
+ const issuer = head.kid.split('#')[0]
183
+ const keys = await keysFor(issuer, now)
184
+ const key = keys.get(head.kid)
185
+
186
+ if (!key) {
187
+ throw new Error(`[${issuer}] does not publish a key called [${head.kid}].`)
188
+ }
189
+
190
+ const verified = await crypto.subtle.verify(
191
+ { name: 'ECDSA', hash: 'SHA-256' },
192
+ key,
193
+ Buffer.from(signature, 'base64url'),
194
+ Buffer.from(`${header}.${payload}`),
195
+ )
196
+
197
+ if (!verified) {
198
+ throw new Error('That ticket does not verify against the key it names.')
199
+ }
200
+
201
+ const claims = readSegment(payload, 'claims') as Record<string, unknown>
202
+
203
+ if (typeof claims.exp !== 'number' || claims.exp * 1000 < now) {
204
+ throw new Error('That ticket has expired.')
205
+ }
206
+
207
+ /*
208
+ * The room is compared rather than trusted. A ticket is issued for one place,
209
+ * and without this a ticket for a table anybody may sit at would open every
210
+ * room on the server.
211
+ */
212
+ if (claims.room !== expectedRoom) {
213
+ throw new Error(`That ticket is for [${String(claims.room)}], not [${expectedRoom}].`)
214
+ }
215
+
216
+ if (typeof claims.sub !== 'string' || claims.sub === '') {
217
+ throw new Error('That ticket seats nobody.')
218
+ }
219
+
220
+ return {
221
+ issuer,
222
+ subject: claims.sub,
223
+ name: typeof claims.name === 'string' ? claims.name : claims.sub,
224
+ room: expectedRoom,
225
+ seat: typeof claims.seat === 'string' ? claims.seat : '',
226
+
227
+ /*
228
+ * Absent on a ticket signed before venues said this, which is an empty
229
+ * roster rather than a refusal — an older venue is not a broken one.
230
+ */
231
+ taken: Array.isArray(claims.taken) ? claims.taken.filter((seat) => typeof seat === 'string') : [],
232
+
233
+ /*
234
+ * Absent on a ticket from a venue that does not do parties, which is
235
+ * nobody's party rather than a refusal — the same reading `taken` gets.
236
+ */
237
+ party: typeof claims.party === 'string' ? claims.party : '',
238
+ }
239
+ }