@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 +21 -0
- package/README.md +133 -0
- package/package.json +45 -0
- package/src/announce.ts +87 -0
- package/src/answers.ts +133 -0
- package/src/hub.ts +87 -0
- package/src/install.ts +64 -0
- package/src/mod.ts +19 -0
- package/src/multikey.ts +127 -0
- package/src/presence.ts +66 -0
- package/src/room.ts +336 -0
- package/src/ticket.ts +239 -0
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‑PHP</code>](https://github.com/StreetMesh/Protocol-PHP) | The framework-free implementation. |
|
|
127
|
+
| [<code>Protocol‑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
|
+
}
|
package/src/announce.ts
ADDED
|
@@ -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'
|
package/src/multikey.ts
ADDED
|
@@ -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
|
+
}
|
package/src/presence.ts
ADDED
|
@@ -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
|
+
}
|